From 731bebf99e8115b9ad9a41b5ae9cb4a24a5fe0cf Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 13 Sep 2025 18:53:13 +0000 Subject: [PATCH 1/9] Fix CodeRabbit issues: implement validation middleware, fix config imports, handle KV errors - Add comprehensive body validation middleware for /sessions, /tutorials, /users endpoints - Fix config import issues by moving to static imports at top of files - Add proper KV persistence error handling with success checks - Validate tutorialId as string and prevent path traversal attacks - Fix implicit any types on request body parameters - Replace parseInt with Number.parseInt for consistency - Add proper 400 error responses with detailed validation messages - Use existing types from app/chat/types.ts for validation - Prevent TypeError when no progress exists by handling 404 responses gracefully Co-Authored-By: srith@agentuity.com --- .../sessions/[sessionId]/messages/route.ts | 38 ++- app/api/sessions/[sessionId]/route.ts | 38 ++- app/api/sessions/route.ts | 26 +- .../[id]/steps/[stepNumber]/route.ts | 40 ++- app/api/tutorials/route.ts | 6 +- app/api/users/tutorial-state/route.ts | 53 ++-- lib/validation/middleware.ts | 250 ++++++++++++++++++ lib/validation/types.ts | 40 +++ 8 files changed, 417 insertions(+), 74 deletions(-) create mode 100644 lib/validation/middleware.ts create mode 100644 lib/validation/types.ts diff --git a/app/api/sessions/[sessionId]/messages/route.ts b/app/api/sessions/[sessionId]/messages/route.ts index b3167dcb..63754507 100644 --- a/app/api/sessions/[sessionId]/messages/route.ts +++ b/app/api/sessions/[sessionId]/messages/route.ts @@ -9,6 +9,7 @@ import { import { toISOString, getCurrentTimestamp } from "@/app/chat/utils/dateUtils"; import { getAgentPulseConfig } from "@/lib/env"; import { config } from "@/lib/config"; +import { parseAndValidateJSON, validateMessage } from "@/lib/validation/middleware"; // Constants const DEFAULT_CONVERSATION_HISTORY_LIMIT = 10; @@ -63,19 +64,34 @@ export async function POST( const paramsData = await params; const sessionId = paramsData.sessionId; - const body = await request.json(); - const { message, processWithAgent = true } = body as { - message: Message; - processWithAgent?: boolean; - }; - - if (!message) { - return NextResponse.json( - { error: "Message data is required" }, - { status: 400 } - ); + + const validation = await parseAndValidateJSON(request, (body: any) => { + if (!body || typeof body !== 'object') { + return { success: false, errors: [{ field: 'body', message: 'must be an object', received: typeof body }] }; + } + + const messageValidation = validateMessage(body.message); + if (!messageValidation.success) { + return messageValidation; + } + + const processWithAgent = body.processWithAgent !== undefined ? Boolean(body.processWithAgent) : true; + + return { + success: true, + data: { + message: messageValidation.data, + processWithAgent + } + }; + }); + + if (!validation.success) { + return validation.response; } + const { message, processWithAgent } = validation.data; + // Ensure timestamp is in ISO string format if (message.timestamp) { message.timestamp = toISOString(message.timestamp); diff --git a/app/api/sessions/[sessionId]/route.ts b/app/api/sessions/[sessionId]/route.ts index bd7ea9b3..688e7627 100644 --- a/app/api/sessions/[sessionId]/route.ts +++ b/app/api/sessions/[sessionId]/route.ts @@ -3,6 +3,7 @@ import { getKVValue, setKVValue, deleteKVValue } from '@/lib/kv-store'; import { Session, Message } from '@/app/chat/types'; import { toISOString } from '@/app/chat/utils/dateUtils'; import { config } from '@/lib/config'; +import { parseAndValidateJSON, validateSession, validateMessage } from '@/lib/validation/middleware'; /** * GET /api/sessions/[sessionId] - Get a specific session @@ -53,12 +54,18 @@ export async function PUT( const paramsData = await params; const sessionId = paramsData.sessionId; - const session = await request.json() as Session; const sessionKey = `${userId}_${sessionId}`; - if (!session || session.sessionId !== sessionId) { + const validation = await parseAndValidateJSON(request, validateSession); + if (!validation.success) { + return validation.response; + } + + const session = validation.data; + + if (session.sessionId !== sessionId) { return NextResponse.json( - { error: 'Invalid session data or session ID mismatch' }, + { error: 'Session ID mismatch' }, { status: 400 } ); } @@ -192,15 +199,24 @@ export async function POST( const paramsData = await params; const sessionId = paramsData.sessionId; const sessionKey = `${userId}_${sessionId}`; - const { message } = await request.json() as { message: Message }; - - if (!message) { - return NextResponse.json( - { error: 'Message data is required' }, - { status: 400 } - ); + + const validation = await parseAndValidateJSON(request, (body: any) => { + if (!body || typeof body !== 'object') { + return { success: false, errors: [{ field: 'body', message: 'must be an object', received: typeof body }] }; + } + const messageValidation = validateMessage(body.message); + if (!messageValidation.success) { + return messageValidation; + } + return { success: true, data: { message: messageValidation.data } }; + }); + + if (!validation.success) { + return validation.response; } + const { message } = validation.data; + // Get current session const sessionResponse = await getKVValue(sessionKey, { storeName: config.defaultStoreName }); if (!sessionResponse.success || !sessionResponse.data) { @@ -256,4 +272,4 @@ export async function POST( { status: 500 } ); } -} \ No newline at end of file +} diff --git a/app/api/sessions/route.ts b/app/api/sessions/route.ts index f6817d85..a0a115cf 100644 --- a/app/api/sessions/route.ts +++ b/app/api/sessions/route.ts @@ -3,6 +3,7 @@ import { getKVValue, setKVValue } from '@/lib/kv-store'; import { Session } from '@/app/chat/types'; import { toISOString } from '@/app/chat/utils/dateUtils'; import { config } from '@/lib/config'; +import { parseAndValidateJSON, validateSession } from '@/lib/validation/middleware'; // Constants const DEFAULT_SESSIONS_LIMIT = 10; @@ -26,7 +27,17 @@ export async function GET(request: NextRequest) { const cursor = Number.isFinite(parsedCursor) ? Math.max(parsedCursor, 0) : 0; const response = await getKVValue(userId, { storeName: config.defaultStoreName }); - if (!response.success || !response.data?.length) { + if (!response.success) { + if (response.statusCode === 404) { + return NextResponse.json({ sessions: [], pagination: { cursor, nextCursor: null, hasMore: false, total: 0, limit } }); + } + return NextResponse.json( + { error: response.error || 'Failed to retrieve sessions' }, + { status: response.statusCode || 500 } + ); + } + + if (!response.data?.length) { return NextResponse.json({ sessions: [], pagination: { cursor, nextCursor: null, hasMore: false, total: 0, limit } }); } @@ -67,15 +78,14 @@ export async function POST(request: NextRequest) { if (!userId) { return NextResponse.json({ error: 'User ID not found' }, { status: 401 }); } - const session = await request.json() as Session; - if (!session || !session.sessionId) { - return NextResponse.json( - { error: 'Invalid session data' }, - { status: 400 } - ); + const validation = await parseAndValidateJSON(request, validateSession); + if (!validation.success) { + return validation.response; } + const session = validation.data; + // Process any messages to ensure timestamps are in ISO string format if (session.messages && session.messages.length > 0) { session.messages = session.messages.map(message => { @@ -136,4 +146,4 @@ export async function POST(request: NextRequest) { { status: 500 } ); } -} \ No newline at end of file +} diff --git a/app/api/tutorials/[id]/steps/[stepNumber]/route.ts b/app/api/tutorials/[id]/steps/[stepNumber]/route.ts index 86ec891a..b1c77783 100644 --- a/app/api/tutorials/[id]/steps/[stepNumber]/route.ts +++ b/app/api/tutorials/[id]/steps/[stepNumber]/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { join } from 'path'; import { readFile } from 'fs/promises'; import matter from 'gray-matter'; +import { validateTutorialId, validateStepNumber, createValidationError } from '@/lib/validation/middleware'; interface RouteParams { params: Promise<{ id: string; stepNumber: string }>; @@ -10,13 +11,18 @@ interface RouteParams { export async function GET(request: NextRequest, { params }: RouteParams) { try { const { id, stepNumber } = await params; - const stepIndex = parseInt(stepNumber, 10); - if (Number.isNaN(stepIndex) || stepIndex < 1) { - return NextResponse.json( - { success: false, error: 'Invalid step number' }, - { status: 400 } - ); + + const idValidation = validateTutorialId(id); + if (!idValidation.success) { + return createValidationError('Invalid tutorial ID', idValidation.errors || []); } + + const stepValidation = validateStepNumber(stepNumber); + if (!stepValidation.success) { + return createValidationError('Invalid step number', stepValidation.errors || []); + } + + const stepIndex = stepValidation.data; const repoRoot = process.cwd(); const tutorialDir = join(repoRoot, 'content', 'Tutorial', id); @@ -47,13 +53,21 @@ export async function GET(request: NextRequest, { params }: RouteParams) { async function loadSnippet(desc: { path: string; lang?: string; from?: number; to?: number; title?: string }) { const filePath = desc.path; if (!filePath || !filePath.startsWith('/examples/')) return; + + if (filePath.includes('..') || filePath.includes('\\')) return; + const absolutePath = join(repoRoot, `.${filePath}`); - const fileRaw = await readFile(absolutePath, 'utf-8'); - const lines = fileRaw.split(/\r?\n/); - const startIdx = Math.max(0, (desc.from ? desc.from - 1 : 0)); - const endIdx = Math.min(lines.length, desc.to ? desc.to : lines.length); - const content = lines.slice(startIdx, endIdx).join('\n'); - snippets.push({ ...desc, content }); + + try { + const fileRaw = await readFile(absolutePath, 'utf-8'); + const lines = fileRaw.split(/\r?\n/); + const startIdx = Math.max(0, (desc.from ? desc.from - 1 : 0)); + const endIdx = Math.min(lines.length, desc.to ? desc.to : lines.length); + const content = lines.slice(startIdx, endIdx).join('\n'); + snippets.push({ ...desc, content }); + } catch (error) { + console.warn(`Failed to load snippet from ${filePath}:`, error); + } } // 1) Parse blocks @@ -145,4 +159,4 @@ export async function GET(request: NextRequest, { params }: RouteParams) { { status: 500 } ); } -} \ No newline at end of file +} \ No newline at end of file diff --git a/app/api/tutorials/route.ts b/app/api/tutorials/route.ts index 0396b11e..be81bb7c 100644 --- a/app/api/tutorials/route.ts +++ b/app/api/tutorials/route.ts @@ -17,6 +17,10 @@ export async function GET(request: NextRequest) { const results: Array<{ id: string; title: string; description?: string; totalSteps: number }> = []; for (const entry of pages) { + if (entry.includes('..') || entry.includes('/') || entry.includes('\\')) { + continue; + } + const dirPath = join(tutorialRoot, entry); const filePath = join(tutorialRoot, `${entry}.mdx`); try { @@ -70,4 +74,4 @@ export async function GET(request: NextRequest) { { status: 500 } ); } -} \ No newline at end of file +} \ No newline at end of file diff --git a/app/api/users/tutorial-state/route.ts b/app/api/users/tutorial-state/route.ts index f7c49c6c..775f1365 100644 --- a/app/api/users/tutorial-state/route.ts +++ b/app/api/users/tutorial-state/route.ts @@ -1,5 +1,12 @@ import { NextRequest, NextResponse } from 'next/server'; import { TutorialStateManager } from '@/lib/tutorial/state-manager'; +import { setKVValue } from '@/lib/kv-store'; +import { config } from '@/lib/config'; +import { + parseAndValidateJSON, + validateTutorialProgressRequest, + validateTutorialResetRequest +} from '@/lib/validation/middleware'; /** * GET /api/users/tutorial-state - Get current user's tutorial state @@ -36,22 +43,12 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'User ID not found' }, { status: 401 }); } - let body; - try { - body = await request.json(); - } catch { - return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }); + const validation = await parseAndValidateJSON(request, validateTutorialProgressRequest); + if (!validation.success) { + return validation.response; } - const { tutorialId, currentStep, totalSteps } = body; - - - if (!tutorialId || typeof currentStep !== 'number' || typeof totalSteps !== 'number') { - return NextResponse.json( - { error: 'Invalid tutorial data. Required: tutorialId, currentStep, totalSteps' }, - { status: 400 } - ); - } + const { tutorialId, currentStep, totalSteps } = validation.data; await TutorialStateManager.updateTutorialProgress( userId, @@ -83,32 +80,28 @@ export async function DELETE(request: NextRequest) { return NextResponse.json({ error: 'User ID not found' }, { status: 401 }); } - let body; - try { - body = await request.json(); - } catch { - return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }); + const validation = await parseAndValidateJSON(request, validateTutorialResetRequest); + if (!validation.success) { + return validation.response; } - const { tutorialId } = body; - if (!tutorialId) { - return NextResponse.json( - { error: 'tutorialId is required' }, - { status: 400 } - ); - } + const { tutorialId } = validation.data; const state = await TutorialStateManager.getUserTutorialState(userId); delete state.tutorials[tutorialId]; // Save the updated state - const { setKVValue } = await import('@/lib/kv-store'); - const { config } = await import('@/lib/config'); - - await setKVValue(`tutorial_state_${userId}`, state, { + const kvResponse = await setKVValue(`tutorial_state_${userId}`, state, { storeName: config.defaultStoreName }); + if (!kvResponse.success) { + return NextResponse.json( + { error: kvResponse.error || 'Failed to reset tutorial state' }, + { status: kvResponse.statusCode || 500 } + ); + } + return NextResponse.json({ success: true, message: 'Tutorial progress reset' diff --git a/lib/validation/middleware.ts b/lib/validation/middleware.ts new file mode 100644 index 00000000..4efde0fd --- /dev/null +++ b/lib/validation/middleware.ts @@ -0,0 +1,250 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { Session, Message, TutorialData, ExecuteRequest } from '@/app/chat/types'; +import { + TutorialProgressRequest, + TutorialResetRequest, + SessionMessageRequest +} from './types'; + +export interface ValidationError { + field: string; + message: string; + received?: any; +} + +export interface ValidationResult { + success: boolean; + data?: T; + errors?: ValidationError[]; +} + +export function createValidationError(message: string, errors: ValidationError[]): NextResponse { + return NextResponse.json( + { + error: message, + details: errors.map(err => `${err.field}: ${err.message}`) + }, + { status: 400 } + ); +} + +export function validateString(value: any, fieldName: string, required = true): ValidationError | null { + if (required && (value === undefined || value === null)) { + return { field: fieldName, message: 'is required', received: value }; + } + if (value !== undefined && value !== null && typeof value !== 'string') { + return { field: fieldName, message: 'must be a string', received: typeof value }; + } + if (required && typeof value === 'string' && value.trim() === '') { + return { field: fieldName, message: 'cannot be empty', received: value }; + } + return null; +} + +export function validateNumber(value: any, fieldName: string, required = true, options?: { min?: number; max?: number; integer?: boolean }): ValidationError | null { + if (required && (value === undefined || value === null)) { + return { field: fieldName, message: 'is required', received: value }; + } + if (value !== undefined && value !== null && typeof value !== 'number') { + return { field: fieldName, message: 'must be a number', received: typeof value }; + } + if (typeof value === 'number') { + if (!Number.isFinite(value)) { + return { field: fieldName, message: 'must be a finite number', received: value }; + } + if (options?.integer && !Number.isInteger(value)) { + return { field: fieldName, message: 'must be an integer', received: value }; + } + if (options?.min !== undefined && value < options.min) { + return { field: fieldName, message: `must be at least ${options.min}`, received: value }; + } + if (options?.max !== undefined && value > options.max) { + return { field: fieldName, message: `must be at most ${options.max}`, received: value }; + } + } + return null; +} + +export function validateMessage(message: any): ValidationResult { + const errors: ValidationError[] = []; + + if (!message || typeof message !== 'object') { + return { success: false, errors: [{ field: 'message', message: 'must be an object', received: typeof message }] }; + } + + const idError = validateString(message.id, 'id'); + if (idError) errors.push(idError); + + if (message.author !== 'USER' && message.author !== 'ASSISTANT') { + errors.push({ field: 'author', message: 'must be either "USER" or "ASSISTANT"', received: message.author }); + } + + const contentError = validateString(message.content, 'content'); + if (contentError) errors.push(contentError); + + const timestampError = validateString(message.timestamp, 'timestamp'); + if (timestampError) errors.push(timestampError); + + if (errors.length > 0) { + return { success: false, errors }; + } + + return { success: true, data: message as Message }; +} + +export function validateSession(session: any): ValidationResult { + const errors: ValidationError[] = []; + + if (!session || typeof session !== 'object') { + return { success: false, errors: [{ field: 'session', message: 'must be an object', received: typeof session }] }; + } + + const sessionIdError = validateString(session.sessionId, 'sessionId'); + if (sessionIdError) errors.push(sessionIdError); + + if (!Array.isArray(session.messages)) { + errors.push({ field: 'messages', message: 'must be an array', received: typeof session.messages }); + } else { + session.messages.forEach((message: any, index: number) => { + const messageValidation = validateMessage(message); + if (!messageValidation.success && messageValidation.errors) { + messageValidation.errors.forEach(error => { + errors.push({ field: `messages[${index}].${error.field}`, message: error.message, received: error.received }); + }); + } + }); + } + + if (errors.length > 0) { + return { success: false, errors }; + } + + return { success: true, data: session as Session }; +} + +export function validateTutorialProgressRequest(body: any): ValidationResult<{ tutorialId: string; currentStep: number; totalSteps: number }> { + const errors: ValidationError[] = []; + + if (!body || typeof body !== 'object') { + return { success: false, errors: [{ field: 'body', message: 'must be an object', received: typeof body }] }; + } + + const tutorialIdError = validateString(body.tutorialId, 'tutorialId'); + if (tutorialIdError) errors.push(tutorialIdError); + + const currentStepError = validateNumber(body.currentStep, 'currentStep', true, { integer: true, min: 0 }); + if (currentStepError) errors.push(currentStepError); + + const totalStepsError = validateNumber(body.totalSteps, 'totalSteps', true, { integer: true, min: 1 }); + if (totalStepsError) errors.push(totalStepsError); + + if (errors.length > 0) { + return { success: false, errors }; + } + + return { success: true, data: { tutorialId: body.tutorialId, currentStep: body.currentStep, totalSteps: body.totalSteps } }; +} + +export function validateTutorialResetRequest(body: any): ValidationResult<{ tutorialId: string }> { + const errors: ValidationError[] = []; + + if (!body || typeof body !== 'object') { + return { success: false, errors: [{ field: 'body', message: 'must be an object', received: typeof body }] }; + } + + const tutorialIdError = validateString(body.tutorialId, 'tutorialId'); + if (tutorialIdError) errors.push(tutorialIdError); + + if (errors.length > 0) { + return { success: false, errors }; + } + + return { success: true, data: { tutorialId: body.tutorialId } }; +} + +export function validateExecuteRequest(body: any): ValidationResult { + const errors: ValidationError[] = []; + + if (!body || typeof body !== 'object') { + return { success: false, errors: [{ field: 'body', message: 'must be an object', received: typeof body }] }; + } + + const codeError = validateString(body.code, 'code'); + if (codeError) errors.push(codeError); + + const filenameError = validateString(body.filename, 'filename'); + if (filenameError) errors.push(filenameError); + + const sessionIdError = validateString(body.sessionId, 'sessionId'); + if (sessionIdError) errors.push(sessionIdError); + + if (errors.length > 0) { + return { success: false, errors }; + } + + return { success: true, data: body as ExecuteRequest }; +} + +export function validateStepNumber(stepNumber: string): ValidationResult { + const stepIndex = Number.parseInt(stepNumber, 10); + + if (Number.isNaN(stepIndex)) { + return { + success: false, + errors: [{ field: 'stepNumber', message: 'must be a valid integer', received: stepNumber }] + }; + } + + if (stepIndex < 1) { + return { + success: false, + errors: [{ field: 'stepNumber', message: 'must be greater than 0', received: stepIndex }] + }; + } + + return { success: true, data: stepIndex }; +} + +export function validateTutorialId(id: string): ValidationResult { + if (!id || typeof id !== 'string') { + return { + success: false, + errors: [{ field: 'tutorialId', message: 'must be a non-empty string', received: typeof id }] + }; + } + + if (id.includes('..') || id.includes('/') || id.includes('\\')) { + return { + success: false, + errors: [{ field: 'tutorialId', message: 'contains invalid characters (path traversal attempt)', received: id }] + }; + } + + return { success: true, data: id }; +} + +export async function parseAndValidateJSON( + request: NextRequest, + validator: (body: any) => ValidationResult +): Promise<{ success: true; data: T } | { success: false; response: NextResponse }> { + let body: any; + + try { + body = await request.json(); + } catch { + return { + success: false, + response: NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }) + }; + } + + const validation = validator(body); + if (!validation.success) { + return { + success: false, + response: createValidationError('Validation failed', validation.errors || []) + }; + } + + return { success: true, data: validation.data! }; +} diff --git a/lib/validation/types.ts b/lib/validation/types.ts new file mode 100644 index 00000000..34e07c80 --- /dev/null +++ b/lib/validation/types.ts @@ -0,0 +1,40 @@ +import { Session, Message, TutorialData } from '../../app/chat/types'; + +export interface TutorialProgressRequest { + tutorialId: string; + currentStep: number; + totalSteps: number; +} + +export interface TutorialResetRequest { + tutorialId: string; +} + +export interface SessionMessageRequest { + message: Message; + processWithAgent?: boolean; +} + +export interface SessionCreateRequest extends Session {} + +export interface SessionUpdateRequest extends Session {} + +export interface ApiResponse { + success: boolean; + data?: T; + error?: string; + message?: string; +} + +export interface PaginationInfo { + cursor: number; + nextCursor: number | null; + hasMore: boolean; + total: number; + limit: number; +} + +export interface SessionsResponse extends ApiResponse { + sessions: Session[]; + pagination: PaginationInfo; +} From b80047903a3b4355cf4224dbbee039add6c2eaf1 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 13 Sep 2025 19:00:53 +0000 Subject: [PATCH 2/9] Fix TypeScript compilation errors in validation middleware - Add SessionMessageValidationResult and SessionMessageOnlyValidationResult types - Fix validation function return type mismatches in session routes - Add proper bounds checking for stepIndex in tutorial route - Ensure all validation errors use consistent error structure - Generate missing docs.json file to resolve import errors All TypeScript compilation errors resolved, ready for CI Co-Authored-By: srith@agentuity.com --- app/api/sessions/[sessionId]/messages/route.ts | 9 +++++---- app/api/sessions/[sessionId]/route.ts | 9 +++++---- .../tutorials/[id]/steps/[stepNumber]/route.ts | 16 +++++++++++++++- lib/validation/types.ts | 9 +++++++++ 4 files changed, 34 insertions(+), 9 deletions(-) diff --git a/app/api/sessions/[sessionId]/messages/route.ts b/app/api/sessions/[sessionId]/messages/route.ts index 63754507..22fd78c3 100644 --- a/app/api/sessions/[sessionId]/messages/route.ts +++ b/app/api/sessions/[sessionId]/messages/route.ts @@ -9,7 +9,8 @@ import { import { toISOString, getCurrentTimestamp } from "@/app/chat/utils/dateUtils"; import { getAgentPulseConfig } from "@/lib/env"; import { config } from "@/lib/config"; -import { parseAndValidateJSON, validateMessage } from "@/lib/validation/middleware"; +import { parseAndValidateJSON, validateMessage, ValidationResult } from "@/lib/validation/middleware"; +import { SessionMessageValidationResult } from "@/lib/validation/types"; // Constants const DEFAULT_CONVERSATION_HISTORY_LIMIT = 10; @@ -65,14 +66,14 @@ export async function POST( const paramsData = await params; const sessionId = paramsData.sessionId; - const validation = await parseAndValidateJSON(request, (body: any) => { + const validation = await parseAndValidateJSON(request, (body: any): ValidationResult => { if (!body || typeof body !== 'object') { return { success: false, errors: [{ field: 'body', message: 'must be an object', received: typeof body }] }; } const messageValidation = validateMessage(body.message); if (!messageValidation.success) { - return messageValidation; + return { success: false, errors: messageValidation.errors || [] }; } const processWithAgent = body.processWithAgent !== undefined ? Boolean(body.processWithAgent) : true; @@ -80,7 +81,7 @@ export async function POST( return { success: true, data: { - message: messageValidation.data, + message: messageValidation.data!, processWithAgent } }; diff --git a/app/api/sessions/[sessionId]/route.ts b/app/api/sessions/[sessionId]/route.ts index 688e7627..bbe7a95a 100644 --- a/app/api/sessions/[sessionId]/route.ts +++ b/app/api/sessions/[sessionId]/route.ts @@ -3,7 +3,8 @@ import { getKVValue, setKVValue, deleteKVValue } from '@/lib/kv-store'; import { Session, Message } from '@/app/chat/types'; import { toISOString } from '@/app/chat/utils/dateUtils'; import { config } from '@/lib/config'; -import { parseAndValidateJSON, validateSession, validateMessage } from '@/lib/validation/middleware'; +import { parseAndValidateJSON, validateSession, validateMessage, ValidationResult } from '@/lib/validation/middleware'; +import { SessionMessageOnlyValidationResult } from '@/lib/validation/types'; /** * GET /api/sessions/[sessionId] - Get a specific session @@ -200,15 +201,15 @@ export async function POST( const sessionId = paramsData.sessionId; const sessionKey = `${userId}_${sessionId}`; - const validation = await parseAndValidateJSON(request, (body: any) => { + const validation = await parseAndValidateJSON(request, (body: any): ValidationResult => { if (!body || typeof body !== 'object') { return { success: false, errors: [{ field: 'body', message: 'must be an object', received: typeof body }] }; } const messageValidation = validateMessage(body.message); if (!messageValidation.success) { - return messageValidation; + return { success: false, errors: messageValidation.errors || [] }; } - return { success: true, data: { message: messageValidation.data } }; + return { success: true, data: { message: messageValidation.data! } }; }); if (!validation.success) { diff --git a/app/api/tutorials/[id]/steps/[stepNumber]/route.ts b/app/api/tutorials/[id]/steps/[stepNumber]/route.ts index b1c77783..b11b4a71 100644 --- a/app/api/tutorials/[id]/steps/[stepNumber]/route.ts +++ b/app/api/tutorials/[id]/steps/[stepNumber]/route.ts @@ -23,6 +23,12 @@ export async function GET(request: NextRequest, { params }: RouteParams) { } const stepIndex = stepValidation.data; + if (!stepIndex) { + return NextResponse.json( + { success: false, error: 'Invalid step number' }, + { status: 400 } + ); + } const repoRoot = process.cwd(); const tutorialDir = join(repoRoot, 'content', 'Tutorial', id); @@ -34,6 +40,14 @@ export async function GET(request: NextRequest, { params }: RouteParams) { // Filter out index; map to actual MDX files const stepSlugs = pages.filter(p => p !== 'index'); + + if (stepIndex < 1 || stepIndex > stepSlugs.length) { + return NextResponse.json( + { success: false, error: 'Step not found' }, + { status: 404 } + ); + } + const slug = stepSlugs[stepIndex - 1]; if (!slug) { return NextResponse.json( @@ -159,4 +173,4 @@ export async function GET(request: NextRequest, { params }: RouteParams) { { status: 500 } ); } -} \ No newline at end of file +} \ No newline at end of file diff --git a/lib/validation/types.ts b/lib/validation/types.ts index 34e07c80..6f9b3400 100644 --- a/lib/validation/types.ts +++ b/lib/validation/types.ts @@ -15,6 +15,15 @@ export interface SessionMessageRequest { processWithAgent?: boolean; } +export interface SessionMessageValidationResult { + message: Message; + processWithAgent: boolean; +} + +export interface SessionMessageOnlyValidationResult { + message: Message; +} + export interface SessionCreateRequest extends Session {} export interface SessionUpdateRequest extends Session {} From 67e71604772e6f1f9bc6fa232dd007d9566e1558 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 13 Sep 2025 22:05:07 +0000 Subject: [PATCH 3/9] Refactor validation middleware to be generic and scalable - Add FieldSchema and ValidationSchema interfaces for declarative validation - Implement validateField and validateObject for schema-based validation - Add overloaded parseAndValidateJSON to accept both validators and schemas - Maintain backward compatibility with existing validation functions - Fix TypeScript compilation errors with explicit Message type annotations - Enable reusable validation for current and future types Co-Authored-By: srith@agentuity.com --- app/api/sessions/[sessionId]/route.ts | 2 +- app/api/sessions/route.ts | 4 +- lib/validation/middleware.ts | 308 +++++++++++++++----------- 3 files changed, 187 insertions(+), 127 deletions(-) diff --git a/app/api/sessions/[sessionId]/route.ts b/app/api/sessions/[sessionId]/route.ts index bbe7a95a..d5e87782 100644 --- a/app/api/sessions/[sessionId]/route.ts +++ b/app/api/sessions/[sessionId]/route.ts @@ -73,7 +73,7 @@ export async function PUT( // Process any messages to ensure timestamps are in ISO string format if (session.messages && session.messages.length > 0) { - session.messages = session.messages.map(message => { + session.messages = session.messages.map((message: Message) => { if (message.timestamp) { return { ...message, diff --git a/app/api/sessions/route.ts b/app/api/sessions/route.ts index a0a115cf..3f5d550a 100644 --- a/app/api/sessions/route.ts +++ b/app/api/sessions/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from 'next/server'; import { getKVValue, setKVValue } from '@/lib/kv-store'; -import { Session } from '@/app/chat/types'; +import { Session, Message } from '@/app/chat/types'; import { toISOString } from '@/app/chat/utils/dateUtils'; import { config } from '@/lib/config'; import { parseAndValidateJSON, validateSession } from '@/lib/validation/middleware'; @@ -88,7 +88,7 @@ export async function POST(request: NextRequest) { // Process any messages to ensure timestamps are in ISO string format if (session.messages && session.messages.length > 0) { - session.messages = session.messages.map(message => { + session.messages = session.messages.map((message: Message) => { if (message.timestamp) { return { ...message, diff --git a/lib/validation/middleware.ts b/lib/validation/middleware.ts index 4efde0fd..a21b98d4 100644 --- a/lib/validation/middleware.ts +++ b/lib/validation/middleware.ts @@ -1,10 +1,4 @@ import { NextRequest, NextResponse } from 'next/server'; -import { Session, Message, TutorialData, ExecuteRequest } from '@/app/chat/types'; -import { - TutorialProgressRequest, - TutorialResetRequest, - SessionMessageRequest -} from './types'; export interface ValidationError { field: string; @@ -18,6 +12,25 @@ export interface ValidationResult { errors?: ValidationError[]; } +export interface FieldSchema { + type: 'string' | 'number' | 'boolean' | 'array' | 'object' | 'enum'; + required?: boolean; + minLength?: number; + maxLength?: number; + min?: number; + max?: number; + integer?: boolean; + pattern?: RegExp; + enumValues?: string[]; + arrayItemSchema?: FieldSchema; + objectSchema?: Record; + customValidator?: (value: any, fieldName: string) => ValidationError | null; +} + +export interface ValidationSchema { + [fieldName: string]: FieldSchema; +} + export function createValidationError(message: string, errors: ValidationError[]): NextResponse { return NextResponse.json( { @@ -28,161 +41,191 @@ export function createValidationError(message: string, errors: ValidationError[] ); } -export function validateString(value: any, fieldName: string, required = true): ValidationError | null { - if (required && (value === undefined || value === null)) { +export function validateField(value: any, fieldName: string, schema: FieldSchema): ValidationError | null { + if (schema.required && (value === undefined || value === null)) { return { field: fieldName, message: 'is required', received: value }; } - if (value !== undefined && value !== null && typeof value !== 'string') { - return { field: fieldName, message: 'must be a string', received: typeof value }; - } - if (required && typeof value === 'string' && value.trim() === '') { - return { field: fieldName, message: 'cannot be empty', received: value }; - } - return null; -} -export function validateNumber(value: any, fieldName: string, required = true, options?: { min?: number; max?: number; integer?: boolean }): ValidationError | null { - if (required && (value === undefined || value === null)) { - return { field: fieldName, message: 'is required', received: value }; + if (!schema.required && (value === undefined || value === null)) { + return null; } - if (value !== undefined && value !== null && typeof value !== 'number') { - return { field: fieldName, message: 'must be a number', received: typeof value }; - } - if (typeof value === 'number') { - if (!Number.isFinite(value)) { - return { field: fieldName, message: 'must be a finite number', received: value }; - } - if (options?.integer && !Number.isInteger(value)) { - return { field: fieldName, message: 'must be an integer', received: value }; - } - if (options?.min !== undefined && value < options.min) { - return { field: fieldName, message: `must be at least ${options.min}`, received: value }; - } - if (options?.max !== undefined && value > options.max) { - return { field: fieldName, message: `must be at most ${options.max}`, received: value }; - } - } - return null; -} -export function validateMessage(message: any): ValidationResult { - const errors: ValidationError[] = []; - - if (!message || typeof message !== 'object') { - return { success: false, errors: [{ field: 'message', message: 'must be an object', received: typeof message }] }; + if (schema.customValidator) { + return schema.customValidator(value, fieldName); } - const idError = validateString(message.id, 'id'); - if (idError) errors.push(idError); + switch (schema.type) { + case 'string': + if (typeof value !== 'string') { + return { field: fieldName, message: 'must be a string', received: typeof value }; + } + if (schema.required && value.trim() === '') { + return { field: fieldName, message: 'cannot be empty', received: value }; + } + if (schema.minLength !== undefined && value.length < schema.minLength) { + return { field: fieldName, message: `must be at least ${schema.minLength} characters`, received: value.length }; + } + if (schema.maxLength !== undefined && value.length > schema.maxLength) { + return { field: fieldName, message: `must be at most ${schema.maxLength} characters`, received: value.length }; + } + if (schema.pattern && !schema.pattern.test(value)) { + return { field: fieldName, message: 'format is invalid', received: value }; + } + break; - if (message.author !== 'USER' && message.author !== 'ASSISTANT') { - errors.push({ field: 'author', message: 'must be either "USER" or "ASSISTANT"', received: message.author }); - } + case 'number': + if (typeof value !== 'number') { + return { field: fieldName, message: 'must be a number', received: typeof value }; + } + if (!Number.isFinite(value)) { + return { field: fieldName, message: 'must be a finite number', received: value }; + } + if (schema.integer && !Number.isInteger(value)) { + return { field: fieldName, message: 'must be an integer', received: value }; + } + if (schema.min !== undefined && value < schema.min) { + return { field: fieldName, message: `must be at least ${schema.min}`, received: value }; + } + if (schema.max !== undefined && value > schema.max) { + return { field: fieldName, message: `must be at most ${schema.max}`, received: value }; + } + break; - const contentError = validateString(message.content, 'content'); - if (contentError) errors.push(contentError); + case 'boolean': + if (typeof value !== 'boolean') { + return { field: fieldName, message: 'must be a boolean', received: typeof value }; + } + break; + + case 'enum': + if (!schema.enumValues || !schema.enumValues.includes(value)) { + return { + field: fieldName, + message: `must be one of: ${schema.enumValues?.join(', ')}`, + received: value + }; + } + break; - const timestampError = validateString(message.timestamp, 'timestamp'); - if (timestampError) errors.push(timestampError); + case 'array': + if (!Array.isArray(value)) { + return { field: fieldName, message: 'must be an array', received: typeof value }; + } + if (schema.arrayItemSchema) { + for (let i = 0; i < value.length; i++) { + const itemError = validateField(value[i], `${fieldName}[${i}]`, schema.arrayItemSchema); + if (itemError) return itemError; + } + } + break; - if (errors.length > 0) { - return { success: false, errors }; + case 'object': + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return { field: fieldName, message: 'must be an object', received: typeof value }; + } + if (schema.objectSchema) { + for (const [propName, propSchema] of Object.entries(schema.objectSchema)) { + const propError = validateField(value[propName], `${fieldName}.${propName}`, propSchema); + if (propError) return propError; + } + } + break; + + default: + return { field: fieldName, message: `unknown validation type: ${schema.type}`, received: value }; } - return { success: true, data: message as Message }; + return null; } -export function validateSession(session: any): ValidationResult { +export function validateObject(obj: any, schema: ValidationSchema): ValidationResult { const errors: ValidationError[] = []; - - if (!session || typeof session !== 'object') { - return { success: false, errors: [{ field: 'session', message: 'must be an object', received: typeof session }] }; - } - const sessionIdError = validateString(session.sessionId, 'sessionId'); - if (sessionIdError) errors.push(sessionIdError); + if (!obj || typeof obj !== 'object' || Array.isArray(obj)) { + return { success: false, errors: [{ field: 'root', message: 'must be an object', received: typeof obj }] }; + } - if (!Array.isArray(session.messages)) { - errors.push({ field: 'messages', message: 'must be an array', received: typeof session.messages }); - } else { - session.messages.forEach((message: any, index: number) => { - const messageValidation = validateMessage(message); - if (!messageValidation.success && messageValidation.errors) { - messageValidation.errors.forEach(error => { - errors.push({ field: `messages[${index}].${error.field}`, message: error.message, received: error.received }); - }); - } - }); + for (const [fieldName, fieldSchema] of Object.entries(schema)) { + const error = validateField(obj[fieldName], fieldName, fieldSchema); + if (error) { + errors.push(error); + } } if (errors.length > 0) { return { success: false, errors }; } - return { success: true, data: session as Session }; + return { success: true, data: obj as T }; } -export function validateTutorialProgressRequest(body: any): ValidationResult<{ tutorialId: string; currentStep: number; totalSteps: number }> { - const errors: ValidationError[] = []; - - if (!body || typeof body !== 'object') { - return { success: false, errors: [{ field: 'body', message: 'must be an object', received: typeof body }] }; - } - - const tutorialIdError = validateString(body.tutorialId, 'tutorialId'); - if (tutorialIdError) errors.push(tutorialIdError); - - const currentStepError = validateNumber(body.currentStep, 'currentStep', true, { integer: true, min: 0 }); - if (currentStepError) errors.push(currentStepError); - - const totalStepsError = validateNumber(body.totalSteps, 'totalSteps', true, { integer: true, min: 1 }); - if (totalStepsError) errors.push(totalStepsError); - - if (errors.length > 0) { - return { success: false, errors }; - } - - return { success: true, data: { tutorialId: body.tutorialId, currentStep: body.currentStep, totalSteps: body.totalSteps } }; +export function validateString(value: any, fieldName: string, required = true): ValidationError | null { + return validateField(value, fieldName, { type: 'string', required }); } -export function validateTutorialResetRequest(body: any): ValidationResult<{ tutorialId: string }> { - const errors: ValidationError[] = []; - - if (!body || typeof body !== 'object') { - return { success: false, errors: [{ field: 'body', message: 'must be an object', received: typeof body }] }; - } +export function validateNumber(value: any, fieldName: string, required = true, options?: { min?: number; max?: number; integer?: boolean }): ValidationError | null { + return validateField(value, fieldName, { + type: 'number', + required, + min: options?.min, + max: options?.max, + integer: options?.integer + }); +} - const tutorialIdError = validateString(body.tutorialId, 'tutorialId'); - if (tutorialIdError) errors.push(tutorialIdError); +export const MessageSchema: ValidationSchema = { + id: { type: 'string', required: true }, + author: { type: 'enum', required: true, enumValues: ['USER', 'ASSISTANT'] }, + content: { type: 'string', required: true }, + timestamp: { type: 'string', required: true }, + tutorialData: { type: 'object', required: false } +}; - if (errors.length > 0) { - return { success: false, errors }; - } +export function validateMessage(message: any): ValidationResult { + return validateObject(message, MessageSchema); +} - return { success: true, data: { tutorialId: body.tutorialId } }; +export const SessionSchema: ValidationSchema = { + sessionId: { type: 'string', required: true }, + messages: { + type: 'array', + required: true, + arrayItemSchema: { type: 'object', required: true, objectSchema: MessageSchema } + }, + isTutorial: { type: 'boolean', required: false }, + title: { type: 'string', required: false } +}; + +export function validateSession(session: any): ValidationResult { + return validateObject(session, SessionSchema); } -export function validateExecuteRequest(body: any): ValidationResult { - const errors: ValidationError[] = []; - - if (!body || typeof body !== 'object') { - return { success: false, errors: [{ field: 'body', message: 'must be an object', received: typeof body }] }; - } +export const TutorialProgressRequestSchema: ValidationSchema = { + tutorialId: { type: 'string', required: true }, + currentStep: { type: 'number', required: true, integer: true, min: 0 }, + totalSteps: { type: 'number', required: true, integer: true, min: 1 } +}; - const codeError = validateString(body.code, 'code'); - if (codeError) errors.push(codeError); +export function validateTutorialProgressRequest(body: any): ValidationResult { + return validateObject(body, TutorialProgressRequestSchema); +} - const filenameError = validateString(body.filename, 'filename'); - if (filenameError) errors.push(filenameError); +export const TutorialResetRequestSchema: ValidationSchema = { + tutorialId: { type: 'string', required: true } +}; - const sessionIdError = validateString(body.sessionId, 'sessionId'); - if (sessionIdError) errors.push(sessionIdError); +export function validateTutorialResetRequest(body: any): ValidationResult { + return validateObject(body, TutorialResetRequestSchema); +} - if (errors.length > 0) { - return { success: false, errors }; - } +export const ExecuteRequestSchema: ValidationSchema = { + code: { type: 'string', required: true }, + filename: { type: 'string', required: true }, + sessionId: { type: 'string', required: true } +}; - return { success: true, data: body as ExecuteRequest }; +export function validateExecuteRequest(body: any): ValidationResult { + return validateObject(body, ExecuteRequestSchema); } export function validateStepNumber(stepNumber: string): ValidationResult { @@ -226,6 +269,16 @@ export function validateTutorialId(id: string): ValidationResult { export async function parseAndValidateJSON( request: NextRequest, validator: (body: any) => ValidationResult +): Promise<{ success: true; data: T } | { success: false; response: NextResponse }>; + +export async function parseAndValidateJSON( + request: NextRequest, + schema: ValidationSchema +): Promise<{ success: true; data: T } | { success: false; response: NextResponse }>; + +export async function parseAndValidateJSON( + request: NextRequest, + validatorOrSchema: ((body: any) => ValidationResult) | ValidationSchema ): Promise<{ success: true; data: T } | { success: false; response: NextResponse }> { let body: any; @@ -238,7 +291,14 @@ export async function parseAndValidateJSON( }; } - const validation = validator(body); + let validation: ValidationResult; + + if (typeof validatorOrSchema === 'function') { + validation = validatorOrSchema(body); + } else { + validation = validateObject(body, validatorOrSchema); + } + if (!validation.success) { return { success: false, From 64a0479fe28d6852e9e6faeb49b357e770483e35 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 13 Sep 2025 22:33:56 +0000 Subject: [PATCH 4/9] Refactor validation to use Zod schemas and eliminate duplicate source of truth - Replace TypeScript interfaces with Zod schemas in app/chat/types.ts - Derive types using z.infer instead of separate interfaces - Update validation middleware to use Zod's safeParse and error handling - Maintain all existing validation behavior while using industry-standard Zod - Fix TypeScript compilation errors and import issues - All API endpoints now use consistent Zod-based validation This eliminates the duplicate source of truth between validation schemas and TypeScript interfaces, making the codebase more maintainable and following modern best practices. Co-Authored-By: srith@agentuity.com --- app/api/sessions/[sessionId]/route.ts | 18 +- app/api/sessions/route.ts | 6 +- app/api/users/tutorial-state/route.ts | 8 +- app/chat/types.ts | 131 ++++++------ lib/validation/middleware.ts | 283 +++++--------------------- lib/validation/types.ts | 59 +++--- package-lock.json | 18 +- package.json | 3 +- 8 files changed, 172 insertions(+), 354 deletions(-) diff --git a/app/api/sessions/[sessionId]/route.ts b/app/api/sessions/[sessionId]/route.ts index d5e87782..d314a9ba 100644 --- a/app/api/sessions/[sessionId]/route.ts +++ b/app/api/sessions/[sessionId]/route.ts @@ -1,10 +1,9 @@ import { NextRequest, NextResponse } from 'next/server'; import { getKVValue, setKVValue, deleteKVValue } from '@/lib/kv-store'; -import { Session, Message } from '@/app/chat/types'; +import { Session, Message, SessionSchema, MessageSchema } from '@/app/chat/types'; import { toISOString } from '@/app/chat/utils/dateUtils'; import { config } from '@/lib/config'; -import { parseAndValidateJSON, validateSession, validateMessage, ValidationResult } from '@/lib/validation/middleware'; -import { SessionMessageOnlyValidationResult } from '@/lib/validation/types'; +import { parseAndValidateJSON, SessionMessageOnlyRequestSchema } from '@/lib/validation/middleware'; /** * GET /api/sessions/[sessionId] - Get a specific session @@ -57,7 +56,7 @@ export async function PUT( const sessionId = paramsData.sessionId; const sessionKey = `${userId}_${sessionId}`; - const validation = await parseAndValidateJSON(request, validateSession); + const validation = await parseAndValidateJSON(request, SessionSchema); if (!validation.success) { return validation.response; } @@ -201,16 +200,7 @@ export async function POST( const sessionId = paramsData.sessionId; const sessionKey = `${userId}_${sessionId}`; - const validation = await parseAndValidateJSON(request, (body: any): ValidationResult => { - if (!body || typeof body !== 'object') { - return { success: false, errors: [{ field: 'body', message: 'must be an object', received: typeof body }] }; - } - const messageValidation = validateMessage(body.message); - if (!messageValidation.success) { - return { success: false, errors: messageValidation.errors || [] }; - } - return { success: true, data: { message: messageValidation.data! } }; - }); + const validation = await parseAndValidateJSON(request, SessionMessageOnlyRequestSchema); if (!validation.success) { return validation.response; diff --git a/app/api/sessions/route.ts b/app/api/sessions/route.ts index 3f5d550a..bd6a97f2 100644 --- a/app/api/sessions/route.ts +++ b/app/api/sessions/route.ts @@ -1,9 +1,9 @@ import { NextRequest, NextResponse } from 'next/server'; import { getKVValue, setKVValue } from '@/lib/kv-store'; -import { Session, Message } from '@/app/chat/types'; +import { Session, Message, SessionSchema } from '@/app/chat/types'; import { toISOString } from '@/app/chat/utils/dateUtils'; import { config } from '@/lib/config'; -import { parseAndValidateJSON, validateSession } from '@/lib/validation/middleware'; +import { parseAndValidateJSON } from '@/lib/validation/middleware'; // Constants const DEFAULT_SESSIONS_LIMIT = 10; @@ -79,7 +79,7 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'User ID not found' }, { status: 401 }); } - const validation = await parseAndValidateJSON(request, validateSession); + const validation = await parseAndValidateJSON(request, SessionSchema); if (!validation.success) { return validation.response; } diff --git a/app/api/users/tutorial-state/route.ts b/app/api/users/tutorial-state/route.ts index 775f1365..6429a3b6 100644 --- a/app/api/users/tutorial-state/route.ts +++ b/app/api/users/tutorial-state/route.ts @@ -4,8 +4,8 @@ import { setKVValue } from '@/lib/kv-store'; import { config } from '@/lib/config'; import { parseAndValidateJSON, - validateTutorialProgressRequest, - validateTutorialResetRequest + TutorialProgressRequestSchema, + TutorialResetRequestSchema } from '@/lib/validation/middleware'; /** @@ -43,7 +43,7 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'User ID not found' }, { status: 401 }); } - const validation = await parseAndValidateJSON(request, validateTutorialProgressRequest); + const validation = await parseAndValidateJSON(request, TutorialProgressRequestSchema); if (!validation.success) { return validation.response; } @@ -80,7 +80,7 @@ export async function DELETE(request: NextRequest) { return NextResponse.json({ error: 'User ID not found' }, { status: 401 }); } - const validation = await parseAndValidateJSON(request, validateTutorialResetRequest); + const validation = await parseAndValidateJSON(request, TutorialResetRequestSchema); if (!validation.success) { return validation.response; } diff --git a/app/chat/types.ts b/app/chat/types.ts index 0916342f..cda91db4 100644 --- a/app/chat/types.ts +++ b/app/chat/types.ts @@ -1,48 +1,57 @@ -// Core message type -export interface Message { - id: string; - author: 'USER' | 'ASSISTANT'; - content: string; - timestamp: string; - tutorialData?: TutorialData; -} +import { z } from 'zod'; -export interface TutorialData { - tutorialId: string; - totalSteps: number; - currentStep: number; - tutorialStep: { - title: string; - mdx: string; - snippets: TutorialSnippet[]; - codeContent?: string; - totalSteps: number; - }; -} +export const TutorialSnippetSchema = z.object({ + path: z.string(), + lang: z.string().optional(), + from: z.number().int().optional(), + to: z.number().int().optional(), + title: z.string().optional(), + content: z.string() +}); -export interface TutorialSnippet { - path: string; - lang?: string; - from?: number; - to?: number; - title?: string; - content: string; -} +export type TutorialSnippet = z.infer; -// Code file type -export interface CodeFile { - filename: string; - content: string; - language: string; -} +export const TutorialDataSchema = z.object({ + tutorialId: z.string(), + totalSteps: z.number().int().min(1), + currentStep: z.number().int().min(0), + tutorialStep: z.object({ + title: z.string(), + mdx: z.string(), + snippets: z.array(TutorialSnippetSchema), + codeContent: z.string().optional(), + totalSteps: z.number().int().min(1) + }) +}); -// Session type -export interface Session { - messages: Message[]; - sessionId: string; - isTutorial?: boolean; - title?: string; -} +export type TutorialData = z.infer; + +export const MessageSchema = z.object({ + id: z.string().min(1), + author: z.enum(['USER', 'ASSISTANT']), + content: z.string(), + timestamp: z.string(), + tutorialData: TutorialDataSchema.optional() +}); + +export type Message = z.infer; + +export const CodeFileSchema = z.object({ + filename: z.string(), + content: z.string(), + language: z.string() +}); + +export type CodeFile = z.infer; + +export const SessionSchema = z.object({ + messages: z.array(MessageSchema), + sessionId: z.string().min(1), + isTutorial: z.boolean().optional(), + title: z.string().optional() +}); + +export type Session = z.infer; export interface SessionSidebarProps { currentSessionId: string; @@ -54,31 +63,25 @@ export interface SessionSidebarProps { isLoadingMore?: boolean; } -// // API request/response types -export interface ExecuteRequest { - code: string; - filename: string; - sessionId: string; -} +export const ExecuteRequestSchema = z.object({ + code: z.string().min(1), + filename: z.string().min(1), + sessionId: z.string().min(1) +}); -// export interface ExecuteResponse { -// success: boolean; -// output?: string; -// error?: string; -// executionTime?: number; -// exitCode?: number; -// } +export type ExecuteRequest = z.infer; -// // Streaming support types -export interface StreamingChunk { - type: 'text-delta' | 'status' | 'tutorial-data' | 'error' | 'finish'; - textDelta?: string; - message?: string; - category?: string; - tutorialData?: TutorialData; - error?: string; - details?: string; -} +export const StreamingChunkSchema = z.object({ + type: z.enum(['text-delta', 'status', 'tutorial-data', 'error', 'finish']), + textDelta: z.string().optional(), + message: z.string().optional(), + category: z.string().optional(), + tutorialData: TutorialDataSchema.optional(), + error: z.string().optional(), + details: z.string().optional() +}); + +export type StreamingChunk = z.infer; // // Agent request type // export interface AgentStreamingRequest { diff --git a/lib/validation/middleware.ts b/lib/validation/middleware.ts index a21b98d4..5d210257 100644 --- a/lib/validation/middleware.ts +++ b/lib/validation/middleware.ts @@ -1,4 +1,6 @@ import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; +import { MessageSchema, SessionSchema } from '@/app/chat/types'; export interface ValidationError { field: string; @@ -12,25 +14,6 @@ export interface ValidationResult { errors?: ValidationError[]; } -export interface FieldSchema { - type: 'string' | 'number' | 'boolean' | 'array' | 'object' | 'enum'; - required?: boolean; - minLength?: number; - maxLength?: number; - min?: number; - max?: number; - integer?: boolean; - pattern?: RegExp; - enumValues?: string[]; - arrayItemSchema?: FieldSchema; - objectSchema?: Record; - customValidator?: (value: any, fieldName: string) => ValidationError | null; -} - -export interface ValidationSchema { - [fieldName: string]: FieldSchema; -} - export function createValidationError(message: string, errors: ValidationError[]): NextResponse { return NextResponse.json( { @@ -41,192 +24,60 @@ export function createValidationError(message: string, errors: ValidationError[] ); } -export function validateField(value: any, fieldName: string, schema: FieldSchema): ValidationError | null { - if (schema.required && (value === undefined || value === null)) { - return { field: fieldName, message: 'is required', received: value }; - } - - if (!schema.required && (value === undefined || value === null)) { - return null; - } - - if (schema.customValidator) { - return schema.customValidator(value, fieldName); - } - - switch (schema.type) { - case 'string': - if (typeof value !== 'string') { - return { field: fieldName, message: 'must be a string', received: typeof value }; - } - if (schema.required && value.trim() === '') { - return { field: fieldName, message: 'cannot be empty', received: value }; - } - if (schema.minLength !== undefined && value.length < schema.minLength) { - return { field: fieldName, message: `must be at least ${schema.minLength} characters`, received: value.length }; - } - if (schema.maxLength !== undefined && value.length > schema.maxLength) { - return { field: fieldName, message: `must be at most ${schema.maxLength} characters`, received: value.length }; - } - if (schema.pattern && !schema.pattern.test(value)) { - return { field: fieldName, message: 'format is invalid', received: value }; - } - break; - - case 'number': - if (typeof value !== 'number') { - return { field: fieldName, message: 'must be a number', received: typeof value }; - } - if (!Number.isFinite(value)) { - return { field: fieldName, message: 'must be a finite number', received: value }; - } - if (schema.integer && !Number.isInteger(value)) { - return { field: fieldName, message: 'must be an integer', received: value }; - } - if (schema.min !== undefined && value < schema.min) { - return { field: fieldName, message: `must be at least ${schema.min}`, received: value }; - } - if (schema.max !== undefined && value > schema.max) { - return { field: fieldName, message: `must be at most ${schema.max}`, received: value }; - } - break; - - case 'boolean': - if (typeof value !== 'boolean') { - return { field: fieldName, message: 'must be a boolean', received: typeof value }; - } - break; - - case 'enum': - if (!schema.enumValues || !schema.enumValues.includes(value)) { - return { - field: fieldName, - message: `must be one of: ${schema.enumValues?.join(', ')}`, - received: value - }; - } - break; - - case 'array': - if (!Array.isArray(value)) { - return { field: fieldName, message: 'must be an array', received: typeof value }; - } - if (schema.arrayItemSchema) { - for (let i = 0; i < value.length; i++) { - const itemError = validateField(value[i], `${fieldName}[${i}]`, schema.arrayItemSchema); - if (itemError) return itemError; - } - } - break; - - case 'object': - if (!value || typeof value !== 'object' || Array.isArray(value)) { - return { field: fieldName, message: 'must be an object', received: typeof value }; - } - if (schema.objectSchema) { - for (const [propName, propSchema] of Object.entries(schema.objectSchema)) { - const propError = validateField(value[propName], `${fieldName}.${propName}`, propSchema); - if (propError) return propError; - } - } - break; - - default: - return { field: fieldName, message: `unknown validation type: ${schema.type}`, received: value }; - } - - return null; +function zodErrorToValidationErrors(error: z.ZodError): ValidationError[] { + return error.issues.map((err: any) => ({ + field: err.path.join('.'), + message: err.message, + received: err.received + })); } -export function validateObject(obj: any, schema: ValidationSchema): ValidationResult { - const errors: ValidationError[] = []; - - if (!obj || typeof obj !== 'object' || Array.isArray(obj)) { - return { success: false, errors: [{ field: 'root', message: 'must be an object', received: typeof obj }] }; +export async function parseAndValidateJSON( + request: NextRequest, + schema: z.ZodSchema +): Promise<{ success: true; data: T } | { success: false; response: NextResponse }> { + let body: any; + + try { + body = await request.json(); + } catch { + return { + success: false, + response: NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }) + }; } - for (const [fieldName, fieldSchema] of Object.entries(schema)) { - const error = validateField(obj[fieldName], fieldName, fieldSchema); - if (error) { - errors.push(error); - } - } + const result = schema.safeParse(body); - if (errors.length > 0) { - return { success: false, errors }; + if (!result.success) { + const errors = zodErrorToValidationErrors(result.error); + return { + success: false, + response: createValidationError('Validation failed', errors) + }; } - return { success: true, data: obj as T }; + return { success: true, data: result.data }; } -export function validateString(value: any, fieldName: string, required = true): ValidationError | null { - return validateField(value, fieldName, { type: 'string', required }); -} +export const TutorialProgressRequestSchema = z.object({ + tutorialId: z.string().min(1), + currentStep: z.number().int().min(0), + totalSteps: z.number().int().min(1) +}); -export function validateNumber(value: any, fieldName: string, required = true, options?: { min?: number; max?: number; integer?: boolean }): ValidationError | null { - return validateField(value, fieldName, { - type: 'number', - required, - min: options?.min, - max: options?.max, - integer: options?.integer - }); -} +export const TutorialResetRequestSchema = z.object({ + tutorialId: z.string().min(1) +}); -export const MessageSchema: ValidationSchema = { - id: { type: 'string', required: true }, - author: { type: 'enum', required: true, enumValues: ['USER', 'ASSISTANT'] }, - content: { type: 'string', required: true }, - timestamp: { type: 'string', required: true }, - tutorialData: { type: 'object', required: false } -}; +export const SessionMessageRequestSchema = z.object({ + message: MessageSchema, + processWithAgent: z.boolean().optional().default(true) +}); -export function validateMessage(message: any): ValidationResult { - return validateObject(message, MessageSchema); -} - -export const SessionSchema: ValidationSchema = { - sessionId: { type: 'string', required: true }, - messages: { - type: 'array', - required: true, - arrayItemSchema: { type: 'object', required: true, objectSchema: MessageSchema } - }, - isTutorial: { type: 'boolean', required: false }, - title: { type: 'string', required: false } -}; - -export function validateSession(session: any): ValidationResult { - return validateObject(session, SessionSchema); -} - -export const TutorialProgressRequestSchema: ValidationSchema = { - tutorialId: { type: 'string', required: true }, - currentStep: { type: 'number', required: true, integer: true, min: 0 }, - totalSteps: { type: 'number', required: true, integer: true, min: 1 } -}; - -export function validateTutorialProgressRequest(body: any): ValidationResult { - return validateObject(body, TutorialProgressRequestSchema); -} - -export const TutorialResetRequestSchema: ValidationSchema = { - tutorialId: { type: 'string', required: true } -}; - -export function validateTutorialResetRequest(body: any): ValidationResult { - return validateObject(body, TutorialResetRequestSchema); -} - -export const ExecuteRequestSchema: ValidationSchema = { - code: { type: 'string', required: true }, - filename: { type: 'string', required: true }, - sessionId: { type: 'string', required: true } -}; - -export function validateExecuteRequest(body: any): ValidationResult { - return validateObject(body, ExecuteRequestSchema); -} +export const SessionMessageOnlyRequestSchema = z.object({ + message: MessageSchema +}); export function validateStepNumber(stepNumber: string): ValidationResult { const stepIndex = Number.parseInt(stepNumber, 10); @@ -266,45 +117,7 @@ export function validateTutorialId(id: string): ValidationResult { return { success: true, data: id }; } -export async function parseAndValidateJSON( - request: NextRequest, - validator: (body: any) => ValidationResult -): Promise<{ success: true; data: T } | { success: false; response: NextResponse }>; - -export async function parseAndValidateJSON( - request: NextRequest, - schema: ValidationSchema -): Promise<{ success: true; data: T } | { success: false; response: NextResponse }>; - -export async function parseAndValidateJSON( - request: NextRequest, - validatorOrSchema: ((body: any) => ValidationResult) | ValidationSchema -): Promise<{ success: true; data: T } | { success: false; response: NextResponse }> { - let body: any; - - try { - body = await request.json(); - } catch { - return { - success: false, - response: NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }) - }; - } - - let validation: ValidationResult; - - if (typeof validatorOrSchema === 'function') { - validation = validatorOrSchema(body); - } else { - validation = validateObject(body, validatorOrSchema); - } - - if (!validation.success) { - return { - success: false, - response: createValidationError('Validation failed', validation.errors || []) - }; - } - - return { success: true, data: validation.data! }; -} +export type TutorialProgressRequest = z.infer; +export type TutorialResetRequest = z.infer; +export type SessionMessageRequest = z.infer; +export type SessionMessageOnlyRequest = z.infer; diff --git a/lib/validation/types.ts b/lib/validation/types.ts index 6f9b3400..23cdbd98 100644 --- a/lib/validation/types.ts +++ b/lib/validation/types.ts @@ -1,32 +1,24 @@ -import { Session, Message, TutorialData } from '../../app/chat/types'; - -export interface TutorialProgressRequest { - tutorialId: string; - currentStep: number; - totalSteps: number; -} - -export interface TutorialResetRequest { - tutorialId: string; -} - -export interface SessionMessageRequest { - message: Message; - processWithAgent?: boolean; -} - -export interface SessionMessageValidationResult { - message: Message; - processWithAgent: boolean; -} - -export interface SessionMessageOnlyValidationResult { - message: Message; -} - -export interface SessionCreateRequest extends Session {} - -export interface SessionUpdateRequest extends Session {} +import type { Session, Message, TutorialData, ExecuteRequest } from '../../app/chat/types'; +import type { + TutorialProgressRequest, + TutorialResetRequest, + SessionMessageRequest, + SessionMessageOnlyRequest +} from './middleware'; + +export type { + Session, + Message, + TutorialData, + ExecuteRequest +}; + +export type { + TutorialProgressRequest, + TutorialResetRequest, + SessionMessageRequest, + SessionMessageOnlyRequest +}; export interface ApiResponse { success: boolean; @@ -47,3 +39,12 @@ export interface SessionsResponse extends ApiResponse { sessions: Session[]; pagination: PaginationInfo; } + +export interface SessionMessageValidationResult { + message: Message; + processWithAgent: boolean; +} + +export interface SessionMessageOnlyValidationResult { + message: Message; +} diff --git a/package-lock.json b/package-lock.json index 33873657..7278bc2b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38,7 +38,8 @@ "swr": "^2.3.6", "twoslash": "^0.3.1", "uuid": "^11.1.0", - "ws": "^8.18.3" + "ws": "^8.18.3", + "zod": "^4.1.8" }, "devDependencies": { "@biomejs/biome": "2.1.2", @@ -14918,6 +14919,15 @@ "node": ">= 6" } }, + "node_modules/fumadocs-mdx/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/fumadocs-twoslash": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/fumadocs-twoslash/-/fumadocs-twoslash-3.1.0.tgz", @@ -20095,9 +20105,9 @@ } }, "node_modules/zod": { - "version": "3.24.2", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.2.tgz", - "integrity": "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==", + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.8.tgz", + "integrity": "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/package.json b/package.json index c290cc51..f6ae15f5 100644 --- a/package.json +++ b/package.json @@ -50,7 +50,8 @@ "swr": "^2.3.6", "twoslash": "^0.3.1", "uuid": "^11.1.0", - "ws": "^8.18.3" + "ws": "^8.18.3", + "zod": "^4.1.8" }, "devDependencies": { "@biomejs/biome": "2.1.2", From bf5fae59829dacd5a9f378ac9b5626fd24c7a6d8 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 13 Sep 2025 22:34:18 +0000 Subject: [PATCH 5/9] Complete Zod migration for messages API endpoint - Replace custom validation logic with SessionMessageRequestSchema - Simplify validation code by using Zod's built-in validation - Maintain all existing functionality while using industry-standard validation Co-Authored-By: srith@agentuity.com --- .../sessions/[sessionId]/messages/route.ts | 24 ++----------------- 1 file changed, 2 insertions(+), 22 deletions(-) diff --git a/app/api/sessions/[sessionId]/messages/route.ts b/app/api/sessions/[sessionId]/messages/route.ts index 22fd78c3..017d17dc 100644 --- a/app/api/sessions/[sessionId]/messages/route.ts +++ b/app/api/sessions/[sessionId]/messages/route.ts @@ -9,8 +9,7 @@ import { import { toISOString, getCurrentTimestamp } from "@/app/chat/utils/dateUtils"; import { getAgentPulseConfig } from "@/lib/env"; import { config } from "@/lib/config"; -import { parseAndValidateJSON, validateMessage, ValidationResult } from "@/lib/validation/middleware"; -import { SessionMessageValidationResult } from "@/lib/validation/types"; +import { parseAndValidateJSON, SessionMessageRequestSchema } from "@/lib/validation/middleware"; // Constants const DEFAULT_CONVERSATION_HISTORY_LIMIT = 10; @@ -66,26 +65,7 @@ export async function POST( const paramsData = await params; const sessionId = paramsData.sessionId; - const validation = await parseAndValidateJSON(request, (body: any): ValidationResult => { - if (!body || typeof body !== 'object') { - return { success: false, errors: [{ field: 'body', message: 'must be an object', received: typeof body }] }; - } - - const messageValidation = validateMessage(body.message); - if (!messageValidation.success) { - return { success: false, errors: messageValidation.errors || [] }; - } - - const processWithAgent = body.processWithAgent !== undefined ? Boolean(body.processWithAgent) : true; - - return { - success: true, - data: { - message: messageValidation.data!, - processWithAgent - } - }; - }); + const validation = await parseAndValidateJSON(request, SessionMessageRequestSchema); if (!validation.success) { return validation.response; From f117f0ad6ce80e204d6dff39faab294c5cb41dc2 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 13 Sep 2025 22:40:03 +0000 Subject: [PATCH 6/9] Complete Zod migration: remove redundant interfaces and convert utility functions - Remove unused SessionMessageValidationResult and SessionMessageOnlyValidationResult interfaces - Convert validateStepNumber and validateTutorialId to use Zod schemas internally - Add StepNumberSchema and TutorialIdSchema for consistent validation - Maintain backward compatibility with existing function signatures - Complete elimination of duplicate source of truth between validation and types - All validation now uses Zod schemas as single source of truth Co-Authored-By: srith@agentuity.com --- lib/validation/middleware.ts | 65 ++++++++++++++++++++++++------------ lib/validation/types.ts | 9 ----- 2 files changed, 44 insertions(+), 30 deletions(-) diff --git a/lib/validation/middleware.ts b/lib/validation/middleware.ts index 5d210257..6c6b3887 100644 --- a/lib/validation/middleware.ts +++ b/lib/validation/middleware.ts @@ -79,42 +79,65 @@ export const SessionMessageOnlyRequestSchema = z.object({ message: MessageSchema }); -export function validateStepNumber(stepNumber: string): ValidationResult { - const stepIndex = Number.parseInt(stepNumber, 10); +export const StepNumberSchema = z.string().transform((val, ctx) => { + const stepIndex = Number.parseInt(val, 10); if (Number.isNaN(stepIndex)) { - return { - success: false, - errors: [{ field: 'stepNumber', message: 'must be a valid integer', received: stepNumber }] - }; + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'must be a valid integer', + }); + return z.NEVER; } if (stepIndex < 1) { - return { - success: false, - errors: [{ field: 'stepNumber', message: 'must be greater than 0', received: stepIndex }] + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'must be greater than 0', + }); + return z.NEVER; + } + + return stepIndex; +}); + +export const TutorialIdSchema = z.string().min(1, 'must be a non-empty string').refine( + (id) => !id.includes('..') && !id.includes('/') && !id.includes('\\'), + 'contains invalid characters (path traversal attempt)' +); + +export function validateStepNumber(stepNumber: string): ValidationResult { + const result = StepNumberSchema.safeParse(stepNumber); + + if (!result.success) { + return { + success: false, + errors: result.error.issues.map(issue => ({ + field: 'stepNumber', + message: issue.message, + received: stepNumber + })) }; } - return { success: true, data: stepIndex }; + return { success: true, data: result.data }; } export function validateTutorialId(id: string): ValidationResult { - if (!id || typeof id !== 'string') { - return { - success: false, - errors: [{ field: 'tutorialId', message: 'must be a non-empty string', received: typeof id }] - }; - } + const result = TutorialIdSchema.safeParse(id); - if (id.includes('..') || id.includes('/') || id.includes('\\')) { - return { - success: false, - errors: [{ field: 'tutorialId', message: 'contains invalid characters (path traversal attempt)', received: id }] + if (!result.success) { + return { + success: false, + errors: result.error.issues.map(issue => ({ + field: 'tutorialId', + message: issue.message, + received: id + })) }; } - return { success: true, data: id }; + return { success: true, data: result.data }; } export type TutorialProgressRequest = z.infer; diff --git a/lib/validation/types.ts b/lib/validation/types.ts index 23cdbd98..0241fa34 100644 --- a/lib/validation/types.ts +++ b/lib/validation/types.ts @@ -39,12 +39,3 @@ export interface SessionsResponse extends ApiResponse { sessions: Session[]; pagination: PaginationInfo; } - -export interface SessionMessageValidationResult { - message: Message; - processWithAgent: boolean; -} - -export interface SessionMessageOnlyValidationResult { - message: Message; -} From c13195e9d4c3554632dd9fd6e5f80467b97937f9 Mon Sep 17 00:00:00 2001 From: senghorn Date: Sat, 13 Sep 2025 16:52:11 -0600 Subject: [PATCH 7/9] delete lib/validation/types.ts unused module --- app/api/sessions/[sessionId]/route.ts | 6 ++-- lib/validation/middleware.ts | 23 ++++++--------- lib/validation/types.ts | 41 --------------------------- 3 files changed, 12 insertions(+), 58 deletions(-) delete mode 100644 lib/validation/types.ts diff --git a/app/api/sessions/[sessionId]/route.ts b/app/api/sessions/[sessionId]/route.ts index d314a9ba..a3900650 100644 --- a/app/api/sessions/[sessionId]/route.ts +++ b/app/api/sessions/[sessionId]/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from 'next/server'; import { getKVValue, setKVValue, deleteKVValue } from '@/lib/kv-store'; -import { Session, Message, SessionSchema, MessageSchema } from '@/app/chat/types'; +import { Session, Message, SessionSchema } from '@/app/chat/types'; import { toISOString } from '@/app/chat/utils/dateUtils'; import { config } from '@/lib/config'; import { parseAndValidateJSON, SessionMessageOnlyRequestSchema } from '@/lib/validation/middleware'; @@ -199,9 +199,9 @@ export async function POST( const paramsData = await params; const sessionId = paramsData.sessionId; const sessionKey = `${userId}_${sessionId}`; - + const validation = await parseAndValidateJSON(request, SessionMessageOnlyRequestSchema); - + if (!validation.success) { return validation.response; } diff --git a/lib/validation/middleware.ts b/lib/validation/middleware.ts index 6c6b3887..ae25d573 100644 --- a/lib/validation/middleware.ts +++ b/lib/validation/middleware.ts @@ -37,7 +37,7 @@ export async function parseAndValidateJSON( schema: z.ZodSchema ): Promise<{ success: true; data: T } | { success: false; response: NextResponse }> { let body: any; - + try { body = await request.json(); } catch { @@ -81,7 +81,7 @@ export const SessionMessageOnlyRequestSchema = z.object({ export const StepNumberSchema = z.string().transform((val, ctx) => { const stepIndex = Number.parseInt(val, 10); - + if (Number.isNaN(stepIndex)) { ctx.addIssue({ code: z.ZodIssueCode.custom, @@ -89,7 +89,7 @@ export const StepNumberSchema = z.string().transform((val, ctx) => { }); return z.NEVER; } - + if (stepIndex < 1) { ctx.addIssue({ code: z.ZodIssueCode.custom, @@ -97,7 +97,7 @@ export const StepNumberSchema = z.string().transform((val, ctx) => { }); return z.NEVER; } - + return stepIndex; }); @@ -108,7 +108,7 @@ export const TutorialIdSchema = z.string().min(1, 'must be a non-empty string'). export function validateStepNumber(stepNumber: string): ValidationResult { const result = StepNumberSchema.safeParse(stepNumber); - + if (!result.success) { return { success: false, @@ -119,13 +119,13 @@ export function validateStepNumber(stepNumber: string): ValidationResult })) }; } - + return { success: true, data: result.data }; } export function validateTutorialId(id: string): ValidationResult { const result = TutorialIdSchema.safeParse(id); - + if (!result.success) { return { success: false, @@ -136,11 +136,6 @@ export function validateTutorialId(id: string): ValidationResult { })) }; } - - return { success: true, data: result.data }; -} -export type TutorialProgressRequest = z.infer; -export type TutorialResetRequest = z.infer; -export type SessionMessageRequest = z.infer; -export type SessionMessageOnlyRequest = z.infer; + return { success: true, data: result.data }; +} \ No newline at end of file diff --git a/lib/validation/types.ts b/lib/validation/types.ts deleted file mode 100644 index 0241fa34..00000000 --- a/lib/validation/types.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type { Session, Message, TutorialData, ExecuteRequest } from '../../app/chat/types'; -import type { - TutorialProgressRequest, - TutorialResetRequest, - SessionMessageRequest, - SessionMessageOnlyRequest -} from './middleware'; - -export type { - Session, - Message, - TutorialData, - ExecuteRequest -}; - -export type { - TutorialProgressRequest, - TutorialResetRequest, - SessionMessageRequest, - SessionMessageOnlyRequest -}; - -export interface ApiResponse { - success: boolean; - data?: T; - error?: string; - message?: string; -} - -export interface PaginationInfo { - cursor: number; - nextCursor: number | null; - hasMore: boolean; - total: number; - limit: number; -} - -export interface SessionsResponse extends ApiResponse { - sessions: Session[]; - pagination: PaginationInfo; -} From ac2fa7c997e3636271a52b6a6ca7d49591151e39 Mon Sep 17 00:00:00 2001 From: afterrburn Date: Sun, 14 Sep 2025 10:58:31 -0600 Subject: [PATCH 8/9] defensively check tutorials state --- app/api/users/tutorial-state/route.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/api/users/tutorial-state/route.ts b/app/api/users/tutorial-state/route.ts index 6429a3b6..70557fa7 100644 --- a/app/api/users/tutorial-state/route.ts +++ b/app/api/users/tutorial-state/route.ts @@ -88,6 +88,9 @@ export async function DELETE(request: NextRequest) { const { tutorialId } = validation.data; const state = await TutorialStateManager.getUserTutorialState(userId); + if (!state.tutorials) { + state.tutorials = {}; + } delete state.tutorials[tutorialId]; // Save the updated state From 4f111537c2fd6d859c90f749ea980935271dbbe8 Mon Sep 17 00:00:00 2001 From: afterrburn Date: Sun, 14 Sep 2025 11:24:39 -0600 Subject: [PATCH 9/9] update tools description and enhance the path checking --- agent-docs/src/agents/agent-pulse/tools.ts | 2 +- .../tutorials/[id]/steps/[stepNumber]/route.ts | 16 +++++++++------- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/agent-docs/src/agents/agent-pulse/tools.ts b/agent-docs/src/agents/agent-pulse/tools.ts index 3e6cd5b5..473855f0 100644 --- a/agent-docs/src/agents/agent-pulse/tools.ts +++ b/agent-docs/src/agents/agent-pulse/tools.ts @@ -24,7 +24,7 @@ export async function createTools(context: ToolContext) { * Tool for starting a tutorial - adds action to state queue */ const startTutorialAtStep = tool({ - description: "Start a specific tutorial for the user. This will validate the tutorial exists, modify user data state, and finally return information about the tutorial including its title, total steps, and description. The tutorial will be displayed to the user automatically. The step number should be between 1 and the total number of steps in the tutorial.", + description: "Start a specific tutorial for the user. You must call this function in order for the user to see the tutorial step content. The step number should be between 1 and the total number of steps in the tutorial.", parameters: z.object({ tutorialId: z.string().describe("The exact ID of the tutorial to start"), stepNumber: z.number().describe("The step number of the tutorial to start (1 to total available steps in the tutorial)") diff --git a/app/api/tutorials/[id]/steps/[stepNumber]/route.ts b/app/api/tutorials/[id]/steps/[stepNumber]/route.ts index b11b4a71..e949733d 100644 --- a/app/api/tutorials/[id]/steps/[stepNumber]/route.ts +++ b/app/api/tutorials/[id]/steps/[stepNumber]/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from 'next/server'; -import { join } from 'path'; +import { join, resolve, sep } from 'path'; import { readFile } from 'fs/promises'; import matter from 'gray-matter'; import { validateTutorialId, validateStepNumber, createValidationError } from '@/lib/validation/middleware'; @@ -67,13 +67,15 @@ export async function GET(request: NextRequest, { params }: RouteParams) { async function loadSnippet(desc: { path: string; lang?: string; from?: number; to?: number; title?: string }) { const filePath = desc.path; if (!filePath || !filePath.startsWith('/examples/')) return; - - if (filePath.includes('..') || filePath.includes('\\')) return; - - const absolutePath = join(repoRoot, `.${filePath}`); - + + // Resolve against repo root and ensure containment within /examples + const resolvedPath = resolve(repoRoot, `.${filePath}`); + const examplesBase = resolve(repoRoot, 'examples'); + const isContained = resolvedPath === examplesBase || resolvedPath.startsWith(examplesBase + sep); + if (!isContained) return; + try { - const fileRaw = await readFile(absolutePath, 'utf-8'); + const fileRaw = await readFile(resolvedPath, 'utf-8'); const lines = fileRaw.split(/\r?\n/); const startIdx = Math.max(0, (desc.from ? desc.from - 1 : 0)); const endIdx = Math.min(lines.length, desc.to ? desc.to : lines.length);