TS4023 when exporting auth config (OAuthProxyOptions cannot be named) #2011

Closed
opened 2026-03-13 09:20:57 -05:00 by GiteaMirror · 2 comments
Owner

Originally created by @Qodestackr on GitHub (Sep 24, 2025).

Is this suited for github?

  • Yes, this is suited for github

To Reproduce

Install "better-auth": "^1.3.16",
Create a config file:

import { betterAuth } from "better-auth";
import { prismaAdapter } from "better-auth/adapters/prisma";
import db from "./db";

export const createAuthConfig = () => {
  return {
    secret: "test-secret",
    database: prismaAdapter(db, { provider: "postgresql" }),
    plugins: [],
  };
};

Run tsc or pnpm build

Current vs. Expected behavior

Current:
TypeScript compiler fails with TS4023 because OAuthProxyOptions is referenced internally by the plugin but not exported in a way that can be named.

Expected Behavior
The config should typecheck cleanly.
Plugin option types (like OAuthProxyOptions) should either:

  • be re-exported at the package root, or
  • be hidden/inlined so they don’t leak into the public API.

What version of Better Auth are you using?

"better-auth": "^1.3.16"

System info

{
  "system": {
    "platform": "linux",
    "arch": "x64",
    "version": "#29-Ubuntu SMP PREEMPT_DYNAMIC Thu Aug  7 18:32:38 UTC 2025",
    "release": "6.14.0-29-generic",
    "cpuCount": 4,
    "cpuModel": "Intel(R) Core(TM) i5-4310U CPU @ 2.00GHz",
    "totalMemory": "11.09 GB",
    "freeMemory": "3.97 GB"
  },
  "node": {
    "version": "v22.16.0",
    "env": "development"
  },
  "packageManager": {
    "name": "npm",
    "version": "10.9.2"
  },
  "frameworks": null,
  "databases": null,
  "betterAuth": {
    "version": "^1.3.16",
    "config": null
  }
}

Which area(s) are affected? (Select all that apply)

Types

Auth config (if applicable)

import { betterAuth } from "better-auth"
export const auth = betterAuth({
  emailAndPassword: {  
    enabled: true
  },
});

Additional context

No response

Originally created by @Qodestackr on GitHub (Sep 24, 2025). ### Is this suited for github? - [x] Yes, this is suited for github ### To Reproduce Install "better-auth": "^1.3.16", Create a config file: ```ts import { betterAuth } from "better-auth"; import { prismaAdapter } from "better-auth/adapters/prisma"; import db from "./db"; export const createAuthConfig = () => { return { secret: "test-secret", database: prismaAdapter(db, { provider: "postgresql" }), plugins: [], }; }; ``` Run tsc or pnpm build ### Current vs. Expected behavior Current: TypeScript compiler fails with TS4023 because OAuthProxyOptions is referenced internally by the plugin but not exported in a way that can be named. Expected Behavior The config should typecheck cleanly. Plugin option types (like OAuthProxyOptions) should either: - be re-exported at the package root, or - be hidden/inlined so they don’t leak into the public API. ### What version of Better Auth are you using? "better-auth": "^1.3.16" ### System info ```bash { "system": { "platform": "linux", "arch": "x64", "version": "#29-Ubuntu SMP PREEMPT_DYNAMIC Thu Aug 7 18:32:38 UTC 2025", "release": "6.14.0-29-generic", "cpuCount": 4, "cpuModel": "Intel(R) Core(TM) i5-4310U CPU @ 2.00GHz", "totalMemory": "11.09 GB", "freeMemory": "3.97 GB" }, "node": { "version": "v22.16.0", "env": "development" }, "packageManager": { "name": "npm", "version": "10.9.2" }, "frameworks": null, "databases": null, "betterAuth": { "version": "^1.3.16", "config": null } } ``` ### Which area(s) are affected? (Select all that apply) Types ### Auth config (if applicable) ```typescript import { betterAuth } from "better-auth" export const auth = betterAuth({ emailAndPassword: { enabled: true }, }); ``` ### Additional context _No response_
Author
Owner

@dosubot[bot] commented on GitHub (Sep 24, 2025):

The TS4023 error happens because the OAuthProxyOptions type used by the OAuth Proxy plugin isn't exported at the package root, so TypeScript can't name it when you export configs or functions that reference it. This is a known issue with Better Auth plugins—similar problems were fixed for other plugins by explicitly exporting their option types, like OrganizationOptions for the organization plugin (example PR).

For now, you can work around this by explicitly typing your exported config or auth instance using ReturnType, like:

export const auth: ReturnType<typeof betterAuth> = betterAuth({
  // your config
});

or, if you want to keep your config separate:

const config = { /* ... */ } satisfies BetterAuthOptions;
export const auth = betterAuth(config) as ReturnType<typeof betterAuth<typeof config>>;

This avoids the TS4023 error and keeps your types safe without changing your tsconfig or disabling declarations (reference, reference).

A long-term fix would be for the package to export OAuthProxyOptions at the plugin or package root, just like was done for other plugins (reference).

To reply, just mention @dosu.


How did I do? Good | Irrelevant | Incorrect | Verbose | Hallucination | Report 🐛 | Other  Chat with Dosu Join Discord Share on X

@dosubot[bot] commented on GitHub (Sep 24, 2025): <!-- Answer --> The TS4023 error happens because the OAuthProxyOptions type used by the OAuth Proxy plugin isn't exported at the package root, so TypeScript can't name it when you export configs or functions that reference it. This is a known issue with Better Auth plugins—similar problems were fixed for other plugins by explicitly exporting their option types, like OrganizationOptions for the organization plugin ([example PR](https://github.com/better-auth/better-auth/pull/3546)). For now, you can work around this by explicitly typing your exported config or auth instance using ReturnType, like: ```ts export const auth: ReturnType<typeof betterAuth> = betterAuth({ // your config }); ``` or, if you want to keep your config separate: ```ts const config = { /* ... */ } satisfies BetterAuthOptions; export const auth = betterAuth(config) as ReturnType<typeof betterAuth<typeof config>>; ``` This avoids the TS4023 error and keeps your types safe without changing your tsconfig or disabling declarations ([reference](https://github.com/better-auth/better-auth/issues/3493), [reference](https://github.com/better-auth/better-auth/issues/4250)). A long-term fix would be for the package to export OAuthProxyOptions at the plugin or package root, just like was done for other plugins ([reference](https://github.com/better-auth/better-auth/pull/3546)). <!-- Dosu Comment Footer --> *To reply, just mention [@dosu](https://go.dosu.dev/dosubot).* --- <sup>How did I do? [Good](https://app.dosu.dev/response-feedback/70f706d0-03cb-4b81-b554-89982a62d798?feedback_type=great_response) | [Irrelevant](https://app.dosu.dev/response-feedback/70f706d0-03cb-4b81-b554-89982a62d798?feedback_type=irrelevant_answer) | [Incorrect](https://app.dosu.dev/response-feedback/70f706d0-03cb-4b81-b554-89982a62d798?feedback_type=incorrect_sources) | [Verbose](https://app.dosu.dev/response-feedback/70f706d0-03cb-4b81-b554-89982a62d798?feedback_type=too_verbose) | [Hallucination](https://app.dosu.dev/response-feedback/70f706d0-03cb-4b81-b554-89982a62d798?feedback_type=hallucination) | [Report 🐛](https://app.dosu.dev/response-feedback/70f706d0-03cb-4b81-b554-89982a62d798?feedback_type=bug_report) | [Other](https://app.dosu.dev/response-feedback/70f706d0-03cb-4b81-b554-89982a62d798?feedback_type=other)</sup>&nbsp;&nbsp;[![Chat with Dosu](https://dosu.dev/dosu-chat-badge.svg)](https://app.dosu.dev/cdda13d9-dd27-4d31-b09a-5d8bec92de21/ask?utm_source=github)&nbsp;[![Join Discord](https://img.shields.io/badge/join-5865F2?logo=discord&logoColor=white&label=)](https://go.dosu.dev/discord-bot)&nbsp;[![Share on X](https://img.shields.io/badge/X-share-black)](https://twitter.com/intent/tweet?text=%40dosu_ai%20helped%20me%20solve%20this%20issue!&url=https%3A//github.com/better-auth/better-auth/issues/4874)
Author
Owner

@Qodestackr commented on GitHub (Sep 24, 2025):

This is the failing code:

import { betterAuth, logger, type BetterAuthOptions } from "better-auth"
import { prismaAdapter } from "better-auth/adapters/prisma"
import {
  admin as adminPlugin,
  multiSession, organization,
  customSession,
  phoneNumber
} from "better-auth/plugins"
import { nextCookies } from "better-auth/next-js"
import db from "@workspace/db"
import { mapUserRoleToBusiness } from "./role-management"
import { ac, roles } from "./permissions"
import { APP_BASE_URL, generateId } from "@workspace/utils"
import { setupSaleorResourcesForNewUser } from "@workspace/coremmerce/server-only"
import { PaymentMethodType } from "@workspace/db/enums"
import { emailService } from "@workspace/notifications"
import { reverify } from "./reverify"

const ALLOWED_ROLES = ["retailer", "wholesaler", "distributor", "user", "driver", "brand_owner"] as const
const businessRoles = new Set(["distributor", "retailer", "wholesaler", "brand_owner"])
const staffRoles = new Set(["user"])

const AUTH_CONFIG = {
  trustedOrigins: [
    "http://localhost:3000",
    //.....
  ],
  appName: "Alcorabooks",
  allowedRoles: ALLOWED_ROLES,
}

let _authInstance: ReturnType<typeof betterAuth> | null = null;

function createAuthConfig(): BetterAuthOptions {
  return {
    trustedOrigins: AUTH_CONFIG.trustedOrigins,
    appName: AUTH_CONFIG.appName,
    secret: process.env.BETTER_AUTH_SECRET,
    database: prismaAdapter(db, {
      provider: "postgresql",
    }),
    logger: {
      level: 'debug'
    },
    emailAndPassword: {
      enabled: true,
      requireEmailVerification: false,
      minPasswordLength: 8,
      maxPasswordLength: 128,
      autoSignIn: true,
    },
    emailVerification: {
      sendVerificationEmail: async ({ user, url, token }) => {
        if (!user?.email) {
          logger.warn(`⚠️ No email found for user ${user?.id}, skipping signup emails`);
          return;
        }

        try {
          await emailService.sendWelcomeEmail({
            email: user.email,
            name: user.name,
            organizationName: `${user.name}'s organization`,
            loginUrl: `${APP_BASE_URL}/sign-in`,
          });

          // await emailService.sendVerificationEmail({
          //   email: user.email,
          //   verificationUrl: url,
          //   userName: user.name,
          //   token,
          // });

          logger.info(`✅ Welcome + verification emails sent to ${user.email}`);
        } catch (err) {
          logger.error(`❌ Failed to send signup emails to ${user.email}:`, err);
        }
      },
      sendOnSignUp: true,
      autoSignInAfterVerification: true,
      expiresIn: 3600 // 1 hour
    },
    socialProviders: {
      google: {
        clientId: process.env.GOOGLE_CLIENT_ID,
        clientSecret: process.env.GOOGLE_CLIENT_SECRET,
        accessType: "offline",
        // prompt: "select_account consent",
      },
    },
    user: {
      additionalFields: {
        role: {
          type: "string",
          required: true,
          defaultValue: "user",
          input: true,
        },
        // Track the original business type selection (including "staff")
        businessType: {
          type: "string",
          required: false,
          input: true,
        },
        premium: {
          type: "boolean",
          required: false,
          defaultValue: false,
          input: false,
        },
        // Phone number for SMS notifications not from better-auth auth plugins
        // @ref https://www.better-auth.com/docs/plugins/phone-number
        phoneNumber: {
          type: "string",
          required: false,
          input: true,
        },
        enableSMS: {
          type: "boolean",
          required: false,
          defaultValue: true,
          input: true,
        },

      },
    },
    session: {
      cookieCache: {
        enabled: true,
        maxAge: 60 * 60 * 24 * 7,
      },
      expiresIn: 60 * 60 * 24 * 7,
      updateAge: 60 * 60 * 24,
      additionalFields: {
        saleorChannelId: { type: "string", required: false },
        saleorChannelSlug: { type: "string", required: false },
        organizationId: { type: "string", required: false },
        activeOrganizationId: { type: "string", required: false },
        organizationName: { type: "string", required: false },
        organizationSlug: { type: "string", required: false },
        businessType: { type: "string", required: false },
        warehouseId: { type: "string", required: false },
      },
    },
    databaseHooks: {
      user: {
        create: {
          before: async (user, ctx) => {
            const role = ctx?.body?.additionalFields?.role ?? "user"
            const businessType = ctx?.body?.additionalFields?.businessType

            if (!ALLOWED_ROLES.includes(role)) {
              throw new Error("Invalid role assignment")
            }

            return {
              data: {
                ...user,
                role,
                businessType // Store the original selection for tracking
              }
            }
          },
        },
      },
      session: {
        create: {
          before: async (session, ctx) => {
            const isSignUp = ctx?.path?.startsWith("/sign-up")
            if (!isSignUp) return { data: session }

            return db.$transaction(async (tx) => {
              const user = await tx.user.findUnique({ where: { id: session.userId } })
              if (!user) return { data: session }

              // 🔥 KEY CHANGE: Check if user is staff (businessType === "staff")
              // Staff users should NOT get organizations created automatically
              const isStaffUser = ctx?.body?.additionalFields?.businessType === "staff"

              if (isStaffUser) {
                logger.info(`👤 Staff user ${user.id} signed up - skipping organization creation`)
                return { data: session } // No org creation for staff
              }

              // Only create orgs for actual business roles
              if (!businessRoles.has(user.role)) return { data: session }

              // Check if org already exists
              const existing = await tx.member.findFirst({
                where: { userId: user.id },
                include: { organization: true }
              })
              if (existing) {
                return { data: { ...session, activeOrganizationId: existing.organization.id } }
              }

              // 2️⃣ Create organization for business users (not staff)
              if (ctx?.path?.startsWith("/sign-up")) {
                // create organization + membership
                const org = await tx.organization.create({
                  data: {
                    name: `${user.name}'s Organization`,
                    slug: generateId(),
                    businessType: mapUserRoleToBusiness(user.role),
                    paymentMethod: PaymentMethodType.MPESA,
                    members: { create: { userId: user.id, role: "owner" } },
                  }
                })

                logger.info(`✅ Organization created synchronously: ${org.id}`)
                return { data: { ...session, activeOrganizationId: org.id } }
              }
              return { data: session }
            })
          },
          after: async (session, ctx) => {
            const isSignUp = ctx?.path.startsWith("/sign-up")
            const orgId = (session as any).activeOrganizationId
            if (!isSignUp || !orgId) return

            const businessType = ctx?.body?.additionalFields?.businessType

            if (businessType === "staff") {
              logger.info(`👤 Staff user ${session.userId} - skipping Saleor setup`)
              return // No Saleor resources for staff
            }

            // TODO: enqueueSaleorJob
            try {
              console.log("Starting Saleor environment setup...")
              // Setup Saleor resources asynchronously - only for business users
              await setupSaleorResourcesForNewUser(
                session.userId,
                orgId,
                session.id
              )
              logger.info("✅ Provisioned: Saleor resources")
            } catch (err) {
              console.error("❌ Error setting up Saleor resources:", err)
              // Don't throw - let the user continue even if Saleor setup fails
            }
          },
        },
      },
    },
    plugins: [
      adminPlugin({
        ac: ac,
        roles,
        adminRoles: ["owner", "admin"],
        impersonationSessionDuration: 60 * 60 * 24 * 7,
      }),
      multiSession(),
      nextCookies(),
      phoneNumber({
        allowedAttempts: 3,
        sendOTP: async ({ phoneNumber, code }, request) => {
          console.log(`Sending OTP ${code} to ${phoneNumber}`)

          try {
            // Check if user exists and has a real email (not temp email)
            const existingUser = await db.user.findFirst({
              where: {
                phoneNumber: phoneNumber,
                NOT: {
                  email: {
                    endsWith: '@alcorabooks.com' // Skip temp emails
                  }
                }
              },
              select: { email: true, name: true }
            })

            if (existingUser && existingUser.email) {
              // User exists with real email - send OTP via email instead of SMS
              console.log(`Sending OTP via email to ${existingUser.email} instead of SMS to ${phoneNumber}`)
              // await emailService.sendEmail({
              //   email: existingUser.email,
              //   name: existingUser.name || 'User',
              //   otp: code,
              //   phoneNumber: phoneNumber,
              // })
              console.log("✅ OTP sent via email (cost saving)")
            } else {
              // New user or user with temp email - send SMS as usual
              console.log(`Sending OTP via SMS to ${phoneNumber} (new user signup)`)

              // Your existing SMS logic here
              console.log("AT SMS sent:")
            }
          } catch (err) {
            console.error("OTP sending failed:", err)
            // Fallback to SMS if email fails
            if ((err as any).message?.includes('email')) {
              console.log("Email failed, falling back to SMS")
              // Your SMS fallback logic here
            }
          }
        },
        signUpOnVerification: {
          getTempEmail: (phoneNumber) => {
            return `${phoneNumber}@alcorabooks.com`
          },
          getTempName: (phoneNumber) => {
            return phoneNumber
          },
        },
        callbackOnVerification: async ({ phoneNumber, user }, request) => {
          // After successful verification
          if (user) {
            console.log(`Login successful for ${phoneNumber}`)
            // Update last login or any other login logic
          }
        },
        otpLength: 4,
        expiresIn: 60 * 5, //5mins
      }),
      customSession(async ({ user, session }) => {
        let orgId = (session as any).activeOrganizationId

        if (!orgId) {
          const firstMembership = await db.member.findFirst({
            where: { userId: user.id },
            select: { organizationId: true },
          })
          orgId = firstMembership?.organizationId ?? null
        }
        const org = orgId
          ? await db.organization.findUnique({
            where: { id: orgId },
            select: {
              id: true, name: true, slug: true, businessType: true,
              saleorChannels: { where: { isActive: true }, take: 1, select: { saleorChannelId: true, slug: true } },
              warehouses: { take: 1, select: { saleorWarehouseId: true } }
            }
          })
          : null

        return {
          ...session,
          user,
          role: (user as any).role,
          organizationId: org?.id,
          organizationName: org?.name,
          organizationSlug: org?.slug,
          businessType: org?.businessType,
          saleorChannelId: org?.saleorChannels[0]?.saleorChannelId,
          saleorChannelSlug: org?.saleorChannels[0]?.slug,
          warehouseId: org?.warehouses[0]?.saleorWarehouseId
        }
      }),
      organization({
        allowUserToCreateOrganization: true,
        membershipLimit: 200_0,
        organizationLimit: 10,
        cancelPendingInvitationsOnReInvite: true,
        requireEmailVerificationOnInvitation: false,
        ac: ac,
        roles: {
          owner: roles.owner,
          admin: roles.admin,
          wholesaler: roles.wholesaler,
          retailer: roles.retailer,
          bartender: roles.bartender,
          cashier: roles.cashier,
          finance: roles.finance,
          driver: roles.driver,
        },
        async sendInvitationEmail(data) {
          try {
            await emailService.sendUserInviteEmail({
              email: data.email,
              inviterName: data.inviter.user.name || 'Someone',
              inviteeName: data.email.split('@')[0] || data.email,
              organizationName: data.organization.name,
              role: Array.isArray(data.role) ? data.role.join(', ') : data.role,
              inviteToken: data.id,
              expiresIn: '48 hours', // TODO: calc from data.expiresAt
            });

            logger.info(`✅ Invitation email sent to ${data.email} for organization ${data.organization.name}`);
          } catch (error) {
            logger.error(`❌ Failed to send invitation email to ${data.email}:`, error);
            throw error; // Re-throw: Better Auth to handle the err
          }
        },
        schema: {
          organization: {
            fields: {
              // Define how organization.metadata maps to our custom fields
              metadata: "metadata",
            },
            additionalFields: {
              businessType: {
                type: "string",
                required: false,
                defaultValue: "",
              },
              description: {
                type: "string",
                required: false,
              },
              logo: {
                type: "string",
                required: false,
              },
              city: {
                type: "string",
                required: false,
              },
              address: {
                type: "string",
                required: false,
              },
              phoneNumber: {
                type: "string",
                required: false,
              },
              enableSMS: {
                type: "boolean",
                required: false,
                defaultValue: true,
              },
              paymentMethod: {
                type: "string",
                required: false,
              },
              subscriptionPlan: {
                type: "string",
                required: false,
              },
              onboardingComplete: {
                type: "boolean",
                required: false,
                defaultValue: false,
              },
              channel: {
                type: "string",
                required: false,
                defaultValue: true,
              },
            },
          },
        },
        organizationCreation: {
          beforeCreate: async ({ organization, user }) => {
            console.log("Inside organizationCreation.beforeCreate", organization)
            const extendedOrg = {
              ...organization,
              slug: organization.slug || generateId(),
            }
            console.log("ext-org.user", extendedOrg, user, user.id)
            return {
              data: extendedOrg,
            }
          },
          afterCreate: async ({ organization, user }) => {
            console.log("Inside organizationCreation.beforeCreate", organization)
            // TODO: Attach Saleor resources
            console.log(organization, "afterCreate", user)
          },
        },
      }),
      reverify()
    ],
  } satisfies BetterAuthOptions
}

export function createAuth() {
  if (_authInstance) {
    return _authInstance;
  }

  try {
    const config = createAuthConfig();
    _authInstance = betterAuth(config);
    return _authInstance;
  } catch (error) {
    console.error('[createAuth] Failed to initialize auth:', error);
    throw error;
  }
}

// // Lazy getter for backward compatibility
// export const auth = new Proxy({} as ReturnType<typeof betterAuth>, {
//   get(target, prop) {
//     const authInstance = createAuth();
//     return authInstance[prop as keyof typeof authInstance];
//   }
// });

export const auth = new Proxy({} as any, {
  get(_, prop) {
    const authInstance = createAuth()
    return (authInstance as any)[prop]
  },
})

export type Auth = ReturnType<typeof createAuth>;
@Qodestackr commented on GitHub (Sep 24, 2025): This is the failing code: ```ts import { betterAuth, logger, type BetterAuthOptions } from "better-auth" import { prismaAdapter } from "better-auth/adapters/prisma" import { admin as adminPlugin, multiSession, organization, customSession, phoneNumber } from "better-auth/plugins" import { nextCookies } from "better-auth/next-js" import db from "@workspace/db" import { mapUserRoleToBusiness } from "./role-management" import { ac, roles } from "./permissions" import { APP_BASE_URL, generateId } from "@workspace/utils" import { setupSaleorResourcesForNewUser } from "@workspace/coremmerce/server-only" import { PaymentMethodType } from "@workspace/db/enums" import { emailService } from "@workspace/notifications" import { reverify } from "./reverify" const ALLOWED_ROLES = ["retailer", "wholesaler", "distributor", "user", "driver", "brand_owner"] as const const businessRoles = new Set(["distributor", "retailer", "wholesaler", "brand_owner"]) const staffRoles = new Set(["user"]) const AUTH_CONFIG = { trustedOrigins: [ "http://localhost:3000", //..... ], appName: "Alcorabooks", allowedRoles: ALLOWED_ROLES, } let _authInstance: ReturnType<typeof betterAuth> | null = null; function createAuthConfig(): BetterAuthOptions { return { trustedOrigins: AUTH_CONFIG.trustedOrigins, appName: AUTH_CONFIG.appName, secret: process.env.BETTER_AUTH_SECRET, database: prismaAdapter(db, { provider: "postgresql", }), logger: { level: 'debug' }, emailAndPassword: { enabled: true, requireEmailVerification: false, minPasswordLength: 8, maxPasswordLength: 128, autoSignIn: true, }, emailVerification: { sendVerificationEmail: async ({ user, url, token }) => { if (!user?.email) { logger.warn(`⚠️ No email found for user ${user?.id}, skipping signup emails`); return; } try { await emailService.sendWelcomeEmail({ email: user.email, name: user.name, organizationName: `${user.name}'s organization`, loginUrl: `${APP_BASE_URL}/sign-in`, }); // await emailService.sendVerificationEmail({ // email: user.email, // verificationUrl: url, // userName: user.name, // token, // }); logger.info(`✅ Welcome + verification emails sent to ${user.email}`); } catch (err) { logger.error(`❌ Failed to send signup emails to ${user.email}:`, err); } }, sendOnSignUp: true, autoSignInAfterVerification: true, expiresIn: 3600 // 1 hour }, socialProviders: { google: { clientId: process.env.GOOGLE_CLIENT_ID, clientSecret: process.env.GOOGLE_CLIENT_SECRET, accessType: "offline", // prompt: "select_account consent", }, }, user: { additionalFields: { role: { type: "string", required: true, defaultValue: "user", input: true, }, // Track the original business type selection (including "staff") businessType: { type: "string", required: false, input: true, }, premium: { type: "boolean", required: false, defaultValue: false, input: false, }, // Phone number for SMS notifications not from better-auth auth plugins // @ref https://www.better-auth.com/docs/plugins/phone-number phoneNumber: { type: "string", required: false, input: true, }, enableSMS: { type: "boolean", required: false, defaultValue: true, input: true, }, }, }, session: { cookieCache: { enabled: true, maxAge: 60 * 60 * 24 * 7, }, expiresIn: 60 * 60 * 24 * 7, updateAge: 60 * 60 * 24, additionalFields: { saleorChannelId: { type: "string", required: false }, saleorChannelSlug: { type: "string", required: false }, organizationId: { type: "string", required: false }, activeOrganizationId: { type: "string", required: false }, organizationName: { type: "string", required: false }, organizationSlug: { type: "string", required: false }, businessType: { type: "string", required: false }, warehouseId: { type: "string", required: false }, }, }, databaseHooks: { user: { create: { before: async (user, ctx) => { const role = ctx?.body?.additionalFields?.role ?? "user" const businessType = ctx?.body?.additionalFields?.businessType if (!ALLOWED_ROLES.includes(role)) { throw new Error("Invalid role assignment") } return { data: { ...user, role, businessType // Store the original selection for tracking } } }, }, }, session: { create: { before: async (session, ctx) => { const isSignUp = ctx?.path?.startsWith("/sign-up") if (!isSignUp) return { data: session } return db.$transaction(async (tx) => { const user = await tx.user.findUnique({ where: { id: session.userId } }) if (!user) return { data: session } // 🔥 KEY CHANGE: Check if user is staff (businessType === "staff") // Staff users should NOT get organizations created automatically const isStaffUser = ctx?.body?.additionalFields?.businessType === "staff" if (isStaffUser) { logger.info(`👤 Staff user ${user.id} signed up - skipping organization creation`) return { data: session } // No org creation for staff } // Only create orgs for actual business roles if (!businessRoles.has(user.role)) return { data: session } // Check if org already exists const existing = await tx.member.findFirst({ where: { userId: user.id }, include: { organization: true } }) if (existing) { return { data: { ...session, activeOrganizationId: existing.organization.id } } } // 2️⃣ Create organization for business users (not staff) if (ctx?.path?.startsWith("/sign-up")) { // create organization + membership const org = await tx.organization.create({ data: { name: `${user.name}'s Organization`, slug: generateId(), businessType: mapUserRoleToBusiness(user.role), paymentMethod: PaymentMethodType.MPESA, members: { create: { userId: user.id, role: "owner" } }, } }) logger.info(`✅ Organization created synchronously: ${org.id}`) return { data: { ...session, activeOrganizationId: org.id } } } return { data: session } }) }, after: async (session, ctx) => { const isSignUp = ctx?.path.startsWith("/sign-up") const orgId = (session as any).activeOrganizationId if (!isSignUp || !orgId) return const businessType = ctx?.body?.additionalFields?.businessType if (businessType === "staff") { logger.info(`👤 Staff user ${session.userId} - skipping Saleor setup`) return // No Saleor resources for staff } // TODO: enqueueSaleorJob try { console.log("Starting Saleor environment setup...") // Setup Saleor resources asynchronously - only for business users await setupSaleorResourcesForNewUser( session.userId, orgId, session.id ) logger.info("✅ Provisioned: Saleor resources") } catch (err) { console.error("❌ Error setting up Saleor resources:", err) // Don't throw - let the user continue even if Saleor setup fails } }, }, }, }, plugins: [ adminPlugin({ ac: ac, roles, adminRoles: ["owner", "admin"], impersonationSessionDuration: 60 * 60 * 24 * 7, }), multiSession(), nextCookies(), phoneNumber({ allowedAttempts: 3, sendOTP: async ({ phoneNumber, code }, request) => { console.log(`Sending OTP ${code} to ${phoneNumber}`) try { // Check if user exists and has a real email (not temp email) const existingUser = await db.user.findFirst({ where: { phoneNumber: phoneNumber, NOT: { email: { endsWith: '@alcorabooks.com' // Skip temp emails } } }, select: { email: true, name: true } }) if (existingUser && existingUser.email) { // User exists with real email - send OTP via email instead of SMS console.log(`Sending OTP via email to ${existingUser.email} instead of SMS to ${phoneNumber}`) // await emailService.sendEmail({ // email: existingUser.email, // name: existingUser.name || 'User', // otp: code, // phoneNumber: phoneNumber, // }) console.log("✅ OTP sent via email (cost saving)") } else { // New user or user with temp email - send SMS as usual console.log(`Sending OTP via SMS to ${phoneNumber} (new user signup)`) // Your existing SMS logic here console.log("AT SMS sent:") } } catch (err) { console.error("OTP sending failed:", err) // Fallback to SMS if email fails if ((err as any).message?.includes('email')) { console.log("Email failed, falling back to SMS") // Your SMS fallback logic here } } }, signUpOnVerification: { getTempEmail: (phoneNumber) => { return `${phoneNumber}@alcorabooks.com` }, getTempName: (phoneNumber) => { return phoneNumber }, }, callbackOnVerification: async ({ phoneNumber, user }, request) => { // After successful verification if (user) { console.log(`Login successful for ${phoneNumber}`) // Update last login or any other login logic } }, otpLength: 4, expiresIn: 60 * 5, //5mins }), customSession(async ({ user, session }) => { let orgId = (session as any).activeOrganizationId if (!orgId) { const firstMembership = await db.member.findFirst({ where: { userId: user.id }, select: { organizationId: true }, }) orgId = firstMembership?.organizationId ?? null } const org = orgId ? await db.organization.findUnique({ where: { id: orgId }, select: { id: true, name: true, slug: true, businessType: true, saleorChannels: { where: { isActive: true }, take: 1, select: { saleorChannelId: true, slug: true } }, warehouses: { take: 1, select: { saleorWarehouseId: true } } } }) : null return { ...session, user, role: (user as any).role, organizationId: org?.id, organizationName: org?.name, organizationSlug: org?.slug, businessType: org?.businessType, saleorChannelId: org?.saleorChannels[0]?.saleorChannelId, saleorChannelSlug: org?.saleorChannels[0]?.slug, warehouseId: org?.warehouses[0]?.saleorWarehouseId } }), organization({ allowUserToCreateOrganization: true, membershipLimit: 200_0, organizationLimit: 10, cancelPendingInvitationsOnReInvite: true, requireEmailVerificationOnInvitation: false, ac: ac, roles: { owner: roles.owner, admin: roles.admin, wholesaler: roles.wholesaler, retailer: roles.retailer, bartender: roles.bartender, cashier: roles.cashier, finance: roles.finance, driver: roles.driver, }, async sendInvitationEmail(data) { try { await emailService.sendUserInviteEmail({ email: data.email, inviterName: data.inviter.user.name || 'Someone', inviteeName: data.email.split('@')[0] || data.email, organizationName: data.organization.name, role: Array.isArray(data.role) ? data.role.join(', ') : data.role, inviteToken: data.id, expiresIn: '48 hours', // TODO: calc from data.expiresAt }); logger.info(`✅ Invitation email sent to ${data.email} for organization ${data.organization.name}`); } catch (error) { logger.error(`❌ Failed to send invitation email to ${data.email}:`, error); throw error; // Re-throw: Better Auth to handle the err } }, schema: { organization: { fields: { // Define how organization.metadata maps to our custom fields metadata: "metadata", }, additionalFields: { businessType: { type: "string", required: false, defaultValue: "", }, description: { type: "string", required: false, }, logo: { type: "string", required: false, }, city: { type: "string", required: false, }, address: { type: "string", required: false, }, phoneNumber: { type: "string", required: false, }, enableSMS: { type: "boolean", required: false, defaultValue: true, }, paymentMethod: { type: "string", required: false, }, subscriptionPlan: { type: "string", required: false, }, onboardingComplete: { type: "boolean", required: false, defaultValue: false, }, channel: { type: "string", required: false, defaultValue: true, }, }, }, }, organizationCreation: { beforeCreate: async ({ organization, user }) => { console.log("Inside organizationCreation.beforeCreate", organization) const extendedOrg = { ...organization, slug: organization.slug || generateId(), } console.log("ext-org.user", extendedOrg, user, user.id) return { data: extendedOrg, } }, afterCreate: async ({ organization, user }) => { console.log("Inside organizationCreation.beforeCreate", organization) // TODO: Attach Saleor resources console.log(organization, "afterCreate", user) }, }, }), reverify() ], } satisfies BetterAuthOptions } export function createAuth() { if (_authInstance) { return _authInstance; } try { const config = createAuthConfig(); _authInstance = betterAuth(config); return _authInstance; } catch (error) { console.error('[createAuth] Failed to initialize auth:', error); throw error; } } // // Lazy getter for backward compatibility // export const auth = new Proxy({} as ReturnType<typeof betterAuth>, { // get(target, prop) { // const authInstance = createAuth(); // return authInstance[prop as keyof typeof authInstance]; // } // }); export const auth = new Proxy({} as any, { get(_, prop) { const authInstance = createAuth() return (authInstance as any)[prop] }, }) export type Auth = ReturnType<typeof createAuth>; ```
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/better-auth#2011