diff --git a/src/app/api/performance/vitals/route.ts b/src/app/api/performance/vitals/route.ts index 695a4784..af7b338e 100644 --- a/src/app/api/performance/vitals/route.ts +++ b/src/app/api/performance/vitals/route.ts @@ -1,6 +1,8 @@ import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; import { edgeLog } from '@/../infra/edge-config'; import { query } from '@/lib/db/pool'; +import { requireAuth } from '@/lib/authMiddleware'; export const runtime = 'nodejs'; @@ -14,35 +16,23 @@ const RANGE_INTERVALS: Record = { const POOR_ALERT_THRESHOLD_PCT = 5; const POOR_CHECK_SESSION_LIMIT = 500; -interface VitalsBody { - name: string; - value: number; - rating: string; - url?: string; - timestamp?: number; - id?: string; - delta?: number; - navigationType?: string; - userAgent?: string; -} +const vitalsSchema = z.object({ + name: z.string().min(1), + value: z.number().finite(), + rating: z.enum(['good', 'needs-improvement', 'poor']), + url: z.string().optional(), + timestamp: z.number().optional(), + id: z.string().optional(), + delta: z.number().optional(), + navigationType: z.string().optional(), + userAgent: z.string().optional(), +}); function parseRange(range: string | null): string { if (!range || !RANGE_INTERVALS[range]) return RANGE_INTERVALS['7d']; return RANGE_INTERVALS[range]; } -function validateMetric(body: unknown): body is VitalsBody { - if (!body || typeof body !== 'object') return false; - const m = body as Record; - return ( - typeof m.name === 'string' && - typeof m.value === 'number' && - Number.isFinite(m.value) && - typeof m.rating === 'string' && - ['good', 'needs-improvement', 'poor'].includes(m.rating) - ); -} - async function checkPoorRate(name: string): Promise { const result = await query( ` @@ -75,18 +65,26 @@ async function checkPoorRate(name: string): Promise { } export async function POST(request: NextRequest) { + const unauth = requireAuth(request); + if (unauth) return unauth; + edgeLog('info', '/api/performance/vitals', 'POST request received'); try { const body: unknown = await request.json(); - if (!validateMetric(body)) { + const parsed = vitalsSchema.safeParse(body); + if (!parsed.success) { return NextResponse.json( - { success: false, message: 'Invalid metric payload' }, + { + success: false, + message: 'Invalid metric payload', + errors: parsed.error.flatten().fieldErrors, + }, { status: 400 }, ); } - const { name, value, rating, url, timestamp } = body; + const { name, value, rating, url, timestamp } = parsed.data; const result = await query( `INSERT INTO web_vitals (name, value, rating, page_url, created_at) diff --git a/src/lib/authMiddleware.ts b/src/lib/authMiddleware.ts index cdce28c9..53cb0e12 100644 --- a/src/lib/authMiddleware.ts +++ b/src/lib/authMiddleware.ts @@ -1,10 +1,18 @@ import { NextRequest, NextResponse } from 'next/server'; /** - * Validates the Authorization header and returns a 401 response if missing or invalid. + * Checks for authentication via Bearer token or internal API secret. + * Returns a 401 response if neither is valid, or null if authorized. * Usage: const unauth = requireAuth(request); if (unauth) return unauth; */ export function requireAuth(request: NextRequest): NextResponse | null { + const internalToken = request.headers.get('x-internal-token'); + const internalSecret = process.env.INTERNAL_API_SECRET; + + if (internalToken && internalSecret && internalToken === internalSecret) { + return null; + } + const authHeader = request.headers.get('authorization'); if (!authHeader || !authHeader.startsWith('Bearer ')) {