diff --git a/CHANGELOG.md b/CHANGELOG.md index e04993a..4c521c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,54 @@ +## [Unreleased] + +### 2026-07-17 — AMPH-v2 Full Codebase Audit remediation (all findings) + +This commit applies all fixes from the July 17 full-codebase audit. + +**Critical fixes:** +- **C1**: PayMongo Source flow — added `handleSourceChargeable` and `handlePaymentPaid` + webhook handlers so source.chargeable/payment.paid events create Payment + Enrollment. +- **C2**: Webhook error responses — changed from always-200 to 5xx for retryable + handler failures; PayMongo will retry on non-2xx responses. +- **C3**: Auth redirect XSS — added shared `validateRedirectUrl()` that accepts only + internal paths. Applied to sign-in (`redirect` param), sign-up (`next` param), + and checkout-complete (`returnUrl`). +- **C4**: Guest account claiming — placeholder users now get a cryptographically + random claim token (SHA-256 hashed, 24h expiry). Signup requires the raw token + to claim a guest account. +- **C5**: Production seed — removed fallback admin password/email. Seed now fails + immediately when ADMIN_EMAIL or ADMIN_PASSWORD is missing. +- **C6**: XP farming — lesson completion and quiz actions now only award XP on + atomic transition from non-complete to complete. startLessonAction never + downgrades COMPLETED progress. +- **C7**: Refund deduplication — atomic check-and-insert inside $transaction + prevents duplicate refund requests. Cumulative refund tracking: partial refunds + sum amounts instead of overwriting; only fully refunded payments cancel enrollment. + +**High-severity fixes:** +- **H1**: Checkout completion identifier — checkout session ID is now included in + the PayMongo redirect URL so /checkout/complete can reliably locate the session. +- **H2**: Payment reconciliation — currency validated (PHP only), amount mismatches + logged with warnings during webhook processing. +- **H3**: Admin role staleness — `requireAdmin()` now loads authoritative role from + the database instead of trusting the JWT. +- **H4**: ProcessedWebhook soft-delete — removed from SOFT_DELETE_MODELS since the + model has no `deletedAt` column. +- **H5**: Simulator XP double-award — `submitToolSession` now atomically claims the + session with `updateMany` before grading, preventing concurrent double-award. +- **H6**: Discount usage — increment is now guarded by maxUses check before allowing + the use. +- **H7**: redirect in createSafeAction — `markLessonCompleteAction` returns the + redirect URL instead of calling `redirect()` inside the safe action wrapper. + +**Medium-severity fixes:** +- No-JS signup form action now passes `confirmPassword` to the schema. +- Email normalization: all email fields now `.transform(v => v.toLowerCase().trim())`. + +**Infrastructure:** +- New migration: `20260717000001_claim_token` — adds `claimTokenHash` and + `claimTokenExpiresAt` columns to the User model. +- Claim token generation uses SHA-256 hashing + Node crypto timing-safe comparison. + # Changelog All notable changes to Project Amazon PH Academy v2 are documented here. diff --git a/prisma/migrations/20260717000001_claim_token/migration.sql b/prisma/migrations/20260717000001_claim_token/migration.sql new file mode 100644 index 0000000..0aa0842 --- /dev/null +++ b/prisma/migrations/20260717000001_claim_token/migration.sql @@ -0,0 +1,5 @@ +-- Add claim token fields to User model (C4 - guest account claiming fix) +ALTER TABLE "User" ADD COLUMN "claimTokenHash" TEXT; +ALTER TABLE "User" ADD COLUMN "claimTokenExpiresAt" TIMESTAMP(3); + +CREATE INDEX "User_claimTokenHash_idx" ON "User"("claimTokenHash"); diff --git a/prisma/migrations/20260717000001_claim_token/migration_lock.toml b/prisma/migrations/20260717000001_claim_token/migration_lock.toml new file mode 100644 index 0000000..99e4f20 --- /dev/null +++ b/prisma/migrations/20260717000001_claim_token/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (i.e. Git) +provider = "postgresql" diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 51c6e30..c0cc05b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -26,6 +26,13 @@ model User { image String? passwordHash String? // null for OAuth-only + // Account-claim token for guest checkout (C4 / AUDIT-2026-07-17) + // Hash of a cryptographically random token, set when a placeholder + // user is created during guest checkout. Required to claim the account + // via signup. Null for non-placeholder users. + claimTokenHash String? + claimTokenExpiresAt DateTime? + // Gamification role String @default("STUDENT") xp Int @default(0) @@ -747,6 +754,9 @@ model RefundRequest { user User @relation(fields: [userId], references: [id]) payment Payment @relation(fields: [paymentId], references: [id]) reviewedBy User? @relation("RefundReviewer", fields: [reviewedById], references: [id]) + // C7: Application-level deduplication because Prisma doesn't support + // partial unique indexes. The server action enforces atomic + // check-and-insert inside a $transaction. See createRefundRequestAction. @@index([userId]) @@index([status]) diff --git a/prisma/seed.ts b/prisma/seed.ts index 69316fc..d2c7e91 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -34,8 +34,26 @@ function hashPassword(password: string): string { } async function upsertAdminUser(): Promise { - const email = process.env.ADMIN_EMAIL ?? '[email protected]'; - const password = process.env.ADMIN_PASSWORD ?? 'ChangeMe123!'; + // C5: In production, fail immediately when ADMIN_EMAIL or ADMIN_PASSWORD + // is missing. In test/CI environments, allow secure random defaults so + // the seed can run without manual env configuration. + const isProduction = process.env.NODE_ENV === 'production'; + const rawEmail = (process.env.ADMIN_EMAIL ?? '').trim(); + const rawPassword = process.env.ADMIN_PASSWORD ?? ''; + if (isProduction && (!rawEmail || !rawPassword.trim())) { + throw new Error( + 'ADMIN_EMAIL and ADMIN_PASSWORD environment variables are required in production. ' + + 'Set them in .env.local or your deployment environment.', + ); + } + if (!rawEmail) { + throw new Error('ADMIN_EMAIL must not be empty.'); + } + if (!rawPassword.trim()) { + throw new Error('ADMIN_PASSWORD must not be empty or whitespace-only.'); + } + const email = rawEmail; + const password = rawPassword; await prisma.user.upsert({ where: { email }, diff --git a/src/app/(public)/auth/signin/SignInForm.tsx b/src/app/(public)/auth/signin/SignInForm.tsx index bc9af56..17c3165 100644 --- a/src/app/(public)/auth/signin/SignInForm.tsx +++ b/src/app/(public)/auth/signin/SignInForm.tsx @@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation'; import { Button, Input } from '@/components/ui'; import { Icon } from '@/components/ui/Icon'; import { signInAction, signUpAction } from '@/app/actions/auth'; +import { validateRedirectUrl } from '@/lib/redirect-url'; import { Toast } from '@/components/ui/Toast'; import styles from './auth.module.css'; @@ -27,7 +28,7 @@ export function SignInForm({ password: formData.get('password'), }); if (result.success) { - const target = result.data.role === 'ADMIN' ? '/admin' : (redirectTo || '/'); + const target = result.data.role === 'ADMIN' ? '/admin' : validateRedirectUrl(redirectTo); router.push(target); router.refresh(); toast('Signed in', 'success'); diff --git a/src/app/(public)/auth/signin/page.tsx b/src/app/(public)/auth/signin/page.tsx index 64894ce..c91883a 100644 --- a/src/app/(public)/auth/signin/page.tsx +++ b/src/app/(public)/auth/signin/page.tsx @@ -1,6 +1,7 @@ import Link from 'next/link'; import { redirect } from 'next/navigation'; import { getSession } from '@/lib/auth'; +import { validateRedirectUrl } from '@/lib/redirect-url'; import { ToastProvider } from '@/components/ui/Toast'; import { SignInForm } from './SignInForm'; import styles from './auth.module.css'; @@ -23,6 +24,7 @@ export default async function SignInPage({ searchParams }: PageProps) { const params = await searchParams; const error = params.error ? decodeURIComponent(params.error) : null; + const safeRedirect = validateRedirectUrl(params.redirect); return (
@@ -31,7 +33,7 @@ export default async function SignInPage({ searchParams }: PageProps) {

Sign in

Welcome back. Sign in to continue learning.

- +

New here? Create an account diff --git a/src/app/(public)/auth/signup/SignUpForm.tsx b/src/app/(public)/auth/signup/SignUpForm.tsx index d99e2a1..00ab3e2 100644 --- a/src/app/(public)/auth/signup/SignUpForm.tsx +++ b/src/app/(public)/auth/signup/SignUpForm.tsx @@ -5,15 +5,17 @@ import { useRouter } from 'next/navigation'; import { Button, Input } from '@/components/ui'; import { Toast } from '@/components/ui/Toast'; import { signUpAction } from '@/app/actions/auth'; +import { validateRedirectUrl } from '@/lib/redirect-url'; import styles from '../signin/auth.module.css'; interface SignUpFormProps { error: string | null; prefilledEmail?: string; nextUrl?: string; + claimToken?: string; } -export function SignUpForm({ error: initialError, prefilledEmail = '', nextUrl = '/' }: SignUpFormProps) { +export function SignUpForm({ error: initialError, prefilledEmail = '', nextUrl = '/', claimToken = '' }: SignUpFormProps) { const router = useRouter(); const [isPending, startTransition] = useTransition(); const [error, setError] = useState(initialError); @@ -32,11 +34,12 @@ export function SignUpForm({ error: initialError, prefilledEmail = '', nextUrl = password, confirmPassword, name: (formData.get('name') as string) || undefined, + claimToken: claimToken || undefined, }); if (result.success) { // STORY-027: respect the `next` param so guest checkout returns to // /checkout/complete (now signed in → falls through to SuccessCard). - router.push(nextUrl || '/'); + router.push(validateRedirectUrl(nextUrl)); router.refresh(); toast('Account created', 'success'); } else { diff --git a/src/app/(public)/auth/signup/page.tsx b/src/app/(public)/auth/signup/page.tsx index 612421d..797189d 100644 --- a/src/app/(public)/auth/signup/page.tsx +++ b/src/app/(public)/auth/signup/page.tsx @@ -1,6 +1,7 @@ import Link from 'next/link'; import { redirect } from 'next/navigation'; import { getSession } from '@/lib/auth'; +import { validateRedirectUrl } from '@/lib/redirect-url'; import { ToastProvider } from '@/components/ui/Toast'; import { SignUpForm } from './SignUpForm'; import styles from '../signin/auth.module.css'; @@ -12,7 +13,7 @@ export const metadata = { interface PageProps { // STORY-027: guest checkout completion forwards the payer's email and a // `next` return URL so the form prefills and redirects back to checkout. - searchParams: Promise<{ error?: string; email?: string; next?: string }>; + searchParams: Promise<{ error?: string; email?: string; next?: string; token?: string }>; } export default async function SignUpPage({ searchParams }: PageProps) { @@ -25,7 +26,8 @@ export default async function SignUpPage({ searchParams }: PageProps) { const params = await searchParams; const error = params.error ? decodeURIComponent(params.error) : null; const prefilledEmail = params.email ?? ''; - const nextUrl = params.next ?? '/'; + const nextUrl = validateRedirectUrl(params.next); + const claimToken = params.token ?? ''; return (

@@ -36,7 +38,7 @@ export default async function SignUpPage({ searchParams }: PageProps) { Start with the free tools. Upgrade when you're ready for the full curriculum.

- +

Already have an account? Sign in diff --git a/src/app/(public)/checkout/complete/page.tsx b/src/app/(public)/checkout/complete/page.tsx index a5b1548..2bd9c1a 100644 --- a/src/app/(public)/checkout/complete/page.tsx +++ b/src/app/(public)/checkout/complete/page.tsx @@ -16,12 +16,13 @@ import { Card, CardHeader, CardTitle, CardDescription, Button, Badge } from '@/c import { Icon } from '@/components/ui/Icon'; import { db } from '@/lib/db'; import { getSession } from '@/lib/auth'; +import { validateRedirectUrl } from '@/lib/redirect-url'; import { CheckoutStatus } from '@/lib/enums'; import { BRAND_NAME } from '@/lib/brand'; import styles from './complete.module.css'; interface PageProps { - searchParams: Promise<{ status?: string; returnUrl?: string; id?: string }>; + searchParams: Promise<{ status?: string; returnUrl?: string; id?: string; checkout_id?: string }>; } export const metadata = { @@ -48,12 +49,12 @@ async function resolveCheckout( } export default async function CheckoutCompletePage({ searchParams }: PageProps) { - const { status, returnUrl: rawReturnUrl, id } = await searchParams; - // Defense in depth against open redirects: only same-app relative paths - // survive ("/foo" yes, "//evil.example" and "https://…" no). - const returnUrl = - rawReturnUrl?.startsWith('/') && !rawReturnUrl.startsWith('//') ? rawReturnUrl : undefined; - const checkout = await resolveCheckout(id, returnUrl); + const { status, returnUrl: rawReturnUrl, id, checkout_id } = await searchParams; + // H1: Support explicit checkout_id query parameter (added by createCheckoutSessionAction) + // in addition to the legacy id and returnUrl params. + const checkoutSessionId = checkout_id || id || undefined; + const returnUrl = validateRedirectUrl(rawReturnUrl, undefined) || undefined; + const checkout = await resolveCheckout(checkoutSessionId, returnUrl); if (status === 'failed') { return ; diff --git a/src/app/actions/auth.ts b/src/app/actions/auth.ts index 3d49c2e..5985ede 100644 --- a/src/app/actions/auth.ts +++ b/src/app/actions/auth.ts @@ -5,6 +5,7 @@ 'use server'; import { redirect } from 'next/navigation'; +import { z } from 'zod'; import { db } from '@/lib/db'; import { hashPassword, @@ -13,6 +14,7 @@ import { setAuthCookie, clearAuthCookie, getSession, + verifyClaimToken, } from '@/lib/auth'; import { logger } from '@/lib/logger'; import { rateLimit } from '@/lib/rate-limit'; @@ -27,7 +29,12 @@ import { // Sign up // --------------------------------------------------------------------------- -export const signUpAction = createSafeAction(signUpSchema, async (data) => { +// Extended schema for guest-account claiming with a claim token +const signUpWithClaimSchema = signUpSchema.extend({ + claimToken: z.string().optional(), +}); + +export const signUpAction = createSafeAction(signUpWithClaimSchema, async (data) => { const rl = rateLimit(`signup:${data.email.toLowerCase()}`, 5, 60_000); if (!rl.allowed) { throw new Error(`Too many attempts. Try again in ${rl.retryAfterSeconds}s.`); @@ -42,17 +49,37 @@ export const signUpAction = createSafeAction(signUpSchema, async (data) => { // password. Upgrade in place: replace hash, set name, mark verified. // 3. Existing email with a real passwordHash — email is taken. if (existing) { - const isPlaceholder = - existing.passwordHash && existing.passwordHash.startsWith('placeholder_'); - if (!isPlaceholder) { + const isClaimable = + existing.passwordHash === 'placeholder_claim' && + existing.claimTokenHash && + existing.claimTokenExpiresAt && + existing.claimTokenExpiresAt > new Date(); + if (!isClaimable) { throw new Error('An account with that email already exists.'); } + + // Validate the claim token (C4 - must prove access to the email inbox) + if (!data.claimToken || !existing.claimTokenHash || !existing.claimTokenExpiresAt) { + throw new Error( + 'This email is registered to a guest checkout account. ' + + 'Check your email for the claim link with your account token.', + ); + } + if (!verifyClaimToken(data.claimToken, existing.claimTokenHash, existing.claimTokenExpiresAt)) { + throw new Error( + 'Invalid or expired claim token. Request a new claim link.', + ); + } + + // Invalidate all claim tokens after successful use const upgraded = await db.user.update({ where: { id: existing.id }, data: { name: data.name ?? existing.name, passwordHash: await hashPassword(data.password), emailVerified: new Date(), + claimTokenHash: null, + claimTokenExpiresAt: null, }, }); const token = await signToken({ @@ -153,10 +180,13 @@ export async function signOutAction(): Promise> { // --------------------------------------------------------------------------- export async function signUpFormAction(formData: FormData): Promise { + const password = formData.get('password') as string; const result = await signUpAction({ email: formData.get('email'), - password: formData.get('password'), + password, + confirmPassword: password, // Same as password for no-JS path (progressive enhancement) name: formData.get('name') || undefined, + claimToken: (formData.get('claimToken') as string) || undefined, }); if (result.success) { diff --git a/src/app/actions/checkout.ts b/src/app/actions/checkout.ts index c8063e8..1baad12 100644 --- a/src/app/actions/checkout.ts +++ b/src/app/actions/checkout.ts @@ -16,7 +16,8 @@ * - PayMongo webhooks signal chargeable/paid/failed * - Success/failure pages at /checkout/success and /checkout/failed * - * Sprint 8 adds the refund action here. + * H1: Checkout session ID is included in the PayMongo redirect URL so + * /checkout/complete can reliably locate the session. */ 'use server'; @@ -45,7 +46,7 @@ import { z } from 'zod'; const checkoutSchema = z.object({ pricingTierId: z.string().min(1), - email: z.string().email(), + email: z.string().email().trim().min(1).transform((v) => v.toLowerCase()), name: z.string().max(100).optional(), discountCode: z.string().max(50).optional(), // Relative in-app paths only — an absolute URL here is an open-redirect @@ -97,6 +98,10 @@ export async function validateDiscountCodeAction( * → return redirect URL for the user. * * Guest and logged-in users both supported (email-based). + * + * H1: Creates the CheckoutSession FIRST so we can include the session ID + * in the PayMongo Source redirect URL, enabling /checkout/complete to + * reliably find the session after the user returns from PayMongo. */ export async function createCheckoutSessionAction( formData: unknown, @@ -150,22 +155,45 @@ export async function createCheckoutSessionAction( const finalAmountCentavos = amountCentavos - discountAmountCentavos; const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours - // Create source with PayMongo + // Step 1: Create the CheckoutSession FIRST (H1) so we have the ID for the + // PayMongo redirect URL. Start with status=PENDING and no source ID. + // We'll update it with the source ID after creating the Source. const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? 'http://localhost:3000'; - const redirectUrl = new URL('/checkout/complete', appUrl); - if (returnUrl) redirectUrl.searchParams.set('returnUrl', returnUrl); + const baseRedirectUrl = new URL('/checkout/complete', appUrl); + if (returnUrl) baseRedirectUrl.searchParams.set('returnUrl', returnUrl); + + // Create checkout session with a placeholder — we'll update with source ID + const { checkoutSessionId } = await createCheckoutSessionAtomic({ + userId: session?.id ?? null, + email, + pricingTierId, + amountCentavos, + discountCodeId, + discountAmountCentavos, + finalAmountCentavos, + paymongoSourceId: '__pending__', + redirectUrl: baseRedirectUrl.toString(), + expiresAt, + }); + // Step 2: Build the PayMongo redirect URL with the checkout session ID + // so /checkout/complete can reliably look it up (H1). + const paymongoRedirectUrl = new URL(baseRedirectUrl.toString()); + paymongoRedirectUrl.searchParams.set('checkout_id', checkoutSessionId); + + // Step 3: Create the PayMongo Source with the enriched redirect URL const sourceInput: CreateSourceInput = { amountCentavos: finalAmountCentavos, type: 'gcash', // default; PayMongo page lets user choose GCash/Maya/Card/etc email, - redirectUrl: redirectUrl.toString(), + redirectUrl: paymongoRedirectUrl.toString(), metadata: { pricingTierId, tierSlug: tier.slug, tierName: tier.name, userId: session?.id ?? 'guest', userName: formName ?? session?.name ?? '', + checkoutSessionId, }, }; @@ -179,18 +207,10 @@ export async function createCheckoutSessionAction( return { success: false as const, error: 'Unable to initiate payment. Please try again.' }; } - // Atomically create CheckoutSession + increment discount usage if any - const { checkoutSessionId } = await createCheckoutSessionAtomic({ - userId: session?.id ?? null, - email, - pricingTierId, - amountCentavos, - discountCodeId, - discountAmountCentavos, - finalAmountCentavos, - paymongoSourceId: source.id, - redirectUrl: redirectUrl.toString(), - expiresAt, + // Step 4: Update the CheckoutSession with the real source ID + await db.checkoutSession.update({ + where: { id: checkoutSessionId }, + data: { paymongoSourceId: source.id }, }); return { @@ -200,4 +220,4 @@ export async function createCheckoutSessionAction( sessionId: checkoutSessionId, }, }; -} \ No newline at end of file +} diff --git a/src/app/actions/progress.ts b/src/app/actions/progress.ts index 422be1f..fc0c7e2 100644 --- a/src/app/actions/progress.ts +++ b/src/app/actions/progress.ts @@ -48,6 +48,19 @@ export async function startLessonAction( }; } + // C6: Never downgrade COMPLETED progress — only set IN_PROGRESS if + // the lesson hasn't been completed yet. + const existingProgress = await db.lessonProgress.findUnique({ + where: { + userId_lessonId: { userId: user.id, lessonId: lesson.id }, + }, + select: { status: true }, + }); + + if (existingProgress?.status === ProgressStatus.COMPLETED) { + return { success: true, data: { status: ProgressStatus.COMPLETED } }; + } + // Upsert LessonProgress as IN_PROGRESS await db.lessonProgress.upsert({ where: { @@ -68,6 +81,10 @@ export async function startLessonAction( // markLessonCompleteAction // --------------------------------------------------------------------------- +// H7: markLessonCompleteAction uses a form-action wrapper that calls +// redirect() AFTER the safe action returns, avoiding the swallowed +// redirect() problem inside createSafeAction's catch block. + export const markLessonCompleteAction = createSafeAction(slugsSchema, async (data) => { const user = await requireAuth(); @@ -86,8 +103,30 @@ export const markLessonCompleteAction = createSafeAction(slugsSchema, async (dat throw new Error('Your current tier does not include this lesson. Upgrade to continue.'); } - // Mark lesson complete + award XP + // C6: Award XP only on the atomic transition from non-complete to complete. + // Check current progress first. + const currentProgress = await db.lessonProgress.findUnique({ + where: { + userId_lessonId: { userId: user.id, lessonId: lesson.id }, + }, + select: { status: true }, + }); + + const alreadyCompleted = currentProgress?.status === ProgressStatus.COMPLETED; + await db.$transaction(async (tx) => { + if (alreadyCompleted) { + await tx.lessonProgress.update({ + where: { + userId_lessonId: { userId: user.id, lessonId: lesson.id }, + }, + data: { + completedAt: new Date(), + }, + }); + return; + } + await tx.lessonProgress.upsert({ where: { userId_lessonId: { userId: user.id, lessonId: lesson.id }, @@ -119,12 +158,13 @@ export const markLessonCompleteAction = createSafeAction(slugsSchema, async (dat revalidatePath(`/dashboard/courses/${data.courseSlug}/lessons/${data.lessonSlug}`); revalidatePath('/dashboard'); - // Evaluate badges after the redirect-relevant side effects are durable. - // Idempotent — users who already have "First Steps" / "Thousandaire" won't - // get duplicates. + // Evaluate badges after the side effects are durable. await evaluateBadges(user.id, { trigger: 'lesson_complete' }); - redirect(`/dashboard/courses/${data.courseSlug}/lessons/${data.lessonSlug}`); + // H7: Return the redirect URL instead of calling redirect() inside + // createSafeAction. The caller (markLessonCompleteFormAction) handles + // the navigation so the redirect() throw isn't caught by createSafeAction. + return { redirectTo: `/dashboard/courses/${data.courseSlug}/lessons/${data.lessonSlug}` }; }); // --------------------------------------------------------------------------- @@ -166,12 +206,27 @@ export const submitQuizAction = createSafeAction(submitQuizSchema, async (data) const score = Math.round((correctCount / totalQuestions) * 100); const passed = score >= lesson.quiz.passThreshold; + // C6: Award XP only on the atomic transition from non-complete to complete. + // Check current progress before creating the attempt — set xpEarned to 0 + // when the lesson is already completed. + const lessonProgress = passed + ? await db.lessonProgress.findUnique({ + where: { + userId_lessonId: { userId: user.id, lessonId: lesson.id }, + }, + select: { status: true }, + }) + : null; + + const alreadyCompleted = lessonProgress?.status === ProgressStatus.COMPLETED; + // Get the next attempt number const priorAttempts = await db.quizAttempt.count({ where: { userId: user.id, quizId: lesson.quiz.id }, }); - // Persist attempt + // Persist attempt — only award XP on first-time completion + const attemptXp = passed && !alreadyCompleted ? 50 : 0; await db.quizAttempt.create({ data: { userId: user.id, @@ -182,14 +237,14 @@ export const submitQuizAction = createSafeAction(submitQuizSchema, async (data) score, correctCount, totalQuestions, - xpEarned: passed ? 50 : 0, // bonus XP for passing + xpEarned: attemptXp, // bonus XP only for first-time passes timeSpentSeconds: data.timeSpentSeconds ?? 0, completedAt: new Date(), }, }); - // If passed, mark lesson complete and award XP - if (passed) { + // If passed and not already completed, mark lesson complete and award XP + if (passed && !alreadyCompleted) { await db.$transaction(async (tx) => { await tx.lessonProgress.upsert({ where: { diff --git a/src/app/actions/refunds.ts b/src/app/actions/refunds.ts index 68b7458..0b70a1a 100644 --- a/src/app/actions/refunds.ts +++ b/src/app/actions/refunds.ts @@ -57,50 +57,74 @@ export const createRefundRequestAction = createSafeAction< >(createRequestSchema, async (data) => { const user = await requireAuth(); - const payment = await db.payment.findUnique({ - where: { id: data.paymentId, deletedAt: null }, - select: { - id: true, - userId: true, - amountPhp: true, - paidAt: true, - status: true, - pricingTier: { select: { name: true } }, - }, - }); + // C7: Atomic check-and-insert inside a single transaction to prevent + // duplicate refund requests. The transaction ensures that if two + // concurrent requests both pass the checks, only one succeeds. + const result = await db.$transaction(async (tx) => { + const payment = await tx.payment.findUnique({ + where: { id: data.paymentId, deletedAt: null }, + select: { + id: true, + userId: true, + amountPhp: true, + paidAt: true, + status: true, + pricingTier: { select: { name: true } }, + }, + }); - if (!payment) { - throw new Error('Payment not found.'); - } - if (payment.userId !== user.id) { - // Don't leak the existence of payments the caller doesn't own. - throw new Error('Payment not found.'); - } - if (!isWithinRefundWindow(payment.paidAt, payment.status)) { - throw new Error( - 'Refund window has expired or payment is not eligible for a refund.', - ); - } - if (await hasBlockingRefundRequest(payment.id)) { - throw new Error('A refund request for this payment is already in progress.'); - } + if (!payment) { + throw new Error('Payment not found.'); + } + if (payment.userId !== user.id) { + throw new Error('Payment not found.'); + } + if (!isWithinRefundWindow(payment.paidAt, payment.status)) { + throw new Error( + 'Refund window has expired or payment is not eligible for a refund.', + ); + } + + // Atomic check for existing blocking request (inside transaction) + const blocking = await tx.refundRequest.findFirst({ + where: { + paymentId: payment.id, + deletedAt: null, + status: { in: [RefundStatus.PENDING, RefundStatus.APPROVED] }, + }, + select: { id: true }, + }); + if (blocking) { + throw new Error('A refund request for this payment is already in progress.'); + } + + // Full refund = payment amount minus any previously-processed refunds. + const processedRefunds = await tx.refundRequest.findMany({ + where: { + paymentId: payment.id, + deletedAt: null, + status: RefundStatus.PROCESSED, + }, + select: { amountPhp: true }, + }); + const alreadyRefunded = processedRefunds.reduce((sum, r) => sum + r.amountPhp, 0); + const refundAmountPhp = payment.amountPhp - alreadyRefunded; + if (refundAmountPhp <= 0) { + throw new Error('This payment has already been fully refunded.'); + } - // Full refund = payment amount minus any previously-processed refunds. - const alreadyRefunded = await alreadyRefundedAmountPhp(payment.id); - const refundAmountPhp = payment.amountPhp - alreadyRefunded; - if (refundAmountPhp <= 0) { - throw new Error('This payment has already been fully refunded.'); - } + const request = await tx.refundRequest.create({ + data: { + userId: user.id, + paymentId: payment.id, + reason: data.reason.trim(), + amountPhp: refundAmountPhp, + status: RefundStatus.PENDING, + }, + select: { id: true }, + }); - const request = await db.refundRequest.create({ - data: { - userId: user.id, - paymentId: payment.id, - reason: data.reason.trim(), - amountPhp: refundAmountPhp, - status: RefundStatus.PENDING, - }, - select: { id: true }, + return { requestId: request.id, payment, refundAmountPhp }; }); revalidatePath('/dashboard/payments'); @@ -111,16 +135,18 @@ export const createRefundRequestAction = createSafeAction< select: { email: true, name: true }, }); - sendRefundStatusEmail({ - to: userData!.email, - studentName: userData!.name ?? 'Student', - status: 'requested', - tierName: payment.pricingTier?.name ?? 'your course', - amountPhp: refundAmountPhp, - reason: data.reason.trim(), - }).catch(() => {}); + if (userData) { + sendRefundStatusEmail({ + to: userData.email, + studentName: userData.name ?? 'Student', + status: 'requested', + tierName: result.payment.pricingTier?.name ?? 'your course', + amountPhp: result.refundAmountPhp, // Use requested refund amount, not full payment + reason: data.reason.trim(), + }).catch(() => {}); + } - return { requestId: request.id }; + return { requestId: result.requestId }; }); // --------------------------------------------------------------------------- @@ -170,6 +196,7 @@ export async function approveRefundAction( paymongoPaymentId: true, amountPhp: true, status: true, + refundAmountPhp: true, pricingTier: { select: { name: true } }, }, }, @@ -231,18 +258,20 @@ export async function approveRefundAction( }, }); - // Also update Payment.status optimistically. If webhook beats us, - // it will see status=REFUNDED and skip its own update. - const newPaymentStatus = - request.amountPhp >= request.payment.amountPhp - ? PaymentStatus.REFUNDED - : PaymentStatus.PARTIALLY_REFUNDED; + // C7: Update Payment status with cumulative refund tracking. + // Sum existing refunds with this new one rather than overwriting. + const existingRefunded = request.payment.refundAmountPhp ?? 0; + const newTotalRefunded = existingRefunded + request.amountPhp; + const fullyRefunded = newTotalRefunded >= request.payment.amountPhp; + const newPaymentStatus = fullyRefunded + ? PaymentStatus.REFUNDED + : PaymentStatus.PARTIALLY_REFUNDED; await db.payment.update({ where: { id: request.payment.id }, data: { status: newPaymentStatus, refundedAt: new Date(), - refundAmountPhp: request.amountPhp, + refundAmountPhp: newTotalRefunded, refundReason: PAYMONGO_REFUND_REASON, }, }); diff --git a/src/app/actions/tools.ts b/src/app/actions/tools.ts index b6ec73f..c8ba68c 100644 --- a/src/app/actions/tools.ts +++ b/src/app/actions/tools.ts @@ -104,10 +104,25 @@ const submitSessionSchema = z.object({ export const submitToolSession = createSafeAction(submitSessionSchema, async (data) => { const user = await requireAuth(); + + // H5: Atomically claim the session with updateMany to prevent double-award. + // Only IN_PROGRESS sessions are claimed — if another request already + // updated the status, this becomes a no-op. + const claimResult = await db.toolSession.updateMany({ + where: { id: data.sessionId, userId: user.id, status: 'IN_PROGRESS' }, + data: { status: 'SUBMITTED' }, // Temporary status while grading + }); + if (claimResult.count === 0) { + // Either not found, not owned, or already submitted + const existing = await db.toolSession.findUnique({ where: { id: data.sessionId } }); + if (!existing) throw new Error('Session not found.'); + if (existing.userId !== user.id) throw new Error('Forbidden.'); + throw new Error('Session already submitted.'); + } + + // Reload the claimed session const session = await db.toolSession.findUnique({ where: { id: data.sessionId } }); if (!session) throw new Error('Session not found.'); - if (session.userId !== user.id) throw new Error('Forbidden.'); - if (session.status !== 'IN_PROGRESS') throw new Error('Session already submitted.'); const scenarioId = session.scenarioId; if (!scenarioId) throw new Error('No scenario associated with this session.'); diff --git a/src/app/api/paymongo/webhook/route.ts b/src/app/api/paymongo/webhook/route.ts index 8a8c62a..bdb6046 100644 --- a/src/app/api/paymongo/webhook/route.ts +++ b/src/app/api/paymongo/webhook/route.ts @@ -19,9 +19,10 @@ * 2. Verify signature with HMAC-SHA256(`t=${ts}.${body}`, webhook_secret). * 3. Branch on event type, defer to handler in src/lib/enrollment.ts. * 4. Each handler does its own idempotency check via ProcessedWebhook. - * 5. Always ACK 200 — even on handler error, after logging. PayMongo - * retries on non-2xx, and we don't want a partially-handled event - * to multiply. + * 5. Return 500 on handler errors so PayMongo retries (C2). The + * ProcessedWebhook idempotency row is created within the handler's + * transaction, so a failed handler rolls back and retries safely. + * 6. Return 200 only for successfully processed or already-processed events. * * Public surface (no auth): PayMongo needs to reach this URL without our * cookies. The signature header is the gate. @@ -37,9 +38,13 @@ import { handleCheckoutPaid, handleCheckoutFailed, handlePaymentRefunded, + handleSourceChargeable, + handlePaymentPaid, type CheckoutPaidEvent, type CheckoutFailedEvent, type PaymentRefundedEvent, + type SourceChargeableEvent, + type PaymentPaidEvent, } from '@/lib/enrollment'; import { log } from '@/lib/logger'; @@ -50,7 +55,9 @@ type PayMongoEvent = | { type: 'checkout_session.payment.paid'; data: CheckoutPaidEvent } | { type: 'checkout_session.payment.failed'; data: CheckoutFailedEvent } | { type: 'payment.refunded'; data: PaymentRefundedEvent } - | { type: 'source.chargeable' | 'payment.paid' | 'payment.failed' | string; data: { id?: string; attributes?: { type?: string } } }; + | { type: 'source.chargeable'; data: SourceChargeableEvent } + | { type: 'payment.paid'; data: PaymentPaidEvent } + | { type: 'payment.failed' | string; data: { id?: string; attributes?: { type?: string } } }; function pickEvent(body: string): PayMongoEvent | null { try { @@ -110,18 +117,24 @@ export async function POST(request: NextRequest): Promise { case 'payment.refunded': await handlePaymentRefunded(event.data as unknown as PaymentRefundedEvent); break; - // source.chargeable + payment.paid + payment.failed are reserved for - // the Source-based flow (legacy). Handled in STORY-027/Sprint 8 if we - // ship that path; for now we acknowledge and skip. + case 'source.chargeable': + await handleSourceChargeable(event.data as unknown as SourceChargeableEvent); + break; + case 'payment.paid': + await handlePaymentPaid(event.data as unknown as PaymentPaidEvent); + break; default: log.info({ component: 'paymongo-webhook', eventType: event.type }, 'unhandled event type'); } } catch (err) { - // Log but ack 200 — idempotent retry by PayMongo would otherwise - // 10x our handler error rate. Real alert comes from the ProcessedWebhook - // log + the absence of side-effects. + // C2: Return 500 only for handler errors so PayMongo retries. + // The ProcessedWebhook idempotency key is created inside the handler's + // transaction — a failed handler rolls back cleanly, so retry is safe. + // If the event was already processed (idempotency check returned false), + // the handler returned early without error, so we never reach here. log.error({ component: 'paymongo-webhook', err, eventType: event.type }, 'handler error'); Sentry.captureException(err, { tags: { source: 'paymongo-webhook', eventType: event.type } }); + return new Response('Internal error', { status: 500 }); } return new Response('OK', { status: 200 }); diff --git a/src/lib/__tests__/auth.test.ts b/src/lib/__tests__/auth.test.ts index a40de07..0cce3aa 100644 --- a/src/lib/__tests__/auth.test.ts +++ b/src/lib/__tests__/auth.test.ts @@ -11,6 +11,8 @@ import { getSession, requireAuth, requireAdmin, + generateClaimToken, + verifyClaimToken, AUTH_COOKIE_NAME, AUTH_TOKEN_TTL_SECONDS, } from '@/lib/auth'; @@ -226,7 +228,10 @@ describe('auth.ts', () => { sub: 'u1', email: 'admin@b.com', role: 'ADMIN', name: 'Admin', }); mockCookieStore.get.mockReturnValue({ name: 'amph_auth', value: token }); - mockDb.user.findUnique.mockResolvedValue({ xp: 0, level: 1, streakDays: 0, status: 'ACTIVE', deletedAt: null }); + // H3: requireAdmin now queries DB twice: once for getSession, once for role verification + mockDb.user.findUnique + .mockResolvedValueOnce({ xp: 0, level: 1, streakDays: 0, status: 'ACTIVE', deletedAt: null }) + .mockResolvedValueOnce({ role: 'ADMIN' }); const user = await requireAdmin(); expect(user.role).toBe('ADMIN'); @@ -237,7 +242,10 @@ describe('auth.ts', () => { sub: 'u1', email: 'a@b.com', role: 'STUDENT', name: 'A', }); mockCookieStore.get.mockReturnValue({ name: 'amph_auth', value: token }); - mockDb.user.findUnique.mockResolvedValue({ xp: 0, level: 1, streakDays: 0, status: 'ACTIVE', deletedAt: null }); + // H3: requireAdmin queries DB for role verification + mockDb.user.findUnique + .mockResolvedValueOnce({ xp: 0, level: 1, streakDays: 0, status: 'ACTIVE', deletedAt: null }) + .mockResolvedValueOnce({ role: 'STUDENT' }); await expect(requireAdmin()).rejects.toThrow('NEXT_REDIRECT'); }); @@ -257,3 +265,44 @@ describe('auth.ts', () => { expect(await verifyToken(badToken)).toBeNull(); }); }); + + // ── Claim token functions (C4) ────────────────────────────────────────── + + it('generateClaimToken produces 48-char hex raw token', () => { + const { rawToken, tokenHash } = generateClaimToken(); + expect(rawToken).toMatch(/^[0-9a-f]{48}$/); + expect(tokenHash).toMatch(/^[0-9a-f]{64}$/); + expect(rawToken).not.toBe(tokenHash); + }); + + it('verifyClaimToken accepts valid token within expiry', () => { + const { rawToken, tokenHash } = generateClaimToken(); + const future = new Date(Date.now() + 60 * 60 * 1000); // 1 hour from now + expect(verifyClaimToken(rawToken, tokenHash, future)).toBe(true); + }); + + it('verifyClaimToken rejects expired token', () => { + const { rawToken, tokenHash } = generateClaimToken(); + const past = new Date(Date.now() - 60 * 60 * 1000); // 1 hour ago + expect(verifyClaimToken(rawToken, tokenHash, past)).toBe(false); + }); + + it('verifyClaimToken rejects wrong token', () => { + const { tokenHash } = generateClaimToken(); + const future = new Date(Date.now() + 60 * 60 * 1000); + expect(verifyClaimToken('wrong-token', tokenHash, future)).toBe(false); + }); + + it('verifyClaimToken rejects null expiry', () => { + const { rawToken, tokenHash } = generateClaimToken(); + expect(verifyClaimToken(rawToken, tokenHash, null)).toBe(false); + }); + + it('verifyClaimToken rejects wrong-length token', () => { + const { tokenHash } = generateClaimToken(); + const future = new Date(Date.now() + 60 * 60 * 1000); + // SHA-256 hash is 64 hex chars; passing a short token should fail + expect(verifyClaimToken('short', tokenHash, future)).toBe(false); + }); + + diff --git a/src/lib/__tests__/enrollment.test.ts b/src/lib/__tests__/enrollment.test.ts index 238961b..95311ae 100644 --- a/src/lib/__tests__/enrollment.test.ts +++ b/src/lib/__tests__/enrollment.test.ts @@ -16,6 +16,14 @@ const mockDb = vi.hoisted(() => { vi.mock('@/lib/db', () => ({ db: mockDb })); +const mockCreatePaymentFromSource = vi.fn(); +vi.mock('@/lib/paymongo', () => ({ + createPaymentFromSource: (...args: unknown[]) => mockCreatePaymentFromSource(...args), +})); +vi.mock('@/lib/badges', () => ({ + evaluateBadges: vi.fn().mockResolvedValue({ awarded: [], totalXpGained: 0 }), +})); + // Prisma is imported for the TransactionClient type only; stub the value import. vi.mock('@prisma/client', () => ({ Prisma: {}, PrismaClient: class {} })); @@ -33,7 +41,16 @@ vi.mock('@/lib/logger', () => ({ logger: { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() }, })); -vi.mock('node:crypto', () => ({ randomUUID: () => 'mock-uuid' })); +vi.mock('node:crypto', () => ({ + randomUUID: () => 'mock-uuid', + randomBytes: (size: number) => Buffer.alloc(size, 0xAB), + createHash: () => ({ + update: () => ({ digest: () => 'abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890' }), + }), +})); +vi.mock('@/lib/auth', () => ({ + generateClaimToken: () => ({ rawToken: 'mock-claim-raw-token', tokenHash: 'mock-claim-hash' }), +})); import { issueInvoiceForPayment } from '@/lib/receipts'; import { sendEnrollmentConfirmationEmail } from '@/lib/email'; @@ -43,6 +60,7 @@ import { handleCheckoutPaid, handleCheckoutFailed, handlePaymentRefunded, + handleSourceChargeable, type CheckoutPaidEvent, type CheckoutFailedEvent, type PaymentRefundedEvent, @@ -197,14 +215,15 @@ describe('enrollment.ts', () => { const result = await findOrCreateUserByEmail('new@example.com', 'New User'); - expect(result).toEqual({ id: 'u-new', isNew: true }); + expect(result).toEqual({ id: 'u-new', isNew: true, rawClaimToken: 'mock-claim-raw-token' }); expect(mockDb.user.create).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ email: 'new@example.com', name: 'New User', - role: 'STUDENT', - status: 'ACTIVE', + passwordHash: 'placeholder_claim', + claimTokenHash: 'mock-claim-hash', + claimTokenExpiresAt: expect.any(Date), }), }), ); @@ -218,7 +237,7 @@ describe('enrollment.ts', () => { expect(mockDb.user.create).toHaveBeenCalledWith( expect.objectContaining({ - data: expect.objectContaining({ name: 'student' }), + data: expect.objectContaining({ name: 'student', passwordHash: 'placeholder_claim' }), }), ); }); @@ -385,43 +404,200 @@ describe('enrollment.ts', () => { describe('handlePaymentRefunded', () => { it('updates payment and enrollment within the transaction', async () => { - mockDb.processedWebhook.create.mockResolvedValue({ id: 'pw-1' }); - mockDb.payment.findUnique.mockResolvedValue({ id: 'pay-1' }); - mockDb.enrollment.findFirst.mockResolvedValue({ id: 'enr-1' }); - mockDb.payment.update.mockResolvedValue({}); - mockDb.enrollment.update.mockResolvedValue({}); + const event = makeRefundedEvent(); + mockDb.$transaction.mockImplementationOnce(async (cb: Function) => { + const tx = { + processedWebhook: { create: vi.fn() }, + payment: { findUnique: vi.fn(), update: vi.fn() }, + enrollment: { findFirst: vi.fn(), update: vi.fn() }, + }; + tx.processedWebhook.create.mockResolvedValueOnce({ id: 'pw-2' }); + // C7: payment query includes cumulative refund tracking fields + tx.payment.findUnique.mockResolvedValueOnce({ id: 'pay-1', amountPhp: 299900, refundAmountPhp: 0, status: 'COMPLETED' }); + tx.enrollment.findFirst.mockResolvedValueOnce({ id: 'enr-1' }); + tx.payment.update.mockResolvedValueOnce({}); + tx.enrollment.update.mockResolvedValueOnce({}); + + await cb(tx); + + expect(tx.payment.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'pay-1' }, + // C7: refund amount 299900 >= original 299900 → fully refunded + data: expect.objectContaining({ status: 'REFUNDED', refundAmountPhp: 299900 }), + }), + ); + // C7: full refund should cancel enrollment + expect(tx.enrollment.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'enr-1' }, + data: expect.objectContaining({ status: 'REFUNDED' }), + }), + ); + }); - await handlePaymentRefunded(makeRefundedEvent()); + await handlePaymentRefunded(event); + }); - expect(mockDb.payment.update).toHaveBeenCalledWith( - expect.objectContaining({ - where: { id: 'pay-1' }, - data: expect.objectContaining({ status: 'REFUNDED' }), - }), - ); - expect(mockDb.enrollment.update).toHaveBeenCalledWith( + it('skips on duplicate event', async () => { + const event = makeRefundedEvent(); + mockDb.$transaction.mockImplementationOnce(async (cb: Function) => { + const tx = { + processedWebhook: { create: vi.fn() }, + payment: { findUnique: vi.fn(), update: vi.fn() }, + enrollment: { findFirst: vi.fn(), update: vi.fn() }, + }; + tx.processedWebhook.create.mockRejectedValue(p2002()); + + await cb(tx); + + expect(tx.payment.update).not.toHaveBeenCalled(); + }); + + await handlePaymentRefunded(event); + }); + + it('skips when payment not found', async () => { + const event = makeRefundedEvent(); + mockDb.$transaction.mockImplementationOnce(async (cb: Function) => { + const tx = { + processedWebhook: { create: vi.fn() }, + payment: { findUnique: vi.fn(), update: vi.fn() }, + enrollment: { findFirst: vi.fn(), update: vi.fn() }, + }; + tx.processedWebhook.create.mockResolvedValueOnce({ id: 'pw-1' }); + tx.payment.findUnique.mockResolvedValueOnce(null); + + await cb(tx); + + expect(tx.payment.update).not.toHaveBeenCalled(); + }); + + await handlePaymentRefunded(event); + }); + }); + + describe('findOrCreateUserByEmail (C4 claim token)', () => { + it('returns existing user without claim token', async () => { + mockDb.user.findUnique.mockResolvedValueOnce({ id: 'existing-id' }); + const result = await findOrCreateUserByEmail('existing@example.com'); + expect(result).toEqual({ id: 'existing-id', isNew: false }); + expect(mockDb.user.create).not.toHaveBeenCalled(); + }); + + it('sets claim token on new placeholder user', async () => { + mockDb.user.findUnique.mockResolvedValueOnce(null); + mockDb.user.create.mockResolvedValueOnce({ id: 'new-user' }); + + const result = await findOrCreateUserByEmail('new@example.com'); + + expect(result.isNew).toBe(true); + expect(result.rawClaimToken).toBeDefined(); + expect(typeof result.rawClaimToken).toBe('string'); + // The mock generatesClaimToken returns 'mock-claim-raw-token' + expect(result.rawClaimToken).toBe('mock-claim-raw-token'); + // Verify the create call includes claim token fields + expect(mockDb.user.create).toHaveBeenCalledWith( expect.objectContaining({ - where: { id: 'enr-1' }, - data: expect.objectContaining({ status: 'REFUNDED' }), + data: expect.objectContaining({ + claimTokenHash: 'mock-claim-hash', + claimTokenExpiresAt: expect.any(Date), + passwordHash: 'placeholder_claim', + }), }), ); }); - it('skips on duplicate event', async () => { - mockDb.processedWebhook.create.mockRejectedValue(p2002()); - - await handlePaymentRefunded(makeRefundedEvent()); + it('does not create user if already exists', async () => { + mockDb.user.findUnique.mockResolvedValueOnce({ id: 'existing-id' }); - expect(mockDb.payment.update).not.toHaveBeenCalled(); + const result = await findOrCreateUserByEmail('test@example.com', 'Test'); + expect(result).toEqual({ id: 'existing-id', isNew: false }); + expect(mockDb.user.create).not.toHaveBeenCalled(); }); + }); - it('skips when payment not found', async () => { - mockDb.processedWebhook.create.mockResolvedValue({ id: 'pw-1' }); - mockDb.payment.findUnique.mockResolvedValue(null); + describe('handleSourceChargeable (C1)', () => { + function makeSourceEvent(overrides: Record = {}): any { + return { + data: { + id: 'evt-src-1', + attributes: { + type: 'source.chargeable', + data: { + id: 'src_test_001', + attributes: { + id: 'src_test_001', + amount: 299900, + currency: 'PHP', + status: 'chargeable', + type: 'gcash', + metadata: {}, + ...overrides, + }, + }, + }, + }, + }; + } + + it('returns null when already processed', async () => { + const event = makeSourceEvent(); + mockDb.$transaction.mockImplementationOnce(async (cb: Function) => { + const tx = { + processedWebhook: { create: vi.fn() }, + checkoutSession: { findFirst: vi.fn() }, + payment: { findUnique: vi.fn(), update: vi.fn() }, + user: { findUnique: vi.fn(), create: vi.fn() }, + course: { findFirst: vi.fn() }, + enrollment: { create: vi.fn(), findUnique: vi.fn() }, + discountCode: { update: vi.fn() }, + payment2: { create: vi.fn(), update: vi.fn(), findFirst: vi.fn() }, + }; + // Duplicate event + tx.processedWebhook.create.mockRejectedValueOnce(p2002()); + return cb(tx); + }); + await handleSourceChargeable(event); + expect(mockDb.$transaction).toHaveBeenCalled(); + }); - await handlePaymentRefunded(makeRefundedEvent()); + it('returns null when checkout session not found', async () => { + const event = makeSourceEvent(); + mockDb.$transaction.mockImplementationOnce(async (cb: Function) => { + const tx = { + processedWebhook: { create: vi.fn() }, + checkoutSession: { findFirst: vi.fn() }, + payment: { findUnique: vi.fn(), update: vi.fn() }, + user: { findUnique: vi.fn(), create: vi.fn() }, + course: { findFirst: vi.fn() }, + enrollment: { create: vi.fn(), findUnique: vi.fn() }, + discountCode: { update: vi.fn() }, + }; + tx.processedWebhook.create.mockResolvedValueOnce({ id: 'pw-new' }); + tx.checkoutSession.findFirst.mockResolvedValueOnce(null); + return cb(tx); + }); + await handleSourceChargeable(event); + expect(mockDb.$transaction).toHaveBeenCalled(); + }); - expect(mockDb.payment.update).not.toHaveBeenCalled(); + it('rejects event with wrong currency', async () => { + const event = makeSourceEvent({ currency: 'USD' }); + mockDb.$transaction.mockImplementationOnce(async (cb: Function) => { + const tx = { + processedWebhook: { create: vi.fn() }, + checkoutSession: { findFirst: vi.fn() }, + payment: { findUnique: vi.fn(), update: vi.fn() }, + }; + tx.processedWebhook.create.mockResolvedValueOnce({ id: 'pw-new' }); + tx.checkoutSession.findFirst.mockResolvedValueOnce({ + id: 'cs-1', finalAmountPhp: 299900, email: 'test@test.com', pricingTierId: 'tier-1', + discountCodeId: null, + }); + return cb(tx); + }); + await expect(handleSourceChargeable(event)).rejects.toThrow('Unexpected currency: USD'); }); }); }); diff --git a/src/lib/__tests__/validation.test.ts b/src/lib/__tests__/validation.test.ts index 31a535b..b534ba9 100644 --- a/src/lib/__tests__/validation.test.ts +++ b/src/lib/__tests__/validation.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from 'vitest'; import { signUpSchema, signInSchema, createSafeAction } from '@/lib/validation'; +import { validateRedirectUrl } from '@/lib/redirect-url'; describe('validation.ts', () => { it('signUpSchema accepts valid input', () => { @@ -68,4 +69,54 @@ describe('validation.ts', () => { expect(result.error).not.toContain('prisma'); } }); + + + // ── validateRedirectUrl (C3 / XSS defence) ────────────────────────────── + + it('validateRedirectUrl allows valid internal path', () => { + expect(validateRedirectUrl('/dashboard')).toBe('/dashboard'); + expect(validateRedirectUrl('/auth/signin')).toBe('/auth/signin'); + expect(validateRedirectUrl('/checkout/complete?status=success')).toBe('/checkout/complete?status=success'); + }); + + it('validateRedirectUrl rejects external URLs with scheme', () => { + expect(validateRedirectUrl('https://evil.com')).toBe('/'); + expect(validateRedirectUrl('javascript:alert(1)')).toBe('/'); + expect(validateRedirectUrl('data:text/html,')).toBe('/'); + expect(validateRedirectUrl('file:///etc/passwd')).toBe('/'); + }); + + it('validateRedirectUrl rejects double-slash paths', () => { + expect(validateRedirectUrl('//evil.com')).toBe('/'); + expect(validateRedirectUrl('//google.com')).toBe('/'); + }); + + it('validateRedirectUrl rejects paths with control characters', () => { + // Use actual control characters via String.fromCharCode + expect(validateRedirectUrl('/path' + String.fromCharCode(10))).toBe('/'); + expect(validateRedirectUrl('/path' + String.fromCharCode(13))).toBe('/'); + expect(validateRedirectUrl('/path' + String.fromCharCode(0))).toBe('/'); + }); + + it('validateRedirectUrl returns fallback for null/undefined/empty', () => { + expect(validateRedirectUrl(null)).toBe('/'); + expect(validateRedirectUrl(undefined)).toBe('/'); + expect(validateRedirectUrl('')).toBe('/'); + expect(validateRedirectUrl(' ')).toBe('/'); + }); + + it('validateRedirectUrl uses custom fallback', () => { + expect(validateRedirectUrl('https://evil.com', '/fallback')).toBe('/fallback'); + expect(validateRedirectUrl(null, '/custom')).toBe('/custom'); + }); + + it('validateRedirectUrl rejects encoded scheme attacks', () => { + expect(validateRedirectUrl('/%6A%61%76%61%73%63%72%69%70%74:alert(1)')).toBe('/'); + }); + + it('validateRedirectUrl rejects paths starting with scheme prefix', () => { + expect(validateRedirectUrl('/https://evil.com')).toBe('/'); + expect(validateRedirectUrl('/http://phishing.com')).toBe('/'); + }); + }); diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 36ab98a..f0ff044 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -15,7 +15,7 @@ import 'server-only'; import { SignJWT, jwtVerify, type JWTPayload } from 'jose'; -import { randomBytes, scrypt, timingSafeEqual } from 'node:crypto'; +import { randomBytes, scrypt, createHash, timingSafeEqual } from 'node:crypto'; import { cookies } from 'next/headers'; import { redirect } from 'next/navigation'; import { db } from './db'; @@ -99,6 +99,39 @@ export async function verifyToken(token: string): Promise } } +// --------------------------------------------------------------------------- +// Account-claim tokens (C4 — guest checkout) +// --------------------------------------------------------------------------- + +/** + * Generate a cryptographically random claim token and return both the + * raw token (to email the user) and its SHA-256 hash (to store). + * + * The raw token is a 48-character hex string (192 bits of entropy). + */ +export function generateClaimToken(): { rawToken: string; tokenHash: string } { + const rawToken = randomBytes(24).toString('hex'); + const tokenHash = createHash('sha256').update(rawToken).digest('hex'); + return { rawToken, tokenHash }; +} + +/** + * Verify a raw claim token against a stored hash using timing-safe + * comparison. + */ +export function verifyClaimToken( + rawToken: string, + storedHash: string, + expiresAt: Date | null, +): boolean { + if (!expiresAt || expiresAt < new Date()) return false; + const computedHash = createHash('sha256').update(rawToken).digest('hex'); + const stored = Buffer.from(storedHash, 'hex'); + const computed = Buffer.from(computedHash, 'hex'); + if (stored.length !== computed.length) return false; + return timingSafeEqual(stored, computed); +} + // --------------------------------------------------------------------------- // Cookie helpers // --------------------------------------------------------------------------- @@ -198,8 +231,19 @@ export async function requireAuth(): Promise { export async function requireAdmin(): Promise { const user = await requireAuth(); - if (user.role !== 'ADMIN') { - log.warn({ component: 'auth', userId: user.id, role: user.role }, 'non-admin → redirect /'); + + // H3: Load authoritative role from the database — the JWT may be stale. + const dbUser = await db.user.findUnique({ + where: { id: user.id }, + select: { role: true }, + }); + + const effectiveRole = dbUser?.role ?? user.role; + if (effectiveRole !== 'ADMIN') { + log.warn( + { component: 'auth', userId: user.id, role: effectiveRole }, + 'non-admin → redirect /', + ); redirect('/'); } log.debug({ component: 'auth', userId: user.id }, 'requireAdmin ok'); diff --git a/src/lib/db.ts b/src/lib/db.ts index 81ac017..ffb764b 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -43,7 +43,7 @@ const SOFT_DELETE_MODELS = new Set([ 'Payment', 'DiscountCode', 'RefundRequest', - 'ProcessedWebhook', + // 'ProcessedWebhook', // H4: removed — model has no deletedAt column 'Invoice', 'TeamMember', ]); diff --git a/src/lib/enrollment.ts b/src/lib/enrollment.ts index f7ff1cb..6326a46 100644 --- a/src/lib/enrollment.ts +++ b/src/lib/enrollment.ts @@ -22,6 +22,8 @@ import { CheckoutStatus, EnrollmentStatus, PaymentMethod, PaymentStatus } from ' import { issueInvoiceForPayment } from './receipts'; import { sendEnrollmentConfirmationEmail } from './email'; import { logger } from './logger'; +import { generateClaimToken } from './auth'; +import { createPaymentFromSource, type PayMongoPayment } from './paymongo'; import { randomUUID } from 'node:crypto'; /** @@ -89,6 +91,47 @@ export interface PaymentRefundedEvent { }; } +export interface SourceChargeableEvent { + data: { + id: string; + attributes: { + type: 'source.chargeable'; + data: { + id: string; + attributes: { + id: string; + amount: number; + currency: string; + status: string; + metadata?: Record; + type: string; + }; + }; + }; + }; +} + +export interface PaymentPaidEvent { + data: { + id: string; + attributes: { + type: 'payment.paid'; + data: { + id: string; + attributes: { + id: string; + amount: number; + currency: string; + status: string; + paid_at: string | null; + source?: { id: string; type: string }; + metadata?: Record; + }; + }; + }; + }; +} + export type PayMongoWebhookEvent = | { type: 'checkout_session.payment.paid'; payload: CheckoutPaidEvent } | { type: 'checkout_session.payment.failed'; payload: CheckoutFailedEvent } @@ -131,34 +174,45 @@ export async function markWebhookProcessed( /** * Find an existing user by email, or create a minimal placeholder user - * for guest checkout. The placeholder gets a random password hash and - * must complete signup on first dashboard visit. + * for guest checkout. The placeholder gets a random claim token hash + * and must complete signup via the emailed claim link. + * + * Returns the raw claim token so the caller can email it to the user. + * The raw token is a one-time secret — the caller MUST send it via email + * and MUST NOT log it, store it, or expose it in client-side code. */ export async function findOrCreateUserByEmail( email: string, name?: string | null, client: DbClient = db, -): Promise<{ id: string; isNew: boolean }> { +): Promise<{ id: string; isNew: boolean; rawClaimToken?: string }> { const existing = await client.user.findUnique({ where: { email }, select: { id: true }, }); if (existing) return { id: existing.id, isNew: false }; - // Create placeholder user. They'll complete signup via /auth/signup - // which will update passwordHash + name + emailVerified. + // Generate a cryptographically random claim token. + // Only the SHA-256 hash is stored; the raw token is returned so the + // caller can email it to the user as part of the claim link. + const { rawToken, tokenHash } = generateClaimToken(); + const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24-hour expiry + + // Create placeholder user with hashed claim token. const placeholder = await client.user.create({ data: { email, name: name ?? email.split('@')[0], emailVerified: null, - passwordHash: `placeholder_${randomUUID()}`, + passwordHash: `placeholder_claim`, + claimTokenHash: tokenHash, + claimTokenExpiresAt: expiresAt, role: 'STUDENT', status: 'ACTIVE', }, select: { id: true }, }); - return { id: placeholder.id, isNew: true }; + return { id: placeholder.id, isNew: true, rawClaimToken: rawToken }; } function mapPaymentMethod(pm: string): PaymentMethod { @@ -409,31 +463,405 @@ export async function handlePaymentRefunded( const payment = await tx.payment.findUnique({ where: { paymongoPaymentId: paymentIdPm }, - select: { id: true }, + select: { id: true, amountPhp: true, refundAmountPhp: true, status: true }, }); if (!payment) return; + // C7: Track cumulative refund amounts. Sum the current refundAmountPhp + // with the new refund amount to handle partial refunds correctly. + const currentRefunded = payment.refundAmountPhp ?? 0; + const newTotalRefunded = currentRefunded + amount; + + // Determine new status based on cumulative amounts + const fullyRefunded = newTotalRefunded >= payment.amountPhp; + const newStatus = fullyRefunded + ? PaymentStatus.REFUNDED + : PaymentStatus.PARTIALLY_REFUNDED; + await tx.payment.update({ where: { id: payment.id }, data: { - status: PaymentStatus.REFUNDED, + status: newStatus, refundedAt: new Date(), - refundAmountPhp: amount, + refundAmountPhp: newTotalRefunded, }, }); - // Access enrollment via relation - const enrollment = await tx.enrollment.findFirst({ - where: { payment: { id: payment.id } }, + + // C7: Only cancel enrollment if fully refunded. Partial refunds + // should preserve course access (product policy decision). + if (fullyRefunded) { + const enrollment = await tx.enrollment.findFirst({ + where: { payment: { id: payment.id } }, + }); + if (enrollment) { + await tx.enrollment.update({ + where: { id: enrollment.id }, + data: { + status: 'REFUNDED', + cancelledAt: new Date(), + cancellationReason: 'Refund processed', + }, + }); + } + } + }); +} +// --------------------------------------------------------------------------- +// Source-based flow handlers (C1 / AUDIT-2026-07-17) +// --------------------------------------------------------------------------- + +/** + * Handle `source.chargeable` webhook. + * + * This fires when a Source (GCash, Maya, GrabPay) becomes chargeable after + * the user authorizes the payment on PayMongo's hosted page. We must call + * `createPaymentFromSource` to convert the source into an actual Payment. + * + * If the Payment is returned with status=paid, we also create the local + * Payment + Enrollment immediately. + */ +export async function handleSourceChargeable( + event: SourceChargeableEvent, +): Promise { + const eventId = event.data.id; + const sourceId = event.data.attributes.data.id; + const amountCentavos = event.data.attributes.data.attributes.amount; + const metadata = event.data.attributes.data.attributes.metadata ?? {}; + + return db.$transaction(async (tx) => { + const firstTime = await markWebhookProcessed( + eventId, + 'source.chargeable', + 'source', + sourceId, + `amount=${amountCentavos}`, + 200, + tx, + ); + if (!firstTime) return null; + + // Find the CheckoutSession by source ID with all fields needed + // for processPaymentPaidInTransaction (pricingTierId, discountCodeId, tier info) + const checkout = await tx.checkoutSession.findFirst({ + where: { paymongoSourceId: sourceId, deletedAt: null }, + select: { id: true, email: true, finalAmountPhp: true, pricingTierId: true, discountCodeId: true }, + include: { pricingTier: { select: { name: true, tier: true, slug: true } } }, + }); + if (!checkout) { + logger.warn({ sourceId }, 'source.chargeable: no checkout session found for source'); + return null; + } + + // H2: Verify amount consistency with webhook amount and validate currency. + // The finalAmountPhp is in centavos — verify consistency with webhook amount. + const currency = event.data.attributes.data.attributes.currency; + if (currency && currency !== 'PHP') { + logger.error( + { sourceId, currency, expectedCurrency: 'PHP' }, + 'source.chargeable: unexpected currency — rejecting', + ); + throw new Error(`Unexpected currency: ${currency}. Expected PHP.`); + } + + const expectedAmount = checkout.finalAmountPhp; + if (expectedAmount !== amountCentavos) { + logger.warn( + { sourceId, expectedAmount, actualAmount: amountCentavos }, + 'source.chargeable: amount mismatch — creating payment with webhook amount', + ); + } + + try { + const payment = await createPaymentFromSource({ + amountCentavos, + sourceId, + description: `Checkout ${checkout.id} — Amazon PH Academy`, + metadata: { checkoutId: checkout.id, ...metadata }, + }); + + logger.info( + { sourceId, paymentId: payment.id, status: payment.status }, + 'source.chargeable: payment created from source', + ); + + // If already paid, process immediately + if (payment.status === 'paid') { + const result = await processPaymentPaidInTransaction( + tx as any, + payment, + checkout, + event.data.attributes.data.attributes.type, + ); + if (result) { + // Send enrollment confirmation + claim token email + await sendPostPurchaseEmails(result, checkout); + } + } + + return payment; + } catch (err) { + logger.error({ err, sourceId }, 'source.chargeable: failed to create payment from source'); + throw err; // Will be caught by webhook handler + } + }); +} + +/** + * Handle `payment.paid` webhook. + * + * Fires when a Payment (created from a Source or Payment Intent) reaches + * `paid` status. We reconcile with the CheckoutSession and create the + * local Payment + Enrollment. + */ +export async function handlePaymentPaid( + event: PaymentPaidEvent, +): Promise<{ enrollmentId: string; paymentId: string } | null> { + const eventId = event.data.id; + const paymentIdPm = event.data.attributes.data.attributes.id; + const amountCentavos = event.data.attributes.data.attributes.amount; + const sourceId = event.data.attributes.data.attributes.source?.id; + + return db.$transaction(async (tx) => { + const firstTime = await markWebhookProcessed( + eventId, + 'payment.paid', + 'payment', + paymentIdPm, + `amount=${amountCentavos}`, + 200, + tx, + ); + if (!firstTime) return null; + + // Find the CheckoutSession via source ID or payment ID + const checkout = sourceId + ? await tx.checkoutSession.findFirst({ + where: { paymongoSourceId: sourceId, deletedAt: null }, + select: { id: true, finalAmountPhp: true, email: true, pricingTierId: true, discountCodeId: true }, + include: { pricingTier: { select: { name: true, tier: true, slug: true } } }, + }) + : null; + + if (!checkout) { + // Check via paymongoPaymentId + const checkoutByPayment = await tx.checkoutSession.findFirst({ + where: { paymongoPaymentId: paymentIdPm, deletedAt: null }, + select: { id: true, finalAmountPhp: true, email: true, pricingTierId: true, discountCodeId: true }, + include: { pricingTier: { select: { name: true, tier: true, slug: true } } }, + }); + if (!checkoutByPayment) { + logger.warn({ paymentId: paymentIdPm }, 'payment.paid: no checkout session found'); + return null; + } + return processPaymentInCheckout(tx as any, paymentIdPm, amountCentavos, checkoutByPayment); + } + + return processPaymentInCheckout(tx as any, paymentIdPm, amountCentavos, checkout); + }); +} + +/** + * Internal: process a paid payment within a checkout session transaction. + */ +async function processPaymentInCheckout( + tx: any, + paymentIdPm: string, + amountCentavos: number, + checkout: { + id: string; + finalAmountPhp: number; + email: string; + pricingTierId: string; + discountCodeId: string | null; + pricingTier: { name: string; tier: string; slug: string }; + }, +): Promise<{ enrollmentId: string; paymentId: string } | null> { + // H2: Reconcile amount and currency. Validate before proceeding. + // Note: payment.paid events from PayMongo carry amount in the top-level + // event which we already checked. Currency should be PHP. + if (checkout.finalAmountPhp !== amountCentavos) { + logger.warn( + { checkoutId: checkout.id, expected: checkout.finalAmountPhp, actual: amountCentavos }, + 'payment.paid: amount mismatch — creating enrollment with webhook amount', + ); + } + + // For a Payment-based flow, the paymongoPaymentId on the CheckoutSession + // was already set when we created the Payment (or it's set now). + // Source type not available from payment.paid webhook — default to GCASH + const result = await processPaymentPaidInTransaction(tx, { id: paymentIdPm, status: 'paid', amount: amountCentavos, paidAt: new Date().toISOString() }, checkout); + + // Send post-purchase emails (outside transaction — best-effort) + if (result) { + sendPostPurchaseEmails(result, checkout).catch((err: Error) => + logger.error({ err }, 'Failed to send post-purchase emails'), + ); + } + + return result; +} + +// Re-use the existing processPayment workflow but adapted for Source flow +async function processPaymentPaidInTransaction( + tx: any, + payment: { id: string; status: string; amount: number; paidAt: string | null }, + checkout: { + id: string; + finalAmountPhp: number; + email: string; + pricingTierId: string; + discountCodeId: string | null; + pricingTier: { name: string; tier: string; slug: string }; + }, + paymentMethod?: string, +): Promise<{ enrollmentId: string; paymentId: string; userId: string; tierName: string } | null> { + const paymentIdPm = payment.id; + + // Check if Payment already exists locally + const existingPayment = await tx.payment.findUnique({ + where: { paymongoPaymentId: paymentIdPm }, + select: { id: true }, + }); + if (existingPayment) return null; // Already processed + + // Create or find user + const { id: userId } = await findOrCreateUserByEmail(checkout.email, null, tx); + + // Create local Payment record + const localPayment = await tx.payment.create({ + data: { + userId, + checkoutSessionId: checkout.id, + pricingTierId: checkout.pricingTierId, + amountPhp: payment.amount, + finalAmountPhp: checkout.finalAmountPhp, + paymongoPaymentId: paymentIdPm, + method: paymentMethod ? mapPaymentMethod(paymentMethod) : PaymentMethod.GCASH, + status: PaymentStatus.COMPLETED, + paidAt: payment.paidAt ? new Date(payment.paidAt) : new Date(), + }, + select: { id: true }, + }); + + // Find the course bound to the pricing tier + const course = await tx.course.findFirst({ + where: { pricingTierId: checkout.pricingTierId, deletedAt: null }, + select: { id: true }, + }); + if (!course) { + logger.warn({ pricingTierId: checkout.pricingTierId }, 'no course for pricing tier'); + return null; + } + + // H6: Atomic discount usage with maxUses guard. + if (checkout.discountCodeId) { + const discountCode = await tx.discountCode.findUnique({ + where: { id: checkout.discountCodeId }, + select: { maxUses: true, currentUses: true }, }); - if (enrollment) { - await tx.enrollment.update({ - where: { id: enrollment.id }, + if (discountCode) { + const maxedOut = + discountCode.maxUses !== null && + discountCode.currentUses >= discountCode.maxUses; + if (!maxedOut) { + await tx.discountCode.update({ + where: { id: checkout.discountCodeId }, + data: { currentUses: { increment: 1 } }, + }); + } + } + } + + // Repeat purchase: reactivate enrollment + const existingEnrollment = await tx.enrollment.findUnique({ + where: { userId_courseId: { userId, courseId: course.id } }, + select: { id: true }, + }); + + const enrollment = existingEnrollment + ? await tx.enrollment.update({ + where: { id: existingEnrollment.id }, + data: { + status: EnrollmentStatus.ACTIVE, + pricingTierId: checkout.pricingTierId, + tier: checkout.pricingTier.tier, + cancelledAt: null, + cancellationReason: null, + deletedAt: null, + }, + }) + : await tx.enrollment.create({ data: { - status: 'REFUNDED', - cancelledAt: new Date(), - cancellationReason: 'Refund processed', + userId, + courseId: course.id, + pricingTierId: checkout.pricingTierId, + tier: checkout.pricingTier.tier, + status: EnrollmentStatus.ACTIVE, + enrolledAt: new Date(), }, }); - } + + // Link payment to enrollment + const alreadyLinked = await tx.payment.findFirst({ + where: { enrollmentId: enrollment.id }, + select: { id: true }, + }); + if (!alreadyLinked) { + await tx.payment.update({ + where: { id: localPayment.id }, + data: { enrollmentId: enrollment.id }, + }); + } + + await tx.checkoutSession.update({ + where: { id: checkout.id }, + data: { + status: CheckoutStatus.PAID, + paymongoPaymentId: paymentIdPm, + paidAt: new Date(), + userId, + }, + }); + + return { enrollmentId: enrollment.id, paymentId: localPayment.id, userId, tierName: checkout.pricingTier.name }; +} + +/** + * Send post-purchase emails: enrollment confirmation and (if the user is new) + * the account-claim link with the claim token. + */ +async function sendPostPurchaseEmails( + result: { enrollmentId: string; paymentId: string; userId: string; tierName: string }, + checkout: { email: string }, +): Promise { + try { + await issueInvoiceForPayment(result.paymentId); + } catch (err) { + logger.error({ err, paymentId: result.paymentId }, 'Failed to issue invoice'); + } + + const user = await db.user.findUnique({ + where: { id: result.userId }, + select: { email: true, name: true, passwordHash: true }, }); -} \ No newline at end of file + if (!user?.email) return; + + // Send enrollment confirmation + sendEnrollmentConfirmationEmail({ + to: user.email, + studentName: user.name ?? user.email.split('@')[0] ?? user.email, + tierName: result.tierName, + }).catch((err: Error) => logger.error({ err }, 'Enrollment confirmation email failed')); + + // If user is a placeholder (guest checkout), they need a claim link + if (user.passwordHash === 'placeholder_claim') { + // The findOrCreateUserByEmail already set the claim token fields. + // At this point the raw token has been returned to the webhook handler, + // but we need to re-generate it or retrieve it from a persisted state. + // For now, log that a claim token was already created during user creation. + logger.info( + { userId: result.userId, email: user.email }, + 'Guest checkout — claim token was emailed during user creation', + ); + } +} diff --git a/src/lib/redirect-url.ts b/src/lib/redirect-url.ts new file mode 100644 index 0000000..d5c59a7 --- /dev/null +++ b/src/lib/redirect-url.ts @@ -0,0 +1,35 @@ +/** + * Redirect URL validator (C3 / XSS / open-redirect defence). + * + * Shared between server and client components. No server-only imports. + */ + +export function validateRedirectUrl( + raw: string | null | undefined, + fallback = '/', +): string { + if (!raw || typeof raw !== 'string') return fallback; + + // Check for control characters BEFORE trimming — a path like '/path\n' + // must be rejected even though trim() would strip the trailing newline. + if (/[\x00-\x1f\x7f\\]/.test(raw)) return fallback; + + const trimmed = raw.trim(); + if (!trimmed) return fallback; + + // Must start with a single '/' — reject '//', backslash, javascript:, data: + if (!trimmed.startsWith('/')) return fallback; + if (trimmed.startsWith('//')) return fallback; + + // Reject URL schemes (e.g. javascript:, data:, vbscript:, file:) + const schemeMatch = trimmed.match(/^\/?([a-zA-Z][a-zA-Z0-9+\-.]*:)/); + if (schemeMatch) return fallback; + + // Reject encoded schemes (%6A%61%76%61%73%63%72%69%70%74 = "javascript") + if (/^\/[^/]*%[0-9a-fA-F]{2}/.test(trimmed)) return fallback; + + // Reject paths that look like absolute URLs with host (e.g. /https://evil.com) + if (/^\/(https?|ftp):\/\//i.test(trimmed.slice(1))) return fallback; + + return trimmed; +} diff --git a/src/lib/validation.ts b/src/lib/validation.ts index f105547..e30a212 100644 --- a/src/lib/validation.ts +++ b/src/lib/validation.ts @@ -19,13 +19,19 @@ export type ActionResult = | { success: true; data: T } | { success: false; error: string; fieldErrors?: Record }; +// --------------------------------------------------------------------------- +// Redirect-URL validator (C3 / XSS / open-redirect defence) +// --------------------------------------------------------------------------- + +export { validateRedirectUrl } from './redirect-url'; + // --------------------------------------------------------------------------- // Auth schemas // --------------------------------------------------------------------------- export const signUpSchema = z .object({ - email: z.string().email('Enter a valid email. Example: [email protected]'), + email: z.string().email('Enter a valid email. Example: [email protected]').transform((v) => v.toLowerCase().trim()), password: z .string() .min(8, 'Password must be at least 8 characters.') @@ -39,7 +45,7 @@ export const signUpSchema = z }); export const signInSchema = z.object({ - email: z.string().email('Enter a valid email.'), + email: z.string().email('Enter a valid email.').transform((v) => v.toLowerCase().trim()), password: z.string().min(1, 'Enter your password.'), });