/opt/canhelp/apps/api/src
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