From 8cff8a567820772ddf882a9a61a0e1a9a3faf81d Mon Sep 17 00:00:00 2001 From: Dusk Date: Fri, 10 Jul 2026 07:45:46 +0000 Subject: [PATCH 1/2] AMPH rework July 2026: XP system additions, str-data-grid, middleware, env, db updates --- project/src/components/amph/str-data-grid.tsx | 1 - project/src/lib/db.ts | 54 ++-- project/src/lib/env.ts | 28 +- project/src/lib/xp.test.ts | 87 +++++++ project/src/lib/xp.ts | 97 +++++++ project/src/middleware.ts | 243 +++++++----------- 6 files changed, 332 insertions(+), 178 deletions(-) mode change 100755 => 100644 project/src/components/amph/str-data-grid.tsx mode change 100755 => 100644 project/src/lib/db.ts mode change 100755 => 100644 project/src/lib/env.ts create mode 100644 project/src/lib/xp.test.ts create mode 100644 project/src/lib/xp.ts mode change 100755 => 100644 project/src/middleware.ts diff --git a/project/src/components/amph/str-data-grid.tsx b/project/src/components/amph/str-data-grid.tsx old mode 100755 new mode 100644 index f41b1b6..3cd1bd5 --- a/project/src/components/amph/str-data-grid.tsx +++ b/project/src/components/amph/str-data-grid.tsx @@ -401,7 +401,6 @@ export function StrDataGrid() { [thresholds.acosTarget, thresholds.roasMinimum] ); - // eslint-disable-next-line react-hooks/incompatible-library const table = useReactTable({ data: searchTerms, columns, diff --git a/project/src/lib/db.ts b/project/src/lib/db.ts old mode 100755 new mode 100644 index de4bcf8..df485a2 --- a/project/src/lib/db.ts +++ b/project/src/lib/db.ts @@ -1,26 +1,40 @@ -import { PrismaClient } from '@prisma/client' +import { PrismaLibSQL } from '@prisma/adapter-libsql'; +import { PrismaClient } from '@prisma/client'; + +const databaseUrl = process.env.DATABASE_URL; +const databaseAuthToken = process.env.DATABASE_AUTH_TOKEN; + +if (process.env.NODE_ENV === 'production' && !databaseUrl) { + throw new Error('Production startup aborted: DATABASE_URL is not defined'); +} + +const resolvedDatabaseUrl = databaseUrl ?? 'file:./prisma/dev.db'; const globalForPrisma = globalThis as unknown as { - prisma: PrismaClient | undefined + prisma: PrismaClient | undefined; +}; + +function createPrismaClient(): PrismaClient { + const adapter = new PrismaLibSQL({ + url: resolvedDatabaseUrl, + ...(databaseAuthToken ? { authToken: databaseAuthToken } : {}), + }); + + return new PrismaClient({ + adapter, + log: + process.env.NODE_ENV === 'development' + ? ['query', 'error', 'warn'] + : ['error'], + }); +} + +export const db = globalForPrisma.prisma ?? createPrismaClient(); + +if (process.env.NODE_ENV !== 'production') { + globalForPrisma.prisma = db; } -// Lazily create PrismaClient — this defers connection errors to query time -// so the app can start even if DATABASE_URL is temporarily unavailable -export const db = - globalForPrisma.prisma ?? - new PrismaClient({ - // Only log queries in development — avoid leaking query details and - // performance overhead in production - log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'], - // Add connection timeout and retry settings for production resilience - datasources: process.env.DATABASE_URL - ? undefined - : undefined, - }) - -if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = db - -// Helper to check if database is configured export function isDatabaseConfigured(): boolean { - return Boolean(process.env.DATABASE_URL) + return typeof databaseUrl === 'string' && databaseUrl.length > 0; } diff --git a/project/src/lib/env.ts b/project/src/lib/env.ts old mode 100755 new mode 100644 index 78ee30d..690eb2a --- a/project/src/lib/env.ts +++ b/project/src/lib/env.ts @@ -1,21 +1,29 @@ -/** - * ProjectAmazonPH Academy: Environment validation — ponytail: Zod schema, ~15 lines. - */ - import { z } from 'zod'; const envSchema = z.object({ DATABASE_URL: z.string().min(1), - NEXTAUTH_SECRET: z.string().min(32, 'NEXTAUTH_SECRET must be >= 32 chars'), + DATABASE_AUTH_TOKEN: z.string().min(1).optional(), + NEXTAUTH_SECRET: z.string().min(32, 'NEXTAUTH_SECRET must be at least 32 characters'), NEXTAUTH_URL: z.string().url().optional(), + NEXT_PUBLIC_SITE_URL: z.string().url(), + DEMO_MODE: z.enum(['true', 'false']).optional(), NODE_ENV: z.enum(['development', 'production', 'test']).default('development'), }); -export function validateEnv() { +export type AppEnv = z.infer; + +export function validateEnv(): AppEnv | null { const parsed = envSchema.safeParse(process.env); - if (!parsed.success) { - const msgs = parsed.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`).join('; '); - console.warn(`[WARN] Env validation: ${msgs}`); + if (parsed.success) return parsed.data; + + const message = parsed.error.issues + .map((issue) => `${issue.path.join('.')}: ${issue.message}`) + .join('; '); + + if (process.env.NODE_ENV === 'production') { + throw new Error(`Invalid production environment: ${message}`); } - return parsed; + + console.warn(`[WARN] Environment validation failed: ${message}`); + return null; } diff --git a/project/src/lib/xp.test.ts b/project/src/lib/xp.test.ts new file mode 100644 index 0000000..cfa5491 --- /dev/null +++ b/project/src/lib/xp.test.ts @@ -0,0 +1,87 @@ +import { Prisma } from '@prisma/client'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + ledgerCreate: vi.fn(), + userUpdate: vi.fn(), + transaction: vi.fn(), +})); + +vi.mock('@/lib/db', () => ({ + db: { + $transaction: mocks.transaction, + }, +})); + +import { awardXp } from '@/lib/xp'; + +describe('awardXp', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.transaction.mockImplementation(async (callback) => + callback({ + xpLedger: { create: mocks.ledgerCreate }, + user: { update: mocks.userUpdate }, + }) + ); + mocks.ledgerCreate.mockResolvedValue({ id: 'ledger-1' }); + mocks.userUpdate + .mockResolvedValueOnce({ xp: 750 }) + .mockResolvedValueOnce({ xp: 750 }); + }); + + it('creates one ledger entry and updates the cached XP projection', async () => { + const granted = await awardXp( + 'user-1', + 'SIMULATION', + 'attempt-1', + 200, + { simulationType: 'BID_ELEVATOR' } + ); + + expect(granted).toBe(true); + expect(mocks.ledgerCreate).toHaveBeenCalledOnce(); + expect(mocks.userUpdate).toHaveBeenCalledTimes(2); + expect(mocks.userUpdate).toHaveBeenNthCalledWith(1, { + where: { id: 'user-1' }, + data: { + xp: { increment: 200 }, + lastActiveAt: expect.any(Date), + }, + select: { xp: true }, + }); + expect(mocks.userUpdate).toHaveBeenNthCalledWith(2, { + where: { id: 'user-1' }, + data: { level: 2 }, + }); + }); + + it('does not update cached XP when the source was already awarded', async () => { + mocks.ledgerCreate.mockRejectedValue( + new Prisma.PrismaClientKnownRequestError('Unique constraint failed', { + code: 'P2002', + clientVersion: '6.19.3', + }) + ); + + const granted = await awardXp( + 'user-1', + 'SIMULATION', + 'attempt-1', + 200 + ); + + expect(granted).toBe(false); + expect(mocks.userUpdate).not.toHaveBeenCalled(); + }); + + it('rejects invalid awards before opening a transaction', async () => { + await expect(awardXp('', 'SIMULATION', 'attempt-1', 200)).resolves.toBe(false); + await expect(awardXp('user-1', 'SIMULATION', 'attempt-1', 0)).resolves.toBe(false); + await expect( + awardXp('user-1', 'SIMULATION', 'attempt-1', Number.NaN) + ).resolves.toBe(false); + + expect(mocks.transaction).not.toHaveBeenCalled(); + }); +}); diff --git a/project/src/lib/xp.ts b/project/src/lib/xp.ts new file mode 100644 index 0000000..0348992 --- /dev/null +++ b/project/src/lib/xp.ts @@ -0,0 +1,97 @@ +import { Prisma } from '@prisma/client'; +import { db } from '@/lib/db'; + +const MAX_XP_AWARD = 1_000_000; +const MAX_METADATA_BYTES = 8_192; + +export interface AwardXpInput { + userId: string; + sourceType: string; + sourceId: string; + amount: number; + metadata?: unknown; +} + +/** + * Grants XP exactly once for a deterministic source key. + * + * The ledger insert and cached user projection update happen in one database + * transaction. If the unique ledger constraint is hit, the transaction rolls + * back and the user's XP is left unchanged. + * + * @returns true when XP was granted, false when the award was invalid or had + * already been granted. + */ +export async function awardXp( + userId: string, + sourceType: string, + sourceId: string, + amount: number, + metadata?: unknown +): Promise { + const input: AwardXpInput = { + userId: userId.trim(), + sourceType: sourceType.trim(), + sourceId: sourceId.trim(), + amount, + metadata, + }; + + if (!input.userId || !input.sourceType || !input.sourceId) return false; + if (!Number.isFinite(input.amount) || input.amount <= 0) return false; + + const xp = Math.min(Math.floor(input.amount), MAX_XP_AWARD); + const serializedMetadata = serializeMetadata(input.metadata); + + try { + await db.$transaction(async (tx) => { + await tx.xpLedger.create({ + data: { + userId: input.userId, + sourceType: input.sourceType, + sourceId: input.sourceId, + amount: xp, + metadata: serializedMetadata, + }, + }); + + const updatedUser = await tx.user.update({ + where: { id: input.userId }, + data: { + xp: { increment: xp }, + lastActiveAt: new Date(), + }, + select: { xp: true }, + }); + + await tx.user.update({ + where: { id: input.userId }, + data: { + level: Math.max(1, Math.floor(updatedUser.xp / 500) + 1), + }, + }); + }); + + return true; + } catch (error) { + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === 'P2002' + ) { + return false; + } + + throw error; + } +} + +function serializeMetadata(metadata: unknown): string | undefined { + if (metadata === undefined) return undefined; + + const serialized = JSON.stringify(metadata); + if (Buffer.byteLength(serialized, 'utf8') > MAX_METADATA_BYTES) { + throw new Error(`XP metadata exceeds ${MAX_METADATA_BYTES} bytes`); + } + + return serialized; +} diff --git a/project/src/middleware.ts b/project/src/middleware.ts old mode 100755 new mode 100644 index 8b2b9d9..219818f --- a/project/src/middleware.ts +++ b/project/src/middleware.ts @@ -1,163 +1,142 @@ -/** - * ProjectAmazonPH Academy: Middleware - * - * Handles authentication, security headers, rate limiting, and CORS. - * - * - Auth: Redirects unauthenticated users to /auth/signin - * - Security: Adds CSP, HSTS, X-Frame-Options, X-Content-Type-Options headers - * In production: CSP is tightened (no unsafe-eval, stricter script-src) - * - Rate Limiting: Protects /api/auth/signup from abuse - * - CORS: Allows same-origin requests; API routes return proper CORS headers - */ - -import { NextResponse } from 'next/server'; import type { NextRequest } from 'next/server'; +import { NextResponse } from 'next/server'; import { getToken } from 'next-auth/jwt'; import { checkRateLimit, getClientIp, RATE_LIMITS } from '@/lib/rate-limit'; -// Routes that don't require authentication -const PUBLIC_PATHS = [ +const PUBLIC_ROUTES = [ + '/', + '/about', + '/contact', + '/privacy', + '/terms', '/auth/signin', '/auth/signup', '/admin/login', '/api/auth', - '/', -]; - -// Routes that require ADMIN role -const ADMIN_PATHS = [ - '/admin/dashboard', - '/admin/users', - '/admin/courses', - '/admin/events', - '/admin/settings', -]; - -// Routes with rate limiting (checked before auth for public endpoints) -const RATE_LIMITED_ROUTES: { path: string; config: typeof RATE_LIMITS.signup }[] = [ - { path: '/api/auth/signup', config: RATE_LIMITS.signup }, -]; + '/api/health', + '/verify', +] as const; + +export function matchesRoute(pathname: string, route: string): boolean { + if (route === '/') return pathname === '/'; + return pathname === route || pathname.startsWith(`${route}/`); +} + +export function isAdminRoute(pathname: string): boolean { + if (matchesRoute(pathname, '/admin/login')) return false; + return pathname === '/admin' || pathname.startsWith('/admin/'); +} + +const RATE_LIMITED_ROUTES: Array<{ + path: string; + config: typeof RATE_LIMITS.signup; +}> = [{ path: '/api/auth/signup', config: RATE_LIMITS.signup }]; const isProd = process.env.NODE_ENV === 'production'; -// Security headers applied to all responses const SECURITY_HEADERS: Record = { 'X-Frame-Options': 'DENY', 'X-Content-Type-Options': 'nosniff', - 'X-XSS-Protection': '1; mode=block', 'Referrer-Policy': 'strict-origin-when-cross-origin', - 'Permissions-Policy': 'camera=(), microphone=(), geolocation=()', - // HSTS — only enforce in production (localhost doesn't use HTTPS) + 'Permissions-Policy': 'camera=(), microphone=(), geolocation=() ', + 'Cross-Origin-Opener-Policy': 'same-origin', + 'Cross-Origin-Resource-Policy': 'same-origin', ...(isProd ? { 'Strict-Transport-Security': 'max-age=63072000; includeSubDomains; preload' } : {}), }; -// Content Security Policy — production-tightened function getCspDirectives(nonce?: string): string { const scriptSrc = isProd ? nonce - ? `script-src 'self' 'nonce-${nonce}'` // Production with nonce: no unsafe-inline/eval - : `script-src 'self'` // Production without nonce: most restrictive - : "script-src 'self' 'unsafe-inline' 'unsafe-eval'"; // Dev: Next.js requires unsafe-inline/eval - - const styleSrc = isProd - ? "style-src 'self' 'unsafe-inline'" // Tailwind CSS still requires unsafe-inline - : "style-src 'self' 'unsafe-inline'"; + ? `script-src 'self' 'nonce-${nonce}' 'strict-dynamic'` + : "script-src 'self'" + : "script-src 'self' 'unsafe-inline' 'unsafe-eval'"; return [ "default-src 'self'", scriptSrc, - styleSrc, - "img-src 'self' data: blob: https://z-cdn.chatglm.cn", - "font-src 'self' https://fonts.gstatic.com", + "style-src 'self' 'unsafe-inline'", + "img-src 'self' data: blob:", + "font-src 'self' data:", "connect-src 'self'", + "object-src 'none'", "frame-ancestors 'none'", "base-uri 'self'", "form-action 'self'", + 'upgrade-insecure-requests', ].join('; '); } +function createNextResponse(request: NextRequest, nonce?: string): NextResponse { + const requestHeaders = new Headers(request.headers); + if (nonce) requestHeaders.set('x-nonce', nonce); + + const response = NextResponse.next({ + request: { headers: requestHeaders }, + }); + applySecurityHeaders(response, nonce); + return response; +} + +function applySecurityHeaders(response: NextResponse, nonce?: string): void { + for (const [key, value] of Object.entries(SECURITY_HEADERS)) { + response.headers.set(key, value.trim()); + } + response.headers.set('Content-Security-Policy', getCspDirectives(nonce)); +} + export async function middleware(request: NextRequest) { const { pathname } = request.nextUrl; - // --- Rate Limiting (before auth check so public endpoints are also protected) --- for (const route of RATE_LIMITED_ROUTES) { - if (pathname.startsWith(route.path)) { - const ip = getClientIp(request); - const result = checkRateLimit(`rl:${route.path}:${ip}`, route.config); - - if (!result.allowed) { - return NextResponse.json( - { error: 'Too many requests. Please try again later.', code: 'RATE_LIMITED' }, - { - status: 429, - headers: { - 'Retry-After': String(Math.ceil((result.resetAt - Date.now()) / 1000)), - 'X-RateLimit-Remaining': '0', - 'X-RateLimit-Reset': String(Math.ceil(result.resetAt / 1000)), - }, - } - ); - } + if (!matchesRoute(pathname, route.path)) continue; + + const ip = getClientIp(request); + const result = checkRateLimit(`rl:${route.path}:${ip}`, route.config); + if (!result.allowed) { + const response = NextResponse.json( + { error: 'Too many requests. Please try again later.', code: 'RATE_LIMITED' }, + { + status: 429, + headers: { + 'Retry-After': String(Math.ceil((result.resetAt - Date.now()) / 1000)), + 'X-RateLimit-Remaining': '0', + 'X-RateLimit-Reset': String(Math.ceil(result.resetAt / 1000)), + }, + } + ); + applySecurityHeaders(response); + return response; } } - // --- Generate nonce for CSP (production only) --- - const nonce = isProd ? Buffer.from(crypto.randomUUID()).toString('base64') : undefined; + const nonce = isProd ? btoa(crypto.randomUUID()) : undefined; + + if ( + pathname.startsWith('/_next') || + pathname === '/favicon.ico' || + /\.[a-zA-Z0-9]+$/.test(pathname) + ) { + return createNextResponse(request, nonce); + } - // --- Allow public routes --- - if (PUBLIC_PATHS.some((path) => pathname.startsWith(path))) { - // If authenticated user visits /, redirect to dashboard + if (PUBLIC_ROUTES.some((route) => matchesRoute(pathname, route))) { if (pathname === '/') { const token = await getToken({ req: request, secret: process.env.NEXTAUTH_SECRET, }); if (token) { - return NextResponse.redirect(new URL('/dashboard', request.url)); + const response = NextResponse.redirect(new URL('/dashboard', request.url)); + applySecurityHeaders(response, nonce); + return response; } } - const response = NextResponse.next(); - applySecurityHeaders(response, nonce); - - // Add CORS headers for auth API routes - if (pathname.startsWith('/api/auth')) { - response.headers.set('Access-Control-Allow-Origin', request.headers.get('origin') || ''); - response.headers.set('Access-Control-Allow-Credentials', 'true'); - } - return response; - } - - // --- Health check is public --- - if (pathname === '/api/health') { - const response = NextResponse.next(); - applySecurityHeaders(response, nonce); - return response; - } - - // --- Allow static files and Next.js internals (strict check) --- - if ( - pathname.startsWith('/_next') || - pathname === '/favicon.ico' || - /^\.[a-zA-Z0-9]+$/.test(pathname.slice(pathname.lastIndexOf('/'))) - ) { - return NextResponse.next(); - } - - // --- CORS preflight for API routes --- - if (pathname.startsWith('/api/') && request.method === 'OPTIONS') { - const response = new NextResponse(null, { status: 204 }); - response.headers.set('Access-Control-Allow-Origin', request.headers.get('origin') || ''); - response.headers.set('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); - response.headers.set('Access-Control-Allow-Headers', 'Content-Type, Authorization'); - response.headers.set('Access-Control-Allow-Credentials', 'true'); - response.headers.set('Access-Control-Max-Age', '86400'); - return response; + return createNextResponse(request, nonce); } - // --- Auth check --- const token = await getToken({ req: request, secret: process.env.NEXTAUTH_SECRET, @@ -165,51 +144,21 @@ export async function middleware(request: NextRequest) { if (!token) { const signInUrl = new URL('/auth/signin', request.url); - signInUrl.searchParams.set('callbackUrl', pathname); - return NextResponse.redirect(signInUrl); - } - - // --- Admin route check --- - if (ADMIN_PATHS.some((path) => pathname.startsWith(path))) { - const role = (token as { role?: string })?.role; - if (role !== 'ADMIN') { - const adminLoginUrl = new URL('/admin/login', request.url); - adminLoginUrl.searchParams.set('callbackUrl', pathname); - return NextResponse.redirect(adminLoginUrl); - } - } - - // --- Apply security headers to all authenticated responses --- - const response = NextResponse.next(); - applySecurityHeaders(response, nonce); - - // --- CORS for authenticated API routes --- - if (pathname.startsWith('/api/')) { - response.headers.set('Access-Control-Allow-Origin', request.headers.get('origin') || ''); - response.headers.set('Access-Control-Allow-Credentials', 'true'); + signInUrl.searchParams.set('callbackUrl', `${pathname}${request.nextUrl.search}`); + const response = NextResponse.redirect(signInUrl); + applySecurityHeaders(response, nonce); + return response; } - // --- Inject nonce into request headers for server components to use --- - if (nonce) { - response.headers.set('X-CSP-Nonce', nonce); + if (isAdminRoute(pathname) && token.role !== 'ADMIN') { + const response = NextResponse.redirect(new URL('/dashboard', request.url)); + applySecurityHeaders(response, nonce); + return response; } - return response; -} - -/** - * Apply security headers to a response. - */ -function applySecurityHeaders(response: NextResponse, nonce?: string): void { - for (const [key, value] of Object.entries(SECURITY_HEADERS)) { - response.headers.set(key, value); - } - // CSP with production tightening - response.headers.set('Content-Security-Policy', getCspDirectives(nonce)); + return createNextResponse(request, nonce); } export const config = { - matcher: [ - '/((?!_next/static|_next/image|favicon.ico).*)', - ], + matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'], }; From 0efb613aa57f97303c2723d6160d85debf84fafe Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Fri, 10 Jul 2026 16:01:34 +0000 Subject: [PATCH 2/2] fix: add missing @prisma/adapter-libsql dependency --- project/package.json | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/project/package.json b/project/package.json index 1e6cb5f..63406f3 100755 --- a/project/package.json +++ b/project/package.json @@ -55,7 +55,8 @@ "tailwind-merge": "^3.3.1", "tailwindcss-animate": "^1.0.7", "zod": "^4.0.2", - "zustand": "^5.0.6" + "zustand": "^5.0.6", + "@prisma/adapter-libsql": "^6.11.1" }, "devDependencies": { "@playwright/test": "^1.60.0", @@ -72,4 +73,4 @@ "typescript": "^5", "vitest": "^4.1.8" } -} +} \ No newline at end of file