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/sessions/[sessionId]/messages/route.ts b/app/api/sessions/[sessionId]/messages/route.ts index b3167dcb..017d17dc 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, SessionMessageRequestSchema } from "@/lib/validation/middleware"; // Constants const DEFAULT_CONVERSATION_HISTORY_LIMIT = 10; @@ -63,19 +64,15 @@ 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, SessionMessageRequestSchema); + + 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..a3900650 100644 --- a/app/api/sessions/[sessionId]/route.ts +++ b/app/api/sessions/[sessionId]/route.ts @@ -1,8 +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 } from '@/app/chat/types'; import { toISOString } from '@/app/chat/utils/dateUtils'; import { config } from '@/lib/config'; +import { parseAndValidateJSON, SessionMessageOnlyRequestSchema } from '@/lib/validation/middleware'; /** * GET /api/sessions/[sessionId] - Get a specific session @@ -53,19 +54,25 @@ 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, SessionSchema); + 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 } ); } // 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, @@ -192,15 +199,15 @@ 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, SessionMessageOnlyRequestSchema); + + 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 +263,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..bd6a97f2 100644 --- a/app/api/sessions/route.ts +++ b/app/api/sessions/route.ts @@ -1,8 +1,9 @@ import { NextRequest, NextResponse } from 'next/server'; import { getKVValue, setKVValue } from '@/lib/kv-store'; -import { Session } 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 } 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,18 +78,17 @@ 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, SessionSchema); + 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 => { + session.messages = session.messages.map((message: Message) => { if (message.timestamp) { return { ...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..e949733d 100644 --- a/app/api/tutorials/[id]/steps/[stepNumber]/route.ts +++ b/app/api/tutorials/[id]/steps/[stepNumber]/route.ts @@ -1,7 +1,8 @@ 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'; interface RouteParams { params: Promise<{ id: string; stepNumber: string }>; @@ -10,8 +11,19 @@ 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) { + + 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; + if (!stepIndex) { return NextResponse.json( { success: false, error: 'Invalid step number' }, { status: 400 } @@ -28,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( @@ -47,13 +67,23 @@ 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; - 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 }); + + // 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(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); + 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 +175,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..70557fa7 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, + TutorialProgressRequestSchema, + TutorialResetRequestSchema +} 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, TutorialProgressRequestSchema); + 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,31 @@ 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, TutorialResetRequestSchema); + 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); + if (!state.tutorials) { + state.tutorials = {}; + } 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/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 new file mode 100644 index 00000000..ae25d573 --- /dev/null +++ b/lib/validation/middleware.ts @@ -0,0 +1,141 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; +import { MessageSchema, SessionSchema } from '@/app/chat/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 } + ); +} + +function zodErrorToValidationErrors(error: z.ZodError): ValidationError[] { + return error.issues.map((err: any) => ({ + field: err.path.join('.'), + message: err.message, + received: err.received + })); +} + +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 }) + }; + } + + const result = schema.safeParse(body); + + if (!result.success) { + const errors = zodErrorToValidationErrors(result.error); + return { + success: false, + response: createValidationError('Validation failed', errors) + }; + } + + return { success: true, data: result.data }; +} + +export const TutorialProgressRequestSchema = z.object({ + tutorialId: z.string().min(1), + currentStep: z.number().int().min(0), + totalSteps: z.number().int().min(1) +}); + +export const TutorialResetRequestSchema = z.object({ + tutorialId: z.string().min(1) +}); + +export const SessionMessageRequestSchema = z.object({ + message: MessageSchema, + processWithAgent: z.boolean().optional().default(true) +}); + +export const SessionMessageOnlyRequestSchema = z.object({ + message: MessageSchema +}); + +export const StepNumberSchema = z.string().transform((val, ctx) => { + const stepIndex = Number.parseInt(val, 10); + + if (Number.isNaN(stepIndex)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'must be a valid integer', + }); + return z.NEVER; + } + + if (stepIndex < 1) { + 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: result.data }; +} + +export function validateTutorialId(id: string): ValidationResult { + const result = TutorialIdSchema.safeParse(id); + + if (!result.success) { + return { + success: false, + errors: result.error.issues.map(issue => ({ + field: 'tutorialId', + message: issue.message, + received: id + })) + }; + } + + return { success: true, data: result.data }; +} \ No newline at end of file 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",