import { randomUUID } from "node:crypto"; import { generateReferralCode, normalizeName } from "@sase/shared"; import { betterAuth } from "better-auth"; import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { apiKey, captcha } from "better-auth/plugins"; import { eq } from "drizzle-orm"; import { drizzle } from "drizzle-orm/postgres-js"; import postgres from "postgres"; import * as schema from "../database/schema/core"; import { EmailService } from "../email/email.service"; import type { NovuService } from "../notifications/novu.service"; /** * Generates a referral code that doesn't collide with an existing one. The code * lands in a unique-indexed column, so a raw collision would otherwise fail the * whole signup. Retries a few times, then falls back to a longer code. */ async function generateUniqueReferralCode( db: ReturnType>, ): Promise { for (let i = 0; i < 5; i++) { const code = generateReferralCode(); const existing = await db .select({ id: schema.users.id }) .from(schema.users) .where(eq(schema.users.referralCode, code)) .limit(1); if (existing.length === 0) return code; } // Astronomically unlikely to reach here; widen the code to defuse collisions. return `${generateReferralCode()}${generateReferralCode().slice(0, 4)}`; } let authInstance: ReturnType | null = null; interface SocialCredentials { googleClientId?: string; googleClientSecret?: string; } interface AuthOptions { social?: SocialCredentials; emailService?: EmailService; /** Lifecycle/transactional e-mail via Novu. Preferred over emailService. */ novu?: NovuService; /** Called after a user's email is verified (referral qualification, etc.). */ onEmailVerified?: (userId: string) => Promise; /** Server-side Meta CAPI sender — reliable signup backstop for BOTH email and * Google OAuth (the browser pixel misses OAuth and is ad-blocked on mobile). */ metaCapi?: { sendCompleteRegistration: (input: { userId: string; email?: string | null }) => Promise; }; } export function createAuth( databaseUrl: string, secret: string, baseUrl: string, options?: AuthOptions, ) { if (authInstance) return authInstance; const client = postgres(databaseUrl, { max: 5 }); const db = drizzle(client, { schema }); authInstance = betterAuth({ database: drizzleAdapter(db, { provider: "pg", usePlural: true, }), advanced: { database: { generateId: () => randomUUID(), }, }, secret, baseURL: baseUrl, basePath: "/api/auth", emailAndPassword: { enabled: true, minPasswordLength: 8, sendResetPassword: async (data) => { if (options?.novu) { await options.novu.passwordReset( { id: data.user.id, email: data.user.email, name: data.user.name }, data.url, ); } else if (options?.emailService) { await options.emailService.sendPasswordReset(data.user.email, data.url); } else { console.log(`[DEV] Password reset URL for ${data.user.email}: ${data.url}`); } }, }, emailVerification: { sendVerificationEmail: async (data) => { // better-auth bakes the sign-up `callbackURL` (the deep-linked search/VIN // page) into data.url. Override it so verifying lands the user on a clear // confirmation page instead of silently dumping them on a vehicle search. let verificationUrl = data.url; try { const parsed = new URL(data.url); parsed.searchParams.set("callbackURL", "/email-verified"); verificationUrl = parsed.toString(); } catch { // Non-absolute URL (shouldn't happen) — fall back to the original. } if (options?.novu) { await options.novu.emailVerification( { id: data.user.id, email: data.user.email, name: data.user.name }, verificationUrl, ); } else if (options?.emailService) { await options.emailService.sendEmailVerification(data.user.email, verificationUrl); } else { console.log(`[DEV] Verification URL for ${data.user.email}: ${verificationUrl}`); } }, afterEmailVerification: async (user) => { // Unlock referral rewards once the referred user proves their email. // Never let a failure here break the verification flow. if (options?.onEmailVerified) { try { await options.onEmailVerified(user.id); } catch (err) { console.error("[auth] onEmailVerified hook failed:", err); } } }, sendOnSignUp: true, }, ...(options?.social?.googleClientId && options?.social?.googleClientSecret ? { socialProviders: { google: { clientId: options.social.googleClientId, clientSecret: options.social.googleClientSecret, }, }, } : {}), databaseHooks: { user: { create: { before: async (userData) => { // Postal logs show signup names arrive in every casing (`mehmet`, // `MEHMET`, `İLKER`, `OTO`) and we render them straight into mail // subjects — `Sase.tr'ye hoş geldin, mehmet` looks unprofessional. // Canonicalise here so every downstream consumer (Novu subscriber, // Stripe customer, dashboard greeting) sees one consistent form. // Turkish-locale-aware (İ/ı handled). const cleanedName = normalizeName(userData.name); return { data: { ...userData, ...(cleanedName ? { name: cleanedName } : {}), referralCode: await generateUniqueReferralCode(db), }, }; }, after: async (user) => { // Server-side Meta CAPI signup event — reliable for BOTH email and // Google OAuth (the browser pixel misses OAuth and is ad-blocked on // mobile in-app). Deduped with the pixel/endpoint via // event_id=signup_. Fire-and-forget; never break signup. void options?.metaCapi?.sendCompleteRegistration({ userId: user.id, email: user.email, }); // Fire-and-forget lifecycle e-mails on signup (covers password + // OAuth). Never await / never let a notification failure surface // into the signup response. `referral` is delay-stepped in Novu // (sent 3 days later); `welcome` goes out immediately. if (!options?.novu) return; const nu = { id: user.id, email: user.email, name: user.name, }; const referralCode = typeof user.referralCode === "string" ? user.referralCode : undefined; void options.novu.welcome(nu); void options.novu.referralInvite(nu, referralCode); }, }, }, }, session: { // "Beni hatırla" işaretliyse oturum 30 gün korunur; işaretli değilse // better-auth çerezi oturum çerezi yapar (tarayıcı kapanınca silinir). expiresIn: 60 * 60 * 24 * 30, // 30 gün cookieCache: { enabled: true, maxAge: 60 * 5, // 5 minutes }, }, user: { additionalFields: { role: { type: "string", defaultValue: "user", input: false, }, status: { type: "string", defaultValue: "active", input: false, }, referralCode: { type: "string", required: false, input: false, }, referredBy: { type: "string", required: false, input: false, }, }, }, trustedOrigins: [ ...(process.env.CORS_ORIGIN || "http://localhost:3000").split(","), "http://localhost:4000", ], plugins: [ // Public API anahtarları (api.sase.tr / /api/v1). Doğrulama ApiKeyGuard // üzerinden auth.api.verifyApiKey ile yapılır; enableSessionForAPIKeys // bilinçli olarak KAPALI — API anahtarı dashboard oturumu yerine geçmez, // cookie tabanlı AuthGuard'ın davranışı değişmez. apiKey({ defaultPrefix: "sase_", enableMetadata: true, // Anahtar başına istek tavanı (verify başına sayaç). Günlük decode // kotasından ayrıdır — o, PublicApiQuotaService'te Redis ile tutulur. rateLimit: { enabled: true, timeWindow: 60_000, // 1 dakika maxRequests: 120, }, }), // Cloudflare Turnstile: yalnızca secret tanımlıysa aktif. sign-in/sign-up // uçları "x-captcha-response" header'ındaki token ile doğrulanır. ...(process.env.TURNSTILE_SECRET_KEY ? [ captcha({ provider: "cloudflare-turnstile", secretKey: process.env.TURNSTILE_SECRET_KEY, endpoints: ["/sign-in/email", "/sign-up/email"], }), ] : []), ], }); return authInstance; } export function getAuth() { if (!authInstance) { throw new Error("Auth not initialized. Call createAuth first."); } return authInstance; } /** apiKey plugin'inin döndürdüğü anahtar kaydı (verify/create cevapları). */ export interface ApiKeyRecord { id: string; userId: string; name: string | null; start: string | null; prefix: string | null; enabled: boolean; expiresAt: Date | null; metadata: Record | null; } /** * apiKey plugin uçları. `authInstance` geniş `ReturnType` * ile tiplendiği için plugin uçları tipte görünmüyor (runtime'da mevcutlar); * bu erişimci onları dar ve doğru imzalarla expose eder. */ export function getApiKeyApi() { return getAuth().api as unknown as { verifyApiKey: (input: { body: { key: string } }) => Promise<{ valid: boolean; error: { message?: string; code: string } | null; key: ApiKeyRecord | null; }>; createApiKey: (input: { body: { userId: string; name?: string; prefix?: string; expiresIn?: number; metadata?: Record; rateLimitEnabled?: boolean; rateLimitTimeWindow?: number; rateLimitMax?: number; }; }) => Promise; }; }