import { FastifyInstance } from 'fastify'; import { z } from 'zod'; import { db } from '../db/client.js'; import { users } from '../db/schema.js'; import { eq } from 'drizzle-orm'; import { findOrCreateGuest, findOrCreateHuawei, refreshJwt } from '../services/auth/jwt.js'; import { verifyHuaweiToken } from '../services/auth/huawei-id-kit.js'; import { linkGuestAccount } from '../services/auth/account-link-service.js'; import { NotFoundError } from '../utils/errors.js'; const guestLoginSchema = z.object({ deviceId: z.string().min(1), }); const huaweiLoginSchema = z.object({ authorizationCode: z.string().min(1), }); const refreshTokenSchema = z.object({ refreshToken: z.string().min(1), }); const linkSchema = z.object({ provider: z.enum(['apple']), credential: z.object({ authorizationCode: z.string().optional(), identityToken: z.string().min(1), }), mergePolicy: z.literal('server_account_first'), clientMigrationId: z.string().min(1), }); export async function authRoutes(app: FastifyInstance): Promise { // Auth endpoints: stricter rate limit (10 requests/minute) app.post('/auth/guest', { config: { rateLimit: { max: 10, timeWindow: '1 minute' } } }, async (request, reply) => { const parsed = guestLoginSchema.safeParse(request.body); if (!parsed.success) { return reply.status(400).send({ success: false, data: null, error: { code: 'VALIDATION_ERROR', message: parsed.error.issues[0]?.message ?? 'Invalid input' }, }); } const result = await findOrCreateGuest(parsed.data.deviceId, app); return reply.send({ success: true, data: result, error: null }); }); app.post('/auth/huawei', { config: { rateLimit: { max: 10, timeWindow: '1 minute' } } }, async (request, reply) => { const parsed = huaweiLoginSchema.safeParse(request.body); if (!parsed.success) { return reply.status(400).send({ success: false, data: null, error: { code: 'VALIDATION_ERROR', message: parsed.error.issues[0]?.message ?? 'Invalid input' }, }); } const userInfo = await verifyHuaweiToken(parsed.data.authorizationCode); const result = await findOrCreateHuawei(userInfo.openId, userInfo.nickname, userInfo.avatarUrl, app); return reply.send({ success: true, data: result, error: null }); }); // Phase 2: Phone login app.post('/auth/phone', { config: { rateLimit: { max: 10, timeWindow: '1 minute' } } }, async (_request, reply) => { return reply.status(501).send({ success: false, data: null, error: { code: 'NOT_IMPLEMENTED', message: 'Phone login not implemented yet' }, }); }); app.post('/auth/refresh', { config: { rateLimit: { max: 10, timeWindow: '1 minute' } } }, async (request, reply) => { const parsed = refreshTokenSchema.safeParse(request.body); if (!parsed.success) { return reply.status(400).send({ success: false, data: null, error: { code: 'VALIDATION_ERROR', message: parsed.error.issues[0]?.message ?? 'Invalid input' }, }); } const result = await refreshJwt(app, parsed.data.refreshToken); return reply.send({ success: true, data: result, error: null }); }); app.post('/auth/link', { config: { rateLimit: { max: 10, timeWindow: '1 minute' } } }, async (request, reply) => { const parsed = linkSchema.safeParse(request.body); if (!parsed.success) { return reply.status(400).send({ success: false, data: null, error: { code: 'VALIDATION_ERROR', message: parsed.error.issues[0]?.message ?? 'Invalid input' }, }); } const { userId } = request.user; const result = await linkGuestAccount({ guestUserId: userId, provider: parsed.data.provider, credential: parsed.data.credential, mergePolicy: parsed.data.mergePolicy, clientMigrationId: parsed.data.clientMigrationId, app, }); return reply.send({ success: true, data: result, error: null }); }); app.get('/auth/me', async (request, reply) => { const { userId } = request.user; const [user] = await db.select().from(users).where(eq(users.id, userId)).limit(1); if (!user) { throw new NotFoundError('User'); } return reply.send({ success: true, data: { id: user.id, nickname: user.nickname ?? null, avatarUrl: user.avatarUrl ?? null, tier: user.tier ?? 'free', xpTotal: user.xpTotal ?? 0, streakDays: user.streakDays ?? 0, heartsRemaining: user.heartsRemaining ?? 5, dailyXpEarned: user.dailyXpEarned ?? 0, dailyXpGoal: user.dailyXpGoal ?? 50, }, error: null, }); }); }