Splits the two hostnames across one deployment. No files move: the Next app still serves every route on both hosts, and the middleware decides per host which paths it owns and 301s the rest. /login and /signup stay on www - all 82 marketing CTAs point at /signup, which carries a hard canonical to www plus ad traffic. src/lib/hosts.ts is the single source of truth for the boundary (APP_PATH_PREFIXES, isAppPath, wwwUrl, appUrl, urlForPath). The middleware and every absolute-URL builder read from it so they cannot drift apart. - Split the overloaded NEXT_PUBLIC_APP_URL into a www and an app origin. It previously fed both public URLs and in-app URLs, so any single value was wrong somewhere. Most important: QRCodeCard encodes this origin into the QR code the user downloads and prints, so it must stay on www. - Route Stripe return URLs, email links and OAuth redirects per path rather than against one origin, so /dashboard lands on app and /pricing on www. - Cross the host boundary once, after a successful login: the router cannot push across origins, so that jump needs a full load. The user arrives signed in because the session cookie is scoped to COOKIE_DOMAIN. - Keep the app host out of search indexes: X-Robots-Tag on every response plus a Disallow-all robots.txt via rewrite, and /sitemap.xml redirects to www. - Point the TikTok callback fallback at www explicitly. It used to read NEXT_PUBLIC_APP_URL, whose meaning changed here, and only the apex domain is verified with TikTok. Host splitting is inert while both origins are equal, so development is unaffected. Verified: tsc clean, production build succeeds including the Edge middleware bundle, and the path-to-host mapping is unit-checked (prefix traps like /created and /settings-guide stay on www, query strings do not break matching). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
181 lines
6.2 KiB
TypeScript
181 lines
6.2 KiB
TypeScript
import { NextRequest, NextResponse } from 'next/server';
|
|
import { wwwUrl } from '@/lib/hosts';
|
|
import bcrypt from 'bcryptjs';
|
|
import crypto from 'crypto';
|
|
import { db } from '@/lib/db';
|
|
import { z } from 'zod';
|
|
import { csrfProtection } from '@/lib/csrf';
|
|
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
|
|
import { signupSchema, validateRequest } from '@/lib/validationSchemas';
|
|
import { sendEmailVerificationEmail } from '@/lib/email';
|
|
import { sendConversionEvent } from '@/lib/metaConversions';
|
|
import {
|
|
ATTRIBUTION_COOKIE_NAME,
|
|
getEmailDomain,
|
|
parseAttributionCookie,
|
|
} from '@/lib/revops';
|
|
import { triggerLifecycleScoring } from '@/lib/revops-server';
|
|
|
|
async function issueVerificationEmail(user: { email: string; name: string | null }) {
|
|
const verificationToken = crypto.randomBytes(32).toString('base64url');
|
|
// Public link in an outgoing email, so it points at the marketing host. The endpoint
|
|
// itself is served on both hosts and redirects into the app afterwards.
|
|
const verificationUrl = new URL(wwwUrl('/api/auth/verify-email'));
|
|
verificationUrl.searchParams.set('token', verificationToken);
|
|
|
|
await db.verificationToken.deleteMany({ where: { identifier: user.email } });
|
|
await db.verificationToken.create({
|
|
data: {
|
|
identifier: user.email,
|
|
token: verificationToken,
|
|
expires: new Date(Date.now() + 24 * 60 * 60 * 1000),
|
|
},
|
|
});
|
|
|
|
try {
|
|
await sendEmailVerificationEmail(user.email, user.name ?? 'there', verificationUrl.toString());
|
|
} catch (error) {
|
|
await db.verificationToken.deleteMany({ where: { token: verificationToken } });
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
export async function POST(request: NextRequest) {
|
|
try {
|
|
// CSRF Protection
|
|
const csrfCheck = csrfProtection(request);
|
|
if (!csrfCheck.valid) {
|
|
return NextResponse.json(
|
|
{ error: csrfCheck.error },
|
|
{ status: 403 }
|
|
);
|
|
}
|
|
|
|
// Rate Limiting
|
|
const clientId = getClientIdentifier(request);
|
|
const rateLimitResult = rateLimit(clientId, RateLimits.SIGNUP);
|
|
|
|
if (!rateLimitResult.success) {
|
|
return NextResponse.json(
|
|
{
|
|
error: 'Too many signup attempts. Please try again later.',
|
|
retryAfter: Math.ceil((rateLimitResult.reset - Date.now()) / 1000)
|
|
},
|
|
{
|
|
status: 429,
|
|
headers: {
|
|
'X-RateLimit-Limit': rateLimitResult.limit.toString(),
|
|
'X-RateLimit-Remaining': rateLimitResult.remaining.toString(),
|
|
'X-RateLimit-Reset': rateLimitResult.reset.toString(),
|
|
}
|
|
}
|
|
);
|
|
}
|
|
|
|
const body = await request.json();
|
|
|
|
// Validate request body
|
|
const validation = await validateRequest(signupSchema, body);
|
|
if (!validation.success) {
|
|
return NextResponse.json(validation.error, { status: 400 });
|
|
}
|
|
|
|
const { name, email, password } = validation.data;
|
|
|
|
// Check if user already exists
|
|
const existingUser = await db.user.findUnique({
|
|
where: { email },
|
|
});
|
|
|
|
if (existingUser) {
|
|
if (!existingUser.emailVerified && existingUser.password && await bcrypt.compare(password, existingUser.password)) {
|
|
try {
|
|
await issueVerificationEmail(existingUser);
|
|
return NextResponse.json({ success: true, requiresEmailVerification: true, email: existingUser.email });
|
|
} catch (emailError) {
|
|
console.error('Email verification resend failed:', emailError);
|
|
return NextResponse.json({ error: 'We could not send the confirmation email. Please try again.' }, { status: 503 });
|
|
}
|
|
}
|
|
|
|
return NextResponse.json(
|
|
{ error: 'User already exists' },
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
|
|
// Hash password
|
|
const hashedPassword = await bcrypt.hash(password, 12);
|
|
|
|
const firstTouch = parseAttributionCookie(request.cookies.get(ATTRIBUTION_COOKIE_NAME)?.value);
|
|
const onboardingStartedAt = new Date();
|
|
|
|
// Create user
|
|
const user = await db.user.create({
|
|
data: {
|
|
name,
|
|
email,
|
|
password: hashedPassword,
|
|
onboardingStartedAt,
|
|
emailDomain: getEmailDomain(email),
|
|
signupSource: firstTouch?.signupSource || null,
|
|
signupMedium: firstTouch?.signupMedium || null,
|
|
signupCampaign: firstTouch?.signupCampaign || null,
|
|
signupContent: firstTouch?.signupContent || null,
|
|
signupTerm: firstTouch?.signupTerm || null,
|
|
signupReferrer: firstTouch?.signupReferrer || null,
|
|
signupLandingPath: firstTouch?.signupLandingPath || '/signup',
|
|
signupFirstSeenAt: firstTouch?.signupFirstSeenAt ? new Date(firstTouch.signupFirstSeenAt) : onboardingStartedAt,
|
|
},
|
|
});
|
|
|
|
triggerLifecycleScoring(user.id, 'signup');
|
|
|
|
// A confirmed address is required before the account can be used or receive campaigns.
|
|
try {
|
|
await issueVerificationEmail(user);
|
|
} catch (emailError) {
|
|
console.error('Email verification message failed:', emailError);
|
|
await db.user.delete({ where: { id: user.id } });
|
|
return NextResponse.json({ error: 'We could not send the confirmation email. Please try again.' }, { status: 503 });
|
|
}
|
|
|
|
// Meta Conversions API - CompleteRegistration event
|
|
sendConversionEvent({
|
|
eventName: 'CompleteRegistration',
|
|
userData: {
|
|
email: user.email,
|
|
ip: request.headers.get('x-forwarded-for')?.split(',')[0] ?? undefined,
|
|
userAgent: request.headers.get('user-agent') ?? undefined,
|
|
fbc: request.cookies.get('_fbc')?.value,
|
|
fbp: request.cookies.get('_fbp')?.value,
|
|
},
|
|
eventSourceUrl: wwwUrl('/signup'),
|
|
}).catch(console.error);
|
|
|
|
// Create response
|
|
const response = NextResponse.json({
|
|
success: true,
|
|
requiresEmailVerification: true,
|
|
email: user.email,
|
|
});
|
|
|
|
response.cookies.delete(ATTRIBUTION_COOKIE_NAME);
|
|
|
|
return response;
|
|
} catch (error) {
|
|
if (error instanceof z.ZodError) {
|
|
return NextResponse.json(
|
|
{ error: 'Invalid input', details: error.errors },
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
|
|
console.error('Signup error:', error);
|
|
return NextResponse.json(
|
|
{ error: 'Internal server error' },
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
}
|