/opt/canhelp/apps/api/src
NameSizeModeActions
lib/-0755rm
middleware/-0755rm
routes/-0755rm
auth.ts156000644editdlrm
db.ts1740644editdlrm
index.ts239420644editdlrm
redis.ts2490644editdlrm
seed.ts830180644editdlrm
socket.ts57540644editdlrm
sync-categories-from-prod.ts27920644editdlrm
translate.ts220300644editdlrm
Edit: /opt/canhelp/apps/api/src/auth.ts (15600B)
import { betterAuth, type User } from 'better-auth' import { drizzleAdapter } from 'better-auth/adapters/drizzle' import { bearer, customSession } from 'better-auth/plugins' import { eq, and, asc } from 'drizzle-orm' import { randomBytes, scrypt as nodeScrypt, timingSafeEqual } from 'node:crypto' import { promisify } from 'node:util' import { db } from './db.js' import * as schema from '@canhelp/db' import { plans } from '@canhelp/db' import { emailWelcome, emailVerification, emailPasswordReset } from './lib/email.js' import { logActivity } from './lib/activity.js' import { generateReferralCode, applyPendingReferral } from './lib/referral.js' import { logPlanHistory } from './lib/plan-history.js' import { notifyAdmin, isRegistrationNotifyEnabled } from './lib/telegram.js' import { getHelperCapability, toClientRole, toLegacyMarketRoleFromInput, toNormalizedRole } from './lib/helper-capability.js' import { getAppleClientSecret } from './lib/apple-client-secret.js' const frontendBaseUrl = (process.env.WEB_URL || 'http://localhost:3000').replace(/\/$/, '') function normalizeVerificationCallback(rawCallback?: string | null): string { if (rawCallback) { try { const parsed = new URL(rawCallback) if (parsed.protocol === 'http:' || parsed.protocol === 'https:') { return parsed.toString() } } catch { // Fall through to default callback URL. } } return `${frontendBaseUrl}/dashboard` } function normalizeVerificationUrl(rawUrl: string): string { try { const parsed = new URL(rawUrl) const callback = parsed.searchParams.get('callbackURL') parsed.searchParams.set('callbackURL', normalizeVerificationCallback(callback)) return parsed.toString() } catch { return rawUrl } } const scryptAsync = promisify(nodeScrypt) const SCRYPT_N = 16384 const SCRYPT_R = 16 const SCRYPT_P = 1 const SCRYPT_KEY_LEN = 64 const SCRYPT_MAXMEM = 128 * SCRYPT_N * SCRYPT_R * 2 async function hashPasswordNative(password: string): Promise { const salt = randomBytes(16).toString('hex') const key = (await scryptAsync(password.normalize('NFKC'), salt, SCRYPT_KEY_LEN, { N: SCRYPT_N, r: SCRYPT_R, p: SCRYPT_P, maxmem: SCRYPT_MAXMEM, })) as Buffer return `${salt}:${key.toString('hex')}` } async function verifyPasswordNative(data: { hash: string; password: string }): Promise { const [salt, hexKey] = data.hash.split(':') if (!salt || !hexKey) return false const stored = Buffer.from(hexKey, 'hex') const derived = (await scryptAsync(data.password.normalize('NFKC'), salt, stored.length, { N: SCRYPT_N, r: SCRYPT_R, p: SCRYPT_P, maxmem: SCRYPT_MAXMEM, })) as Buffer if (stored.length !== derived.length) return false return timingSafeEqual(stored, derived) } const appleClientSecret = getAppleClientSecret() export const auth = betterAuth({ baseURL: process.env.BETTER_AUTH_URL || 'http://localhost:4000', basePath: '/api/auth', secret: process.env.BETTER_AUTH_SECRET || 'dev-secret-change-in-production', session: { expiresIn: 60 * 60 * 24 * 30, updateAge: 60 * 60 * 24, }, // Social Sign-In providers for native clients. // Google: GOOGLE_CLIENT_ID + GOOGLE_CLIENT_SECRET // Apple: APPLE_CLIENT_ID + auto-generation via APPLE_TEAM_ID/APPLE_KEY_ID/APPLE_PRIVATE_KEY[_PATH] ...( (process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET) || (process.env.APPLE_CLIENT_ID && appleClientSecret) ? { socialProviders: { ...(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET ? { google: { clientId: process.env.GOOGLE_CLIENT_ID, clientSecret: process.env.GOOGLE_CLIENT_SECRET, }, } : {}), ...(process.env.APPLE_CLIENT_ID && appleClientSecret ? { apple: { clientId: process.env.APPLE_CLIENT_ID, clientSecret: appleClientSecret, }, } : {}), }, } : {}), database: drizzleAdapter(db, { provider: 'pg', schema: { user: schema.users, session: schema.sessions, account: schema.accounts, verification: schema.verifications, }, }), emailVerification: { sendOnSignUp: true, sendVerificationEmail: async ({ user, url }: { user: User; url: string }) => { const verificationUrl = normalizeVerificationUrl(url) console.log('[Auth] sendVerificationEmail →', user.email, verificationUrl) // Fire-and-forget: do NOT await SMTP — otherwise sign-in/sign-up requests // block for up to 10s (SMTP connectionTimeout) when the mail server is slow // or unreachable. Google OAuth skips this path entirely → fast. const userName = (user as any).firstName || user.name || user.email emailVerification({ to: user.email, name: userName, link: verificationUrl, locale: (user as any).locale || 'el', }) .then(() => console.log('[Auth] sendVerificationEmail ✓ sent to', user.email)) .catch((err) => console.error('[Auth] sendVerificationEmail ERROR:', err)) notifyAdmin( `📩 Запрос подтверждения email\n👤 ${userName}\n📧 ${user.email}\n🔗 ${verificationUrl}`, ).catch((err) => { console.error('[Auth] sendVerificationEmail telegram notify ERROR:', err) }) }, autoSignInAfterVerification: true, afterEmailVerification: async (user: User) => { // Apply referral reward now that email is confirmed await applyPendingReferral(user.id).catch(() => {}) emailWelcome({ to: user.email, name: (user as any).firstName || user.name || 'Χρήστη', role: toLegacyMarketRoleFromInput((user as any).role || 'user'), locale: (user as any).locale || 'el', }).catch(() => {}) }, }, emailAndPassword: { enabled: true, minPasswordLength: 8, requireEmailVerification: true, // Keep Better Auth's default scrypt parameters but use Node's native // implementation to avoid slow JS CPU-bound verification on sign-in. password: { hash: hashPasswordNative, verify: verifyPasswordNative, }, sendResetPassword: async ({ user, url }: { user: User; url: string }) => { try { // Convert backend URL to frontend URL // From: https://api.canhelp.gr/api/auth/reset-password/TOKEN?callbackURL=... // To: https://canhelp.gr/reset-password?token=TOKEN const token = url.split('/reset-password/')[1]?.split('?')[0] const frontendUrl = token ? `${frontendBaseUrl}/reset-password?token=${token}` : url await emailPasswordReset({ to: user.email, name: (user as any).firstName || user.name || user.email, link: frontendUrl, locale: (user as any).locale || 'el', }) console.log('[Auth] sendResetPassword ✓ sent to', user.email) } catch (err) { console.error('[Auth] sendResetPassword ERROR:', err) throw new Error('SEND_FAILED') } }, }, plugins: [ bearer(), // Enables Authorization: Bearer for Flutter/mobile // Enrich the session with helper capability so clients can branch on the // market role ('specialist' | 'customer' | 'admin') instead of the raw DB // role ('user' | 'admin'). Helper status is card-based, not role-based. customSession(async ({ user, session }) => { let isHelperReady = false try { isHelperReady = await getHelperCapability(user.id) } catch (err) { console.error('[Auth] customSession getHelperCapability ERROR:', err) } return { session, user: { ...user, isHelperReady, legacyMarketRole: isHelperReady ? 'specialist' : 'customer', role: toClientRole((user as any).role, isHelperReady), }, } }), ], user: { additionalFields: { firstName: { type: 'string', required: true, defaultValue: '', input: true, }, lastName: { type: 'string', required: true, defaultValue: '', input: true, }, role: { type: 'string', required: false, defaultValue: 'user', input: true, }, phone: { type: 'string', required: false, input: true, }, bio: { type: 'string', required: false, input: true, }, skills: { type: 'string[]', required: false, input: true, }, locale: { type: 'string', required: false, defaultValue: 'el', input: true, }, isActive: { type: 'boolean', required: false, defaultValue: true, }, lastSeenAt: { type: 'date', required: false, }, planId: { type: 'string', required: false, }, refCode: { type: 'string', required: false, input: true, fieldName: 'referredBy', }, }, }, trustedOrigins: [ process.env.WEB_URL || 'http://localhost:3000', 'canhelp://', ], databaseHooks: { user: { create: { after: async (user) => { // Generate unique referral code let code = generateReferralCode() // Retry on collision (extremely rare) let attempts = 0 while (attempts < 5) { try { await db .update(schema.users) .set({ referralCode: code, updatedAt: new Date() }) .where(eq(schema.users.id, user.id)) break } catch { code = generateReferralCode() attempts++ } } // Assign plan on registration: // If the end-of-year Pro promo is still active, give the user the Pro plan // expiring Dec 31 of the current year. Otherwise fall back to the free plan. const roleInput = ((user as any).role || 'user') as string const normalizedRole = toNormalizedRole(roleInput) const legacyRole = toLegacyMarketRoleFromInput(roleInput) await db .update(schema.users) .set({ role: normalizedRole, updatedAt: new Date() }) .where(eq(schema.users.id, user.id)) .catch((err) => console.error('[auth] role normalization failed:', err)) const now = new Date() const yearEnd = new Date(now.getFullYear(), 11, 31, 23, 59, 59) const promoActive = now < yearEnd // Tariffs are unified (no customer/specialist split). Pick the default // free plan and, during the promo, the active Pro plan — regardless of role. const [defaultPlan] = await db .select() .from(plans) .where(and(eq(plans.isActive, true), eq(plans.isDefault, true))) .limit(1) .catch(() => []) let freePlan = defaultPlan if (!freePlan) { const [freeTierPlan] = await db .select() .from(plans) .where(and(eq(plans.isActive, true), eq(plans.tier, 'free'))) .orderBy(asc(plans.order)) .limit(1) .catch(() => []) freePlan = freeTierPlan } let assignedPlanId: string | null = freePlan?.id ?? null let assignedPlanExpiresAt: Date | null = null if (promoActive) { const [proPlan] = await db .select() .from(plans) .where(and(eq(plans.isActive, true), eq(plans.tier, 'pro'))) .orderBy(asc(plans.order)) .limit(1) .catch(() => []) if (proPlan) { assignedPlanId = proPlan.id assignedPlanExpiresAt = yearEnd } } await db .update(schema.users) .set({ planId: assignedPlanId, planExpiresAt: assignedPlanExpiresAt, updatedAt: new Date(), }) .where(eq(schema.users.id, user.id)) .catch(() => {}) logPlanHistory({ userId: user.id, event: 'activated', planId: assignedPlanId, planName: assignedPlanId, expiresAt: assignedPlanExpiresAt, note: promoActive && assignedPlanId !== freePlan?.id ? 'end-of-year promo' : null, }).catch(() => {}) // Save pending referral code in referredBy — reward applied after email confirmation const refCode = ((user as any).refCode ?? (user as any).referredBy) as string | undefined if (refCode) { await db .update(schema.users) .set({ referredBy: refCode, updatedAt: new Date() }) .where(eq(schema.users.id, user.id)) .catch(() => {}) } // For Google OAuth users email is already verified — applyPendingReferral // won't be triggered by afterEmailVerification, so apply referral now if ((user as any).emailVerified === true && refCode) { applyPendingReferral(user.id).catch(() => {}) } // For email/password registrations, send welcome email only after // address verification (in afterEmailVerification) to avoid sending // two emails at once and confusing users. if ((user as any).emailVerified === true) { emailWelcome({ to: user.email, name: (user as any).firstName || user.name || 'Χρήστη', role: legacyRole, locale: (user as any).locale || 'el', }).catch(() => {}) } // Telegram: notify admin about new registration (fire-and-forget) isRegistrationNotifyEnabled().then((enabled) => { if (!enabled) return const name = (user as any).firstName ? `${(user as any).firstName} ${(user as any).lastName ?? ''}`.trim() : user.name const role = legacyRole const method = (user as any).emailVerified === true ? 'Google OAuth' : 'email' notifyAdmin( `🆕 Новая регистрация\n👤 ${name}\n📧 ${user.email}\n🎭 ${role}\n🔑 ${method}`, ).catch(() => {}) }).catch(() => {}) // Log registration event logActivity({ userId: user.id, userEmail: user.email, userName: (user as any).firstName ? `${(user as any).firstName} ${(user as any).lastName ?? ''}`.trim() : user.name, event: 'user.register', details: { role: legacyRole, pendingRef: refCode ?? null }, }) }, }, }, session: { create: { after: async (session) => { // Log every new session = login logActivity({ userId: session.userId, event: 'user.login', ipAddress: (session as any).ipAddress ?? null, userAgent: (session as any).userAgent ?? null, }) }, }, }, }, }) export type Session = typeof auth.$Infer.Session.session export type AuthUser = typeof auth.$Infer.Session.user