import { betterAuth } from 'better-auth'; import { drizzleAdapter } from 'better-auth/adapters/drizzle'; import { db } from '@/lib/db'; /** * Better Auth server configuration. * * Sessions are stored in PostgreSQL (not Redis) per SECURITY-GUIDELINES.md §1.2. * The drizzle adapter handles session persistence via the existing `sessions` table. */ /** * In dev, allow requests from any LAN IP so the same `pnpm dev` instance can * serve both localhost (Mac) and the LAN IP (iPhone on Wi-Fi). In production, * trustedOrigins is locked down to NEXT_PUBLIC_APP_URL only. */ /** * In dev, allow localhost + any LAN-IP origin so the same `pnpm dev` instance * can serve both Mac (localhost) and iPhone-on-Wi-Fi (192.168.x.x). The * function form is preferred over a static list because the LAN IP can vary * across networks. In production, lock down to NEXT_PUBLIC_APP_URL only. */ const isProd = process.env.NODE_ENV === 'production'; const DEV_ORIGIN_PATTERNS = [ /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/, /^https?:\/\/192\.168\.\d+\.\d+(:\d+)?$/, /^https?:\/\/10\.\d+\.\d+\.\d+(:\d+)?$/, ]; const trustedOrigins: (request?: Request) => Promise = async (request) => { if (isProd) { const prodUrl = process.env.NEXT_PUBLIC_APP_URL; return prodUrl ? [prodUrl] : []; } const origin = request?.headers.get('origin') ?? ''; if (origin && DEV_ORIGIN_PATTERNS.some((re) => re.test(origin))) { return [origin]; } return ['http://localhost:3000', 'http://localhost:3001']; }; /** * `betterAuth(...)` is wrapped in a lazy initializer so the auth singleton * is constructed on first property access (i.e. first request) rather than * at module import. This is required so that Next.js's "collect page data" * phase during `pnpm build` doesn't trigger better-auth's "default secret" * check against the unset BETTER_AUTH_SECRET — at build time the auth * config is never accessed, and at runtime the env is fully populated. * * Call sites continue to use `auth.api.foo(...)` unchanged; the Proxy * intercepts the property access and resolves the real instance just-in- * time. `typeof auth.$Infer.Session` is a type-only access and never * triggers the Proxy at runtime. */ function buildAuth() { return betterAuth({ database: drizzleAdapter(db, { provider: 'pg', }), trustedOrigins, emailAndPassword: { enabled: true, minPasswordLength: 9, // Accounts are admin-created only - no self-service email verification flow. requireEmailVerification: false, // Self-service password reset for CRM users. The reset link lands // on the existing /reset-password page (which already handles // better-auth's token + new-password POST). The email send goes // through the shared SMTP infra so EMAIL_REDIRECT_TO honours it // in dev. sendResetPassword: async ({ user, url }) => { const { sendEmail } = await import('@/lib/email'); const subject = 'Reset your Port Nimara CRM password'; const html = `

Hi ${user.name || 'there'},

You requested a password reset for your Port Nimara CRM account.

Click here to set a new password — the link expires in 1 hour.

If you didn't request this, you can safely ignore this email.

`; const text = `Reset your password: ${url}`; await sendEmail(user.email, subject, html, undefined, text); }, }, session: { // Enable cookie-level session caching to reduce DB reads (5-minute cache). cookieCache: { enabled: true, maxAge: 5 * 60, }, // Absolute session lifetime: 24 hours. expiresIn: 60 * 60 * 24, // Refresh the session whenever the user is active in the last 25% of its lifetime (6h). updateAge: 60 * 60 * 6, }, advanced: { cookiePrefix: 'pn-crm', defaultCookieAttributes: { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'strict' as const, }, }, logger: { disabled: false, level: 'error' as const, }, }); } type AuthInstance = ReturnType; let _authInstance: AuthInstance | null = null; function getAuth(): AuthInstance { if (!_authInstance) _authInstance = buildAuth(); return _authInstance; } export const auth = new Proxy({} as AuthInstance, { get(_target, prop) { return Reflect.get(getAuth(), prop); }, }); export type Session = typeof auth.$Infer.Session; export type User = typeof auth.$Infer.Session.user;