import { NextResponse } from "next/server" import { cookies } from "next/headers" import { z } from "zod" import bcrypt from "bcryptjs" import { prisma } from "@/lib/prisma" import { rateLimit } from "@/lib/rate-limit" import { INVITE_COOKIE, InviteError, claimInvite } from "@/lib/invites" const registerSchema = z.object({ name: z .string() .min(1, "Name is required") .max(100, "Name must be 100 characters or less"), email: z .string() .min(1, "Email is required") .email("Invalid email address"), password: z .string() .min(10, "Password must be at least 10 characters") .max(128, "Password must be 128 characters or less") .regex(/[a-z]/, "Password must contain at least one lowercase letter") .regex(/[A-Z]/, "Password must contain at least one uppercase letter") .regex(/[0-9]/, "Password must contain at least one number"), }) export async function POST(request: Request) { try { // Rate limit: 5 registration attempts per IP per minute. // Take the LAST hop, not the first: everything before it is client-supplied and // can be forged to get a fresh bucket per request. The last entry is the one our // own reverse proxy appended. const forwarded = request.headers.get("x-forwarded-for") const hops = forwarded?.split(",").map((h) => h.trim()).filter(Boolean) ?? [] const ip = hops.length > 0 ? hops[hops.length - 1] : "unknown" const rl = rateLimit(`register:${ip}`, 5, 60 * 1000) if (!rl.success) { return NextResponse.json( { error: "Too many registration attempts. Please try again later." }, { status: 429 } ) } const body = await request.json() const result = registerSchema.safeParse(body) if (!result.success) { const errors = result.error.flatten().fieldErrors return NextResponse.json( { error: "Validation failed", details: errors }, { status: 400 } ) } const { name, email, password } = result.data // Signup is invite only. The token is read from the HttpOnly cookie set by // /invite/, never from the request body, so a caller cannot supply one. const inviteToken = cookies().get(INVITE_COOKIE)?.value if (!inviteToken) { return NextResponse.json( { error: "An invitation is required to sign up." }, { status: 403 } ) } const hashedPassword = await bcrypt.hash(password, 10) // One transaction so a duplicate email rolls back the consumed invite use // rather than burning it on a failed signup. const user = await prisma.$transaction(async (tx) => { const invite = await claimInvite(tx, inviteToken) if (!invite) { throw new InviteError("This invitation link is no longer valid.", 403) } const existingUser = await tx.user.findUnique({ where: { email } }) if (existingUser) { throw new InviteError("An account with this email already exists", 409) } const created = await tx.user.create({ data: { name, email, password: hashedPassword, // The invite is the verification - there is no email infrastructure to // send a confirmation, and the link was delivered out of band. emailVerified: new Date(), role: "MEMBER", status: "ACTIVE", invitedById: invite.createdById, }, }) await tx.inviteRedemption.create({ data: { inviteId: invite.id, userId: created.id, email, provider: "credentials", }, }) return created }) const response = NextResponse.json( { id: user.id, name: user.name, email: user.email }, { status: 201 } ) // Spent - do not leave it lying around for a second signup attempt. response.cookies.delete(INVITE_COOKIE) return response } catch (error) { if (error instanceof InviteError) { return NextResponse.json({ error: error.message }, { status: error.status }) } console.error("Registration error:", error) return NextResponse.json( { error: "Something went wrong. Please try again." }, { status: 500 } ) } }