import { NextRequest } from 'next/server'; import { db } from '@/lib/db'; import bcrypt from 'bcryptjs'; import { acceptInvitationTokenForUser, getValidInvitationByToken } from '@/lib/invitations'; import { checkRateLimit, getClientIp, rateLimitHeaders, RATE_LIMIT_CONFIGS, } from '@/lib/rate-limit'; import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response'; import { isInviteCodeRequired } from '@/lib/feature-flags'; import { logError } from '@/lib/logger'; import { createVerificationToken, isEmailVerificationEnabled, sendVerificationEmail, } from '@/lib/email-verification'; import { isValidEmailAddress, normalizeEmail } from '@/lib/email-validation'; import { recordSignupCompleted } from '@/lib/analytics/signup'; import { readRequestVisitor } from '@/lib/analytics/visitor'; export async function POST(request: NextRequest) { try { // Rate limiting by IP const clientIp = getClientIp(request); const rateLimitKey = `register:${clientIp}`; const rateLimit = await checkRateLimit(rateLimitKey, 'register'); if (!rateLimit.allowed) { return apiErrors.rateLimited('Too many registration attempts. Please try again later.'); } const body = await request.json(); const { name, email, password, inviteCode, invitationToken } = body; // Validate required fields if (!name || typeof name !== 'string' || name.trim().length < 2 || name.trim().length > 100) { return apiErrors.badRequest('Name must be between 2 and 100 characters'); } if (!email || typeof email !== 'string') { return apiErrors.badRequest('Email is required'); } const normalizedEmail = normalizeEmail(email); // Basic email validation if (!isValidEmailAddress(normalizedEmail)) { return apiErrors.validationError('Invalid email format'); } // Allow registration via a valid invitation token OR global invite code. let invitationIsValid = false; let validatedInvitationToken: string | null = null; if (typeof invitationToken === 'string' && invitationToken.trim()) { const normalizedToken = invitationToken.trim(); const invitation = await getValidInvitationByToken(normalizedToken); if (invitation && invitation.email === normalizedEmail) { invitationIsValid = true; validatedInvitationToken = normalizedToken; } else { return apiErrors.forbidden('Invalid or expired invitation token'); } } if (!invitationIsValid && isInviteCodeRequired()) { // Validate invite code using constant-time comparison to prevent timing attacks const validInviteCode = process.env.INVITE_CODE; if (!validInviteCode || !inviteCode) { return apiErrors.forbidden('Invalid invite code'); } // Constant-time comparison const { timingSafeEqual } = await import('crypto'); const validBuffer = Buffer.from(validInviteCode); const providedBuffer = Buffer.from(String(inviteCode)); // Ensure same length for comparison (prevents length-based timing leak) const isValidLength = validBuffer.length === providedBuffer.length; const compareBuffer = isValidLength ? providedBuffer : validBuffer; const isValidCode = isValidLength && timingSafeEqual(validBuffer, compareBuffer); if (!isValidCode) { return apiErrors.forbidden('Invalid invite code'); } } if (!password || typeof password !== 'string' || password.length < 8 || password.length > 128) { return apiErrors.badRequest('Password must be between 8 and 128 characters'); } // Check if email already exists const existingUser = await db.user.findUnique({ where: { email: normalizedEmail }, }); if (existingUser) { return apiErrors.conflict('An account with this email already exists'); } // Hash password const hashedPassword = await bcrypt.hash(password, 12); // If SMTP is not configured, auto-verify the email so users aren't locked out const emailVerificationRequired = isEmailVerificationEnabled(); // Create user const user = await db.user.create({ data: { name: name.trim(), email: normalizedEmail, password: hashedPassword, emailVerified: emailVerificationRequired ? null : new Date(), }, select: { id: true, name: true, email: true, createdAt: true, }, }); if (validatedInvitationToken) { const result = await acceptInvitationTokenForUser({ token: validatedInvitationToken, userId: user.id, email: normalizedEmail, }); if (result !== 'accepted') { await db.user.delete({ where: { id: user.id } }); return apiErrors.conflict( 'Invitation could not be accepted. Please request a new invitation.' ); } } // Ties the account to the first touch stored in this browser's cookie and // claims the visitor events that led here. Recorded after the invitation has // been accepted, so an account that gets rolled back never leaves a signup. await recordSignupCompleted({ userId: user.id, visitor: await readRequestVisitor(request), }); // Send verification email if SMTP is configured if (emailVerificationRequired) { const verificationToken = await createVerificationToken(normalizedEmail); // Invited users are sent back to the invitation after verifying, which forwards them // to the workspace/project they joined instead of the generic dashboard. await sendVerificationEmail(normalizedEmail, verificationToken, { next: validatedInvitationToken ? `/invitations/accept?token=${encodeURIComponent(validatedInvitationToken)}` : undefined, }); } const message = emailVerificationRequired ? 'Account created. Please check your email to verify your address before signing in.' : 'Account created successfully'; const response = successResponse({ message, user, emailVerificationRequired }, 201); // Add rate limit headers to successful response const headers = rateLimitHeaders(rateLimit, RATE_LIMIT_CONFIGS.register.maxRequests); Object.entries(headers).forEach(([key, value]) => { response.headers.set(key, value); }); return withCacheControl(response, 'private, no-store'); } catch (error) { logError('Registration error:', error); return apiErrors.internalError('Failed to create account'); } }