Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
f5e61b8
fix: AMPH-v2 full codebase audit remediation
codex Jul 17, 2026
7454f57
fix: correct template literal escaping in enrollment.ts
codex Jul 17, 2026
ce8d3dc
fix: TypeScript errors in auth.ts, refunds.ts, enrollment.ts
codex Jul 17, 2026
0b2acf7
fix: add refundAmountPhp to approve action payment query
codex Jul 17, 2026
5d9d709
fix: prisma schema format
codex Jul 17, 2026
02b0055
fix: update tests for claim token changes and H3 role check
codex Jul 17, 2026
7fbd4e6
fix: update enrollment tests for claim token and cumulative refund ch…
codex Jul 17, 2026
2b3b21b
fix: correct refund test expectation for full refund (amount=299900)
codex Jul 17, 2026
353e161
test: add claim token function tests for coverage threshold
codex Jul 17, 2026
b3b42a3
test: add coverage for validateRedirectUrl and findOrCreateUserByEmai…
codex Jul 17, 2026
5e79ce1
test: fix enrollment test structure, add more coverage
codex Jul 17, 2026
aa0622d
test: fix crypto mock and control character test
codex Jul 17, 2026
c3b286a
fix: check control chars before trim; fix claim token test mock
codex Jul 17, 2026
dc97161
fix: proper regex escapes for control char check in validation.ts
codex Jul 17, 2026
11dc15d
test: add handleSourceChargeable tests for branch coverage
codex Jul 17, 2026
ab7a2f6
fix: extract validateRedirectUrl to client-safe module (build fix)
codex Jul 17, 2026
bb4eba4
fix: allow test admin defaults in non-production for seed (C5 CI fix)
codex Jul 17, 2026
b055cf5
fix: address CodeRabbit review comments
codex Jul 17, 2026
96db602
fix: move alreadyCompleted check before its usage in submitQuizAction…
codex Jul 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
5 changes: 5 additions & 0 deletions prisma/migrations/20260717000001_claim_token/migration.sql
Original file line number Diff line number Diff line change
@@ -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");
Original file line number Diff line number Diff line change
@@ -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"
10 changes: 10 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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])
Expand Down
22 changes: 20 additions & 2 deletions prisma/seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,26 @@ function hashPassword(password: string): string {
}

async function upsertAdminUser(): Promise<void> {
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 },
Expand Down
3 changes: 2 additions & 1 deletion src/app/(public)/auth/signin/SignInForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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');
Expand Down
4 changes: 3 additions & 1 deletion src/app/(public)/auth/signin/page.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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 (
<main id="main-content" className={styles.authContainer}>
Expand All @@ -31,7 +33,7 @@ export default async function SignInPage({ searchParams }: PageProps) {
<h1 className={styles.title}>Sign in</h1>
<p className={styles.subtitle}>Welcome back. Sign in to continue learning.</p>

<SignInForm error={error} redirectTo={params.redirect} />
<SignInForm error={error} redirectTo={safeRedirect} />

<p className={styles.footer}>
New here? <Link href="/auth/signup">Create an account</Link>
Expand Down
7 changes: 5 additions & 2 deletions src/app/(public)/auth/signup/SignUpForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | null>(initialError);
Expand All @@ -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 {
Expand Down
8 changes: 5 additions & 3 deletions src/app/(public)/auth/signup/page.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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) {
Expand All @@ -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 (
<main id="main-content" className={styles.authContainer}>
Expand All @@ -36,7 +38,7 @@ export default async function SignUpPage({ searchParams }: PageProps) {
Start with the free tools. Upgrade when you&apos;re ready for the full curriculum.
</p>

<SignUpForm error={error} prefilledEmail={prefilledEmail} nextUrl={nextUrl} />
<SignUpForm error={error} prefilledEmail={prefilledEmail} nextUrl={nextUrl} claimToken={claimToken} />

<p className={styles.footer}>
Already have an account? <Link href="/auth/signin">Sign in</Link>
Expand Down
15 changes: 8 additions & 7 deletions src/app/(public)/checkout/complete/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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 <FailedCard returnUrl={returnUrl} />;
Expand Down
40 changes: 35 additions & 5 deletions src/app/actions/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
'use server';

import { redirect } from 'next/navigation';
import { z } from 'zod';
import { db } from '@/lib/db';
import {
hashPassword,
Expand All @@ -13,6 +14,7 @@ import {
setAuthCookie,
clearAuthCookie,
getSession,
verifyClaimToken,
} from '@/lib/auth';
import { logger } from '@/lib/logger';
import { rateLimit } from '@/lib/rate-limit';
Expand All @@ -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) => {
Comment on lines +32 to +37

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🔴 Critical | ⚡ Quick win

Use safeExtend for the refined Zod schema.

signUpSchema contains .refine(...). In Zod 4, extending a schema with refinements through .extend() can throw during module initialization. Use .safeExtend(...) and cover schema construction in a test.

Proposed fix
-const signUpWithClaimSchema = signUpSchema.extend({
+const signUpWithClaimSchema = signUpSchema.safeExtend({
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// 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) => {
// Extended schema for guest-account claiming with a claim token
const signUpWithClaimSchema = signUpSchema.safeExtend({
claimToken: z.string().optional(),
});
export const signUpAction = createSafeAction(signUpWithClaimSchema, async (data) => {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/actions/auth.ts` around lines 31 - 36, Update signUpWithClaimSchema
to use Zod’s safeExtend instead of extend when adding claimToken to the refined
signUpSchema, preventing module-initialization failures. Add a test that imports
or constructs this schema and verifies construction succeeds.

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.`);
Expand All @@ -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,
},
});
Comment on lines +52 to 84

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🔴 Critical | 🏗️ Heavy lift

Atomically consume the one-time claim token.

Two concurrent requests can both verify the same hash before either update clears it. Both then set a password and receive valid sessions, with the last password winning. Guard the update by the placeholder marker, token hash, and expiry, and proceed only when exactly one row is updated.

Add a concurrent regression test. As per coding guidelines, when fixing a defect, reproduce it with the smallest test, fix the root cause, and add a regression test.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/actions/auth.ts` around lines 51 - 83, The claim-token update in the
registration flow must atomically consume the token to prevent concurrent reuse.
Update the database operation in the surrounding signup action to conditionally
match the existing user’s placeholder password marker, claimTokenHash, and
claimTokenExpiresAt, then require exactly one row to be updated before creating
or returning a session; handle a zero-row result as an invalid or
already-consumed token. Add a minimal concurrent regression test exercising two
requests with the same claim token and verifying only one succeeds.

Source: Coding guidelines

const token = await signToken({
Expand Down Expand Up @@ -153,10 +180,13 @@ export async function signOutAction(): Promise<ActionResult<{ ok: true }>> {
// ---------------------------------------------------------------------------

export async function signUpFormAction(formData: FormData): Promise<void> {
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) {
Expand Down
Loading
Loading