Skip to content

Commit 556f8ff

Browse files
fix: resolve critical build, security, and billing issues for production readiness
Merging critical fixes for production readiness: - Fix FieldButton outline variant (unblocks Vercel build) - Disable subscription checkout/change (no unpaid upgrades) - Remove JWT fallback secret (require env var at startup) - Protect questions/guides APIs with server-side auth + tier checks - Remove raw verification token logging - Make rate limiter atomic with db.$transaction - Remove fabricated aggregate rating CI failures (ESLint, Vercel) are pre-existing in files not touched by this PR.
2 parents 10b70d2 + 190b3be commit 556f8ff

14 files changed

Lines changed: 2750 additions & 1964 deletions

File tree

package-lock.json

Lines changed: 2588 additions & 1667 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/app/api/auth/register/route.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -124,14 +124,13 @@ export async function POST(request: Request) {
124124
});
125125

126126
// Create email verification token
127-
const verifyToken = createVerificationToken(sanitizedEmail);
127+
await createVerificationToken(sanitizedEmail);
128128

129129
// Clean up expired tokens periodically
130130
// cleanup handled by Prisma TTL;
131131

132-
// Log verification link (MVP — in production, send via email)
133-
const verifyUrl = `${process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'}/api/auth/verify-email?token=${verifyToken}`;
134-
console.log(`[EMAIL VERIFICATION] Verify URL for ${sanitizedEmail}: ${verifyUrl}`);
132+
// In production, send verification via email instead of logging
133+
// Verification token is stored in the database for the verify-email endpoint
135134

136135
// Create JWT session (HttpOnly cookie)
137136
const response = NextResponse.json({

src/app/api/guides/route.ts

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { db } from '@/lib/db';
22
import { getUserFromRequest } from '@/lib/auth-helpers';
3+
import { checkGuideAccess } from '@/lib/subscription-guard';
34
import { sanitizeText } from '@/lib/sanitize';
45
import { NextResponse } from 'next/server';
56

@@ -9,6 +10,25 @@ export async function GET(request: Request) {
910
const level = searchParams.get('level');
1011
const role = searchParams.get('role');
1112

13+
// Auth required for non-beginner guides
14+
const user = await getUserFromRequest(request);
15+
16+
if (level && level !== 'beginner' && level !== 'all') {
17+
if (!user) {
18+
return NextResponse.json(
19+
{ error: 'Authentication required for premium guides.' },
20+
{ status: 401 }
21+
);
22+
}
23+
const access = checkGuideAccess(user.subscriptionTier, level);
24+
if (!access.allowed) {
25+
return NextResponse.json(
26+
{ error: access.reason || 'Subscription required for premium guides.' },
27+
{ status: 403 }
28+
);
29+
}
30+
}
31+
1232
const where: Record<string, unknown> = { status: 'published' };
1333
if (level && level !== 'all') where.level = level;
1434
if (role && role !== 'all') where.role = { in: [role, 'General'] };
@@ -18,7 +38,17 @@ export async function GET(request: Request) {
1838
orderBy: [{ level: 'asc' }, { title: 'asc' }],
1939
});
2040

21-
return NextResponse.json({ guides });
41+
// Strip content for guides the user doesn't have access to
42+
const userTier = user?.subscriptionTier ?? 'free';
43+
const sanitized = guides.map((guide: Record<string, unknown>) => {
44+
const access = checkGuideAccess(userTier, guide.level as string);
45+
if (access.allowed) return guide;
46+
// Return only metadata without content
47+
const { content, ...meta } = guide;
48+
return { ...meta, content: null, locked: true };
49+
});
50+
51+
return NextResponse.json({ guides: sanitized });
2252
} catch (error) {
2353
console.error('Guides GET error:', error);
2454
return NextResponse.json({ error: 'Failed to fetch guides' }, { status: 500 });

src/app/api/questions/route.ts

Lines changed: 58 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,28 @@
11
import { db } from '@/lib/db';
2+
import { getUserFromRequest } from '@/lib/auth-helpers';
3+
import { checkQuestionBankAccess } from '@/lib/subscription-guard';
24
import { NextResponse } from 'next/server';
35

6+
const QUESTION_SAFE_FIELDS = {
7+
id: true,
8+
role: true,
9+
difficulty: true,
10+
type: true,
11+
skillArea: true,
12+
question: true,
13+
answerFormat: true,
14+
status: true,
15+
createdAt: true,
16+
} as const;
17+
18+
const PREMIUM_FIELDS = {
19+
whyEmployersAsk: true,
20+
strongAnswerPoints: true,
21+
weakAnswerWarnings: true,
22+
sampleAnswer: true,
23+
advancedQuestions: true,
24+
} as const;
25+
426
export async function GET(request: Request) {
527
try {
628
const { searchParams } = new URL(request.url);
@@ -9,8 +31,11 @@ export async function GET(request: Request) {
931
const type = searchParams.get('type');
1032
const skillArea = searchParams.get('skillArea');
1133
const search = searchParams.get('search');
12-
const limit = parseInt(searchParams.get('limit') || '50');
13-
const offset = parseInt(searchParams.get('offset') || '0');
34+
const limit = Math.min(Math.max(parseInt(searchParams.get('limit') || '50'), 1), 100);
35+
const offset = Math.max(parseInt(searchParams.get('offset') || '0'), 0);
36+
37+
// Check authentication and subscription for non-free content
38+
const user = await getUserFromRequest(request);
1439

1540
const where: Record<string, unknown> = { status: 'published' };
1641
if (role && role !== 'all') where.role = role;
@@ -21,6 +46,23 @@ export async function GET(request: Request) {
2146
where.question = { contains: search };
2247
}
2348

49+
// For non-free difficulties, require auth and subscription check
50+
if (difficulty && difficulty !== 'beginner') {
51+
if (!user) {
52+
return NextResponse.json(
53+
{ error: 'Authentication required for premium questions.' },
54+
{ status: 401 }
55+
);
56+
}
57+
const access = checkQuestionBankAccess(user.subscriptionTier, difficulty);
58+
if (!access.allowed) {
59+
return NextResponse.json(
60+
{ error: access.reason || 'Subscription required for premium questions.' },
61+
{ status: 403 }
62+
);
63+
}
64+
}
65+
2466
const [questions, total] = await Promise.all([
2567
db.question.findMany({
2668
where,
@@ -31,7 +73,20 @@ export async function GET(request: Request) {
3173
db.question.count({ where }),
3274
]);
3375

34-
return NextResponse.json({ questions, total });
76+
// Strip premium fields for free-tier users
77+
const userTier = user?.subscriptionTier ?? 'free';
78+
const canAccessPremium = (userTier === 'starter' || userTier === 'pro');
79+
80+
const sanitized = questions.map((q: Record<string, unknown>) => {
81+
if (canAccessPremium) return q;
82+
const safe: Record<string, unknown> = {};
83+
for (const key of Object.keys(QUESTION_SAFE_FIELDS)) {
84+
safe[key] = q[key];
85+
}
86+
return safe;
87+
});
88+
89+
return NextResponse.json({ questions: sanitized, total });
3590
} catch (error) {
3691
console.error('Questions GET error:', error);
3792
return NextResponse.json({ error: 'Failed to fetch questions' }, { status: 500 });
Lines changed: 5 additions & 147 deletions
Original file line numberDiff line numberDiff line change
@@ -1,150 +1,8 @@
1-
import { db } from '@/lib/db';
2-
import { getUserFromRequest } from '@/lib/auth-helpers';
3-
import { PRICING_TIERS, TierKey, BillingPeriod, getTierPrice, CURRENCY } from '@/lib/pricing';
4-
import { TIER_HIERARCHY } from '@/lib/pricing';
51
import { NextResponse } from 'next/server';
62

7-
interface CheckoutRequestBody {
8-
tier: 'starter' | 'pro';
9-
billing: BillingPeriod;
10-
}
11-
12-
export async function POST(request: Request) {
13-
try {
14-
const user = await getUserFromRequest(request);
15-
if (!user) {
16-
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
17-
}
18-
19-
const body: CheckoutRequestBody = await request.json();
20-
const { tier, billing = 'monthly' } = body;
21-
22-
// Validate requested tier
23-
if (!['starter', 'pro'].includes(tier)) {
24-
return NextResponse.json(
25-
{ error: 'Invalid tier. Must be "starter" or "pro".' },
26-
{ status: 400 }
27-
);
28-
}
29-
30-
// Validate billing period
31-
if (!['monthly', 'yearly'].includes(billing)) {
32-
return NextResponse.json(
33-
{ error: 'Invalid billing period. Must be "monthly" or "yearly".' },
34-
{ status: 400 }
35-
);
36-
}
37-
38-
// Check if user is already on this tier or higher
39-
const currentTierLevel = TIER_HIERARCHY[user.subscriptionTier as TierKey] ?? 0;
40-
const requestedTierLevel = TIER_HIERARCHY[tier as TierKey] ?? 0;
41-
42-
if (currentTierLevel >= requestedTierLevel) {
43-
return NextResponse.json(
44-
{ error: `You are already on the ${PRICING_TIERS[user.subscriptionTier as TierKey]?.name ?? user.subscriptionTier} plan or higher.` },
45-
{ status: 400 }
46-
);
47-
}
48-
49-
const price = getTierPrice(tier as TierKey, billing);
50-
// For PHP, amounts are stored in centavos (1 PHP = 100 centavos)
51-
const amountInCents = Math.round(price * 100);
52-
53-
// Calculate period dates
54-
const now = new Date();
55-
const periodEnd = new Date(now);
56-
if (billing === 'monthly') {
57-
periodEnd.setMonth(periodEnd.getMonth() + 1);
58-
} else {
59-
periodEnd.setFullYear(periodEnd.getFullYear() + 1);
60-
}
61-
62-
// --- Direct upgrade (no Stripe) ---
63-
// This section would be replaced with Stripe checkout session creation
64-
// when Stripe keys are configured. For now, we perform the upgrade directly.
65-
66-
// 1. Create or update Subscription record
67-
const existingSubscription = await db.subscription.findUnique({
68-
where: { userId: user.id },
69-
});
70-
71-
let subscription;
72-
if (existingSubscription) {
73-
subscription = await db.subscription.update({
74-
where: { id: existingSubscription.id },
75-
data: {
76-
tier,
77-
status: 'active',
78-
currentPeriodStart: now,
79-
currentPeriodEnd: periodEnd,
80-
cancelAtPeriodEnd: false,
81-
stripePriceId: billing === 'monthly'
82-
? PRICING_TIERS[tier as TierKey].priceId
83-
: PRICING_TIERS[tier as TierKey].yearlyPriceId ?? PRICING_TIERS[tier as TierKey].priceId,
84-
},
85-
});
86-
} else {
87-
subscription = await db.subscription.create({
88-
data: {
89-
userId: user.id,
90-
tier,
91-
status: 'active',
92-
currentPeriodStart: now,
93-
currentPeriodEnd: periodEnd,
94-
cancelAtPeriodEnd: false,
95-
stripePriceId: billing === 'monthly'
96-
? PRICING_TIERS[tier as TierKey].priceId
97-
: PRICING_TIERS[tier as TierKey].yearlyPriceId ?? PRICING_TIERS[tier as TierKey].priceId,
98-
},
99-
});
100-
}
101-
102-
// 2. Update User.subscriptionTier
103-
await db.user.update({
104-
where: { id: user.id },
105-
data: { subscriptionTier: tier },
106-
});
107-
108-
// 3. Create Payment record
109-
const payment = await db.payment.create({
110-
data: {
111-
userId: user.id,
112-
amount: amountInCents,
113-
currency: CURRENCY.code.toLowerCase(),
114-
status: 'completed',
115-
description: `${PRICING_TIERS[tier as TierKey].name} plan - ${billing === 'yearly' ? 'Yearly' : 'Monthly'} subscription`,
116-
metadata: JSON.stringify({
117-
tier,
118-
billing,
119-
subscriptionId: subscription.id,
120-
directUpgrade: true,
121-
}),
122-
},
123-
});
124-
125-
// When Stripe is configured, this would return:
126-
// return NextResponse.json({ url: stripeCheckoutSession.url });
127-
// For now, return a success response with the subscription details
128-
return NextResponse.json({
129-
success: true,
130-
url: `/dashboard?upgraded=${tier}`, // Frontend redirect URL
131-
subscription: {
132-
id: subscription.id,
133-
tier: subscription.tier,
134-
status: subscription.status,
135-
currentPeriodStart: subscription.currentPeriodStart,
136-
currentPeriodEnd: subscription.currentPeriodEnd,
137-
},
138-
payment: {
139-
id: payment.id,
140-
amount: payment.amount,
141-
status: payment.status,
142-
description: payment.description,
143-
},
144-
message: `Successfully upgraded to ${PRICING_TIERS[tier as TierKey].name} plan!`,
145-
});
146-
} catch (error) {
147-
console.error('Subscription checkout POST error:', error);
148-
return NextResponse.json({ error: 'Failed to process checkout' }, { status: 500 });
149-
}
3+
export async function POST() {
4+
return NextResponse.json(
5+
{ error: 'Paid plans are not currently available.' },
6+
{ status: 503 }
7+
);
1508
}

0 commit comments

Comments
 (0)