import { Hono } from 'hono'; import { zValidator } from '@hono/zod-validator'; import { z } from 'zod'; import { eq } from 'drizzle-orm'; import { auth } from '../lib/betterAuth.js'; import { validatePassword } from '../lib/passwordPolicy.js'; import { db, dbGet, users } from '../db/index.js'; import { getNow, toDbBool } from '../lib/utils.js'; import { rateLimitMiddleware } from '../lib/rateLimit.js'; // Custom auth flows that Better Auth doesn't provide out of the box. Mounted // at /api/auth-ext to avoid colliding with Better Auth's /api/auth/* handler. const authExtRateLimit = rateLimitMiddleware({ max: 20, windowMs: 15 * 60 * 1000, prefix: 'auth-ext' }); const authExt = new Hono(); const claimAccountSchema = z.object({ password: z.string().min(10, 'Password must be at least 10 characters'), }); // Complete a progressive-account claim. The user arrives here already holding // a session established by the claim magic link; this endpoint deliberately // accepts accountStatus 'unclaimed' sessions (requireAuth would reject them) // and is the ONLY endpoint that does. authExt.post('/claim-account', authExtRateLimit, zValidator('json', claimAccountSchema), async (c) => { const session = await auth.api.getSession({ headers: c.req.raw.headers }); if (!session?.user) { return c.json({ error: 'Unauthorized. Please use the claim link from your email.' }, 401); } const user = session.user as any; if (user.banned || user.accountStatus === 'suspended') { return c.json({ error: 'Account is suspended. Please contact support.' }, 403); } if (user.isClaimed && user.accountStatus === 'active') { return c.json({ error: 'Account is already claimed' }, 400); } const { password } = c.req.valid('json'); const passwordValidation = validatePassword(password); if (!passwordValidation.valid) { return c.json({ error: passwordValidation.error }, 400); } // Creates the credential account with the argon2id hash from lib/betterAuth.ts await auth.api.setPassword({ body: { newPassword: password }, headers: c.req.raw.headers, }); // The magic link click proved email ownership await (db as any) .update(users) .set({ isClaimed: toDbBool(true), accountStatus: 'active', emailVerified: true, updatedAt: getNow(), }) .where(eq((users as any).id, user.id)); return c.json({ message: 'Account claimed successfully!', user: { id: user.id, email: user.email, name: user.name, role: user.role, isClaimed: true, phone: user.phone ?? null, rucNumber: user.rucNumber ?? null, languagePreference: user.languagePreference ?? null, }, }); }); // Whether an email belongs to an unclaimed account. Deliberate, rate-limited // exception to enumeration-safety, matching the legacy register/login UX that // surfaced "this account can be claimed". authExt.get('/claim-eligibility', authExtRateLimit, async (c) => { const email = c.req.query('email'); if (!email || !z.string().email().safeParse(email).success) { return c.json({ canClaim: false }); } const user = await dbGet( (db as any).select().from(users).where(eq((users as any).email, email)) ); const canClaim = !!user && !user.banned && user.accountStatus !== 'suspended' && (!user.isClaimed || user.accountStatus === 'unclaimed'); return c.json({ canClaim }); }); export default authExt;