mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
fix(elevenlabs): added internal auth helper for proxy routes (#1732)
* fix(elevenlabs): added internal auth helper for proxy routes * remove concurrent tests * build fix
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { validateImageUrl } from '@/lib/security/input-validation'
|
||||
import { generateRequestId } from '@/lib/utils'
|
||||
@@ -14,6 +15,12 @@ export async function GET(request: NextRequest) {
|
||||
const imageUrl = url.searchParams.get('url')
|
||||
const requestId = generateRequestId()
|
||||
|
||||
const authResult = await checkHybridAuth(request, { requireWorkflowId: false })
|
||||
if (!authResult.success) {
|
||||
logger.error(`[${requestId}] Authentication failed for image proxy:`, authResult.error)
|
||||
return new NextResponse('Unauthorized', { status: 401 })
|
||||
}
|
||||
|
||||
if (!imageUrl) {
|
||||
logger.error(`[${requestId}] Missing 'url' parameter`)
|
||||
return new NextResponse('Missing URL parameter', { status: 400 })
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { generateInternalToken } from '@/lib/auth/internal'
|
||||
import { isDev } from '@/lib/environment'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
@@ -242,12 +244,18 @@ export async function GET(request: Request) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
export async function POST(request: NextRequest) {
|
||||
const requestId = generateRequestId()
|
||||
const startTime = new Date()
|
||||
const startTimeISO = startTime.toISOString()
|
||||
|
||||
try {
|
||||
const authResult = await checkHybridAuth(request, { requireWorkflowId: false })
|
||||
if (!authResult.success) {
|
||||
logger.error(`[${requestId}] Authentication failed for proxy:`, authResult.error)
|
||||
return createErrorResponse('Unauthorized', 401)
|
||||
}
|
||||
|
||||
let requestBody
|
||||
try {
|
||||
requestBody = await request.json()
|
||||
@@ -311,7 +319,6 @@ export async function POST(request: Request) {
|
||||
error: result.error || 'Unknown error',
|
||||
})
|
||||
|
||||
// Let the main executeTool handle error transformation to avoid double transformation
|
||||
throw new Error(result.error || 'Tool execution failed')
|
||||
}
|
||||
|
||||
@@ -319,10 +326,8 @@ export async function POST(request: Request) {
|
||||
const endTimeISO = endTime.toISOString()
|
||||
const duration = endTime.getTime() - startTime.getTime()
|
||||
|
||||
// Add explicit timing information directly to the response
|
||||
const responseWithTimingData = {
|
||||
...result,
|
||||
// Add timing data both at root level and in nested timing object
|
||||
startTime: startTimeISO,
|
||||
endTime: endTimeISO,
|
||||
duration,
|
||||
@@ -335,7 +340,6 @@ export async function POST(request: Request) {
|
||||
|
||||
logger.info(`[${requestId}] Tool executed successfully: ${toolId} (${duration}ms)`)
|
||||
|
||||
// Return the response with CORS headers
|
||||
return formatResponse(responseWithTimingData)
|
||||
} catch (error: any) {
|
||||
logger.error(`[${requestId}] Proxy request failed`, {
|
||||
@@ -344,7 +348,6 @@ export async function POST(request: Request) {
|
||||
name: error instanceof Error ? error.name : undefined,
|
||||
})
|
||||
|
||||
// Add timing information even to error responses
|
||||
const endTime = new Date()
|
||||
const endTimeISO = endTime.toISOString()
|
||||
const duration = endTime.getTime() - startTime.getTime()
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { validateAlphanumericId } from '@/lib/security/input-validation'
|
||||
import { uploadFile } from '@/lib/uploads/storage-client'
|
||||
@@ -6,19 +8,25 @@ import { getBaseUrl } from '@/lib/urls/utils'
|
||||
|
||||
const logger = createLogger('ProxyTTSAPI')
|
||||
|
||||
export async function POST(request: Request) {
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const authResult = await checkHybridAuth(request, { requireWorkflowId: false })
|
||||
if (!authResult.success) {
|
||||
logger.error('Authentication failed for TTS proxy:', authResult.error)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const { text, voiceId, apiKey, modelId = 'eleven_monolingual_v1' } = body
|
||||
|
||||
if (!text || !voiceId || !apiKey) {
|
||||
return new NextResponse('Missing required parameters', { status: 400 })
|
||||
return NextResponse.json({ error: 'Missing required parameters' }, { status: 400 })
|
||||
}
|
||||
|
||||
const voiceIdValidation = validateAlphanumericId(voiceId, 'voiceId', 255)
|
||||
if (!voiceIdValidation.isValid) {
|
||||
logger.error(`Invalid voice ID: ${voiceIdValidation.error}`)
|
||||
return new NextResponse(voiceIdValidation.error, { status: 400 })
|
||||
return NextResponse.json({ error: voiceIdValidation.error }, { status: 400 })
|
||||
}
|
||||
|
||||
logger.info('Proxying TTS request for voice:', voiceId)
|
||||
@@ -41,16 +49,17 @@ export async function POST(request: Request) {
|
||||
|
||||
if (!response.ok) {
|
||||
logger.error(`Failed to generate TTS: ${response.status} ${response.statusText}`)
|
||||
return new NextResponse(`Failed to generate TTS: ${response.status} ${response.statusText}`, {
|
||||
status: response.status,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ error: `Failed to generate TTS: ${response.status} ${response.statusText}` },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
const audioBlob = await response.blob()
|
||||
|
||||
if (audioBlob.size === 0) {
|
||||
logger.error('Empty audio received from ElevenLabs')
|
||||
return new NextResponse('Empty audio received', { status: 422 })
|
||||
return NextResponse.json({ error: 'Empty audio received' }, { status: 422 })
|
||||
}
|
||||
|
||||
const audioBuffer = Buffer.from(await audioBlob.arrayBuffer())
|
||||
@@ -67,11 +76,11 @@ export async function POST(request: Request) {
|
||||
} catch (error) {
|
||||
logger.error('Error proxying TTS:', error)
|
||||
|
||||
return new NextResponse(
|
||||
`Internal Server Error: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
return NextResponse.json(
|
||||
{
|
||||
status: 500,
|
||||
}
|
||||
error: `Internal Server Error: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { env } from '@/lib/env'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { validateAlphanumericId } from '@/lib/security/input-validation'
|
||||
@@ -7,6 +8,12 @@ const logger = createLogger('ProxyTTSStreamAPI')
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const authResult = await checkHybridAuth(request, { requireWorkflowId: false })
|
||||
if (!authResult.success) {
|
||||
logger.error('Authentication failed for TTS stream proxy:', authResult.error)
|
||||
return new Response('Unauthorized', { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const { text, voiceId, modelId = 'eleven_turbo_v2_5' } = body
|
||||
|
||||
|
||||
@@ -51,6 +51,16 @@ export const elevenLabsTtsTool: ToolConfig<ElevenLabsTtsParams, ElevenLabsTtsRes
|
||||
transformResponse: async (response: Response) => {
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok || data.error) {
|
||||
return {
|
||||
success: false,
|
||||
error: data.error || 'Unknown error occurred',
|
||||
output: {
|
||||
audioUrl: '',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
|
||||
@@ -31,7 +31,7 @@ const createMockExecutionContext = (overrides?: Partial<ExecutionContext>): Exec
|
||||
})
|
||||
|
||||
describe('Tools Registry', () => {
|
||||
it.concurrent('should include all expected built-in tools', () => {
|
||||
it('should include all expected built-in tools', () => {
|
||||
expect(Object.keys(tools).length).toBeGreaterThan(10)
|
||||
|
||||
// Check for existence of some core tools
|
||||
@@ -45,7 +45,7 @@ describe('Tools Registry', () => {
|
||||
expect(tools.serper_search).toBeDefined()
|
||||
})
|
||||
|
||||
it.concurrent('getTool should return the correct tool by ID', () => {
|
||||
it('getTool should return the correct tool by ID', () => {
|
||||
const httpTool = getTool('http_request')
|
||||
expect(httpTool).toBeDefined()
|
||||
expect(httpTool?.id).toBe('http_request')
|
||||
@@ -57,7 +57,7 @@ describe('Tools Registry', () => {
|
||||
expect(gmailTool?.name).toBe('Gmail Read')
|
||||
})
|
||||
|
||||
it.concurrent('getTool should return undefined for non-existent tool', () => {
|
||||
it('getTool should return undefined for non-existent tool', () => {
|
||||
const nonExistentTool = getTool('non_existent_tool')
|
||||
expect(nonExistentTool).toBeUndefined()
|
||||
})
|
||||
@@ -133,7 +133,7 @@ describe('Custom Tools', () => {
|
||||
vi.resetAllMocks()
|
||||
})
|
||||
|
||||
it.concurrent('should get custom tool by ID', () => {
|
||||
it('should get custom tool by ID', () => {
|
||||
const customTool = getTool('custom_custom-tool-123')
|
||||
expect(customTool).toBeDefined()
|
||||
expect(customTool?.name).toBe('Custom Weather Tool')
|
||||
@@ -142,7 +142,7 @@ describe('Custom Tools', () => {
|
||||
expect(customTool?.params.location.required).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('should handle non-existent custom tool', () => {
|
||||
it('should handle non-existent custom tool', () => {
|
||||
const nonExistentTool = getTool('custom_non-existent')
|
||||
expect(nonExistentTool).toBeUndefined()
|
||||
})
|
||||
@@ -193,7 +193,7 @@ describe('executeTool Function', () => {
|
||||
cleanupEnvVars()
|
||||
})
|
||||
|
||||
it.concurrent('should execute a tool successfully', async () => {
|
||||
it('should execute a tool successfully', async () => {
|
||||
const result = await executeTool(
|
||||
'http_request',
|
||||
{
|
||||
@@ -241,7 +241,7 @@ describe('executeTool Function', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it.concurrent('should handle non-existent tool', async () => {
|
||||
it('should handle non-existent tool', async () => {
|
||||
// Create the mock with a matching implementation
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
@@ -254,7 +254,7 @@ describe('executeTool Function', () => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it.concurrent('should handle errors from tools', async () => {
|
||||
it('should handle errors from tools', async () => {
|
||||
// Mock a failed response
|
||||
global.fetch = Object.assign(
|
||||
vi.fn().mockImplementation(async () => {
|
||||
@@ -284,7 +284,7 @@ describe('executeTool Function', () => {
|
||||
expect(result.timing).toBeDefined()
|
||||
})
|
||||
|
||||
it.concurrent('should add timing information to results', async () => {
|
||||
it('should add timing information to results', async () => {
|
||||
const result = await executeTool(
|
||||
'http_request',
|
||||
{
|
||||
@@ -315,58 +315,59 @@ describe('Automatic Internal Route Detection', () => {
|
||||
cleanupEnvVars()
|
||||
})
|
||||
|
||||
it.concurrent(
|
||||
'should detect internal routes (URLs starting with /api/) and call them directly',
|
||||
async () => {
|
||||
// Mock a tool with an internal route
|
||||
const mockTool = {
|
||||
id: 'test_internal_tool',
|
||||
name: 'Test Internal Tool',
|
||||
description: 'A test tool with internal route',
|
||||
version: '1.0.0',
|
||||
params: {},
|
||||
request: {
|
||||
url: '/api/test/endpoint',
|
||||
method: 'POST',
|
||||
headers: () => ({ 'Content-Type': 'application/json' }),
|
||||
},
|
||||
transformResponse: vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
output: { result: 'Internal route success' },
|
||||
}),
|
||||
}
|
||||
|
||||
// Mock the tool registry to include our test tool
|
||||
const originalTools = { ...tools }
|
||||
;(tools as any).test_internal_tool = mockTool
|
||||
|
||||
// Mock fetch for the internal API call
|
||||
global.fetch = Object.assign(
|
||||
vi.fn().mockImplementation(async (url) => {
|
||||
// Should call the internal API directly, not the proxy
|
||||
expect(url).toBe('http://localhost:3000/api/test/endpoint')
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve({ success: true, data: 'test' }),
|
||||
clone: vi.fn().mockReturnThis(),
|
||||
}
|
||||
}),
|
||||
{ preconnect: vi.fn() }
|
||||
) as typeof fetch
|
||||
|
||||
const result = await executeTool('test_internal_tool', {}, false)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.output.result).toBe('Internal route success')
|
||||
expect(mockTool.transformResponse).toHaveBeenCalled()
|
||||
|
||||
// Restore original tools
|
||||
Object.assign(tools, originalTools)
|
||||
it('should detect internal routes (URLs starting with /api/) and call them directly', async () => {
|
||||
// Mock a tool with an internal route
|
||||
const mockTool = {
|
||||
id: 'test_internal_tool',
|
||||
name: 'Test Internal Tool',
|
||||
description: 'A test tool with internal route',
|
||||
version: '1.0.0',
|
||||
params: {},
|
||||
request: {
|
||||
url: '/api/test/endpoint',
|
||||
method: 'POST',
|
||||
headers: () => ({ 'Content-Type': 'application/json' }),
|
||||
},
|
||||
transformResponse: vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
output: { result: 'Internal route success' },
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
it.concurrent('should detect external routes (full URLs) and use proxy', async () => {
|
||||
// Mock the tool registry to include our test tool
|
||||
const originalTools = { ...tools }
|
||||
;(tools as any).test_internal_tool = mockTool
|
||||
|
||||
// Mock fetch for the internal API call
|
||||
global.fetch = Object.assign(
|
||||
vi.fn().mockImplementation(async (url) => {
|
||||
// Should call the internal API directly, not the proxy
|
||||
expect(url).toBe('http://localhost:3000/api/test/endpoint')
|
||||
const responseData = { success: true, data: 'test' }
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
headers: new Headers(),
|
||||
json: () => Promise.resolve(responseData),
|
||||
text: () => Promise.resolve(JSON.stringify(responseData)),
|
||||
clone: vi.fn().mockReturnThis(),
|
||||
}
|
||||
}),
|
||||
{ preconnect: vi.fn() }
|
||||
) as typeof fetch
|
||||
|
||||
const result = await executeTool('test_internal_tool', {}, false)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.output.result).toBe('Internal route success')
|
||||
expect(mockTool.transformResponse).toHaveBeenCalled()
|
||||
|
||||
// Restore original tools
|
||||
Object.assign(tools, originalTools)
|
||||
})
|
||||
|
||||
it('should detect external routes (full URLs) and use proxy', async () => {
|
||||
// Mock a tool with an external route
|
||||
const mockTool = {
|
||||
id: 'test_external_tool',
|
||||
@@ -390,14 +391,17 @@ describe('Automatic Internal Route Detection', () => {
|
||||
vi.fn().mockImplementation(async (url) => {
|
||||
// Should call the proxy, not the external API directly
|
||||
expect(url).toBe('http://localhost:3000/api/proxy')
|
||||
const responseData = {
|
||||
success: true,
|
||||
output: { result: 'External route via proxy' },
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
success: true,
|
||||
output: { result: 'External route via proxy' },
|
||||
}),
|
||||
statusText: 'OK',
|
||||
headers: new Headers(),
|
||||
json: () => Promise.resolve(responseData),
|
||||
text: () => Promise.resolve(JSON.stringify(responseData)),
|
||||
}
|
||||
}),
|
||||
{ preconnect: vi.fn() }
|
||||
@@ -412,7 +416,7 @@ describe('Automatic Internal Route Detection', () => {
|
||||
Object.assign(tools, originalTools)
|
||||
})
|
||||
|
||||
it.concurrent('should handle dynamic URLs that resolve to internal routes', async () => {
|
||||
it('should handle dynamic URLs that resolve to internal routes', async () => {
|
||||
// Mock a tool with a dynamic URL function that returns internal route
|
||||
const mockTool = {
|
||||
id: 'test_dynamic_internal',
|
||||
@@ -442,10 +446,14 @@ describe('Automatic Internal Route Detection', () => {
|
||||
vi.fn().mockImplementation(async (url) => {
|
||||
// Should call the internal API directly with the resolved dynamic URL
|
||||
expect(url).toBe('http://localhost:3000/api/resources/123')
|
||||
const responseData = { success: true, data: 'test' }
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve({ success: true, data: 'test' }),
|
||||
statusText: 'OK',
|
||||
headers: new Headers(),
|
||||
json: () => Promise.resolve(responseData),
|
||||
text: () => Promise.resolve(JSON.stringify(responseData)),
|
||||
clone: vi.fn().mockReturnThis(),
|
||||
}
|
||||
}),
|
||||
@@ -461,7 +469,7 @@ describe('Automatic Internal Route Detection', () => {
|
||||
Object.assign(tools, originalTools)
|
||||
})
|
||||
|
||||
it.concurrent('should handle dynamic URLs that resolve to external routes', async () => {
|
||||
it('should handle dynamic URLs that resolve to external routes', async () => {
|
||||
// Mock a tool with a dynamic URL function that returns external route
|
||||
const mockTool = {
|
||||
id: 'test_dynamic_external',
|
||||
@@ -487,14 +495,17 @@ describe('Automatic Internal Route Detection', () => {
|
||||
vi.fn().mockImplementation(async (url) => {
|
||||
// Should call the proxy, not the external API directly
|
||||
expect(url).toBe('http://localhost:3000/api/proxy')
|
||||
const responseData = {
|
||||
success: true,
|
||||
output: { result: 'Dynamic external route via proxy' },
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
success: true,
|
||||
output: { result: 'Dynamic external route via proxy' },
|
||||
}),
|
||||
statusText: 'OK',
|
||||
headers: new Headers(),
|
||||
json: () => Promise.resolve(responseData),
|
||||
text: () => Promise.resolve(JSON.stringify(responseData)),
|
||||
}
|
||||
}),
|
||||
{ preconnect: vi.fn() }
|
||||
@@ -509,51 +520,48 @@ describe('Automatic Internal Route Detection', () => {
|
||||
Object.assign(tools, originalTools)
|
||||
})
|
||||
|
||||
it.concurrent(
|
||||
'should respect skipProxy parameter and call internal routes directly even for external URLs',
|
||||
async () => {
|
||||
const mockTool = {
|
||||
id: 'test_skip_proxy',
|
||||
name: 'Test Skip Proxy Tool',
|
||||
description: 'A test tool to verify skipProxy behavior',
|
||||
version: '1.0.0',
|
||||
params: {},
|
||||
request: {
|
||||
url: 'https://api.example.com/endpoint',
|
||||
method: 'GET',
|
||||
headers: () => ({ 'Content-Type': 'application/json' }),
|
||||
},
|
||||
transformResponse: vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
output: { result: 'Skipped proxy, called directly' },
|
||||
}),
|
||||
}
|
||||
|
||||
const originalTools = { ...tools }
|
||||
;(tools as any).test_skip_proxy = mockTool
|
||||
|
||||
global.fetch = Object.assign(
|
||||
vi.fn().mockImplementation(async (url) => {
|
||||
expect(url).toBe('https://api.example.com/endpoint')
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve({ success: true, data: 'test' }),
|
||||
clone: vi.fn().mockReturnThis(),
|
||||
}
|
||||
}),
|
||||
{ preconnect: vi.fn() }
|
||||
) as typeof fetch
|
||||
|
||||
const result = await executeTool('test_skip_proxy', {}, true) // skipProxy = true
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.output.result).toBe('Skipped proxy, called directly')
|
||||
expect(mockTool.transformResponse).toHaveBeenCalled()
|
||||
|
||||
Object.assign(tools, originalTools)
|
||||
it('should respect skipProxy parameter and call internal routes directly even for external URLs', async () => {
|
||||
const mockTool = {
|
||||
id: 'test_skip_proxy',
|
||||
name: 'Test Skip Proxy Tool',
|
||||
description: 'A test tool to verify skipProxy behavior',
|
||||
version: '1.0.0',
|
||||
params: {},
|
||||
request: {
|
||||
url: 'https://api.example.com/endpoint',
|
||||
method: 'GET',
|
||||
headers: () => ({ 'Content-Type': 'application/json' }),
|
||||
},
|
||||
transformResponse: vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
output: { result: 'Skipped proxy, called directly' },
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
const originalTools = { ...tools }
|
||||
;(tools as any).test_skip_proxy = mockTool
|
||||
|
||||
global.fetch = Object.assign(
|
||||
vi.fn().mockImplementation(async (url) => {
|
||||
expect(url).toBe('https://api.example.com/endpoint')
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve({ success: true, data: 'test' }),
|
||||
clone: vi.fn().mockReturnThis(),
|
||||
}
|
||||
}),
|
||||
{ preconnect: vi.fn() }
|
||||
) as typeof fetch
|
||||
|
||||
const result = await executeTool('test_skip_proxy', {}, true) // skipProxy = true
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.output.result).toBe('Skipped proxy, called directly')
|
||||
expect(mockTool.transformResponse).toHaveBeenCalled()
|
||||
|
||||
Object.assign(tools, originalTools)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Centralized Error Handling', () => {
|
||||
@@ -600,7 +608,7 @@ describe('Centralized Error Handling', () => {
|
||||
expect(result.error).toBe(expectedError)
|
||||
}
|
||||
|
||||
it.concurrent('should extract GraphQL error format (Linear API)', async () => {
|
||||
it('should extract GraphQL error format (Linear API)', async () => {
|
||||
await testErrorFormat(
|
||||
'GraphQL',
|
||||
{ errors: [{ message: 'Invalid query field' }] },
|
||||
@@ -608,7 +616,7 @@ describe('Centralized Error Handling', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it.concurrent('should extract X/Twitter API error format', async () => {
|
||||
it('should extract X/Twitter API error format', async () => {
|
||||
await testErrorFormat(
|
||||
'X/Twitter',
|
||||
{ errors: [{ detail: 'Rate limit exceeded' }] },
|
||||
@@ -616,15 +624,15 @@ describe('Centralized Error Handling', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it.concurrent('should extract Hunter API error format', async () => {
|
||||
it('should extract Hunter API error format', async () => {
|
||||
await testErrorFormat('Hunter', { errors: [{ details: 'Invalid API key' }] }, 'Invalid API key')
|
||||
})
|
||||
|
||||
it.concurrent('should extract direct errors array (string)', async () => {
|
||||
it('should extract direct errors array (string)', async () => {
|
||||
await testErrorFormat('Direct string array', { errors: ['Network timeout'] }, 'Network timeout')
|
||||
})
|
||||
|
||||
it.concurrent('should extract direct errors array (object)', async () => {
|
||||
it('should extract direct errors array (object)', async () => {
|
||||
await testErrorFormat(
|
||||
'Direct object array',
|
||||
{ errors: [{ message: 'Validation failed' }] },
|
||||
@@ -632,11 +640,11 @@ describe('Centralized Error Handling', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it.concurrent('should extract OAuth error description', async () => {
|
||||
it('should extract OAuth error description', async () => {
|
||||
await testErrorFormat('OAuth', { error_description: 'Invalid grant' }, 'Invalid grant')
|
||||
})
|
||||
|
||||
it.concurrent('should extract SOAP fault error', async () => {
|
||||
it('should extract SOAP fault error', async () => {
|
||||
await testErrorFormat(
|
||||
'SOAP fault',
|
||||
{ fault: { faultstring: 'Server unavailable' } },
|
||||
@@ -644,7 +652,7 @@ describe('Centralized Error Handling', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it.concurrent('should extract simple SOAP faultstring', async () => {
|
||||
it('should extract simple SOAP faultstring', async () => {
|
||||
await testErrorFormat(
|
||||
'Simple SOAP',
|
||||
{ faultstring: 'Authentication failed' },
|
||||
@@ -652,11 +660,11 @@ describe('Centralized Error Handling', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it.concurrent('should extract Notion/Discord message format', async () => {
|
||||
it('should extract Notion/Discord message format', async () => {
|
||||
await testErrorFormat('Notion/Discord', { message: 'Page not found' }, 'Page not found')
|
||||
})
|
||||
|
||||
it.concurrent('should extract Airtable error object format', async () => {
|
||||
it('should extract Airtable error object format', async () => {
|
||||
await testErrorFormat(
|
||||
'Airtable',
|
||||
{ error: { message: 'Invalid table ID' } },
|
||||
@@ -664,7 +672,7 @@ describe('Centralized Error Handling', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it.concurrent('should extract simple error string format', async () => {
|
||||
it('should extract simple error string format', async () => {
|
||||
await testErrorFormat(
|
||||
'Simple string',
|
||||
{ error: 'Simple error message' },
|
||||
@@ -672,7 +680,7 @@ describe('Centralized Error Handling', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it.concurrent('should fall back to HTTP status when JSON parsing fails', async () => {
|
||||
it('should fall back to HTTP status when JSON parsing fails', async () => {
|
||||
global.fetch = Object.assign(
|
||||
vi.fn().mockImplementation(async () => ({
|
||||
ok: false,
|
||||
@@ -701,7 +709,7 @@ describe('Centralized Error Handling', () => {
|
||||
expect(result.error).toBe('Failed to parse response from function_execute: Error: Invalid JSON')
|
||||
})
|
||||
|
||||
it.concurrent('should handle complex nested error objects', async () => {
|
||||
it('should handle complex nested error objects', async () => {
|
||||
await testErrorFormat(
|
||||
'Complex nested',
|
||||
{ error: { code: 400, message: 'Complex validation error', details: 'Field X is invalid' } },
|
||||
@@ -709,7 +717,7 @@ describe('Centralized Error Handling', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it.concurrent('should handle error arrays with multiple entries (take first)', async () => {
|
||||
it('should handle error arrays with multiple entries (take first)', async () => {
|
||||
await testErrorFormat(
|
||||
'Multiple errors',
|
||||
{ errors: [{ message: 'First error' }, { message: 'Second error' }] },
|
||||
@@ -717,7 +725,7 @@ describe('Centralized Error Handling', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it.concurrent('should stringify complex error objects when no message found', async () => {
|
||||
it('should stringify complex error objects when no message found', async () => {
|
||||
const complexError = { code: 500, type: 'ServerError', context: { requestId: '123' } }
|
||||
await testErrorFormat(
|
||||
'Complex object stringify',
|
||||
@@ -742,7 +750,7 @@ describe('MCP Tool Execution', () => {
|
||||
cleanupEnvVars()
|
||||
})
|
||||
|
||||
it.concurrent('should execute MCP tool with valid tool ID', async () => {
|
||||
it('should execute MCP tool with valid tool ID', async () => {
|
||||
global.fetch = Object.assign(
|
||||
vi.fn().mockImplementation(async (url, options) => {
|
||||
expect(url).toBe('http://localhost:3000/api/mcp/tools/execute')
|
||||
@@ -787,7 +795,7 @@ describe('MCP Tool Execution', () => {
|
||||
expect(result.timing).toBeDefined()
|
||||
})
|
||||
|
||||
it.concurrent('should handle MCP tool ID parsing correctly', async () => {
|
||||
it('should handle MCP tool ID parsing correctly', async () => {
|
||||
global.fetch = Object.assign(
|
||||
vi.fn().mockImplementation(async (url, options) => {
|
||||
const body = JSON.parse(options?.body as string)
|
||||
@@ -818,7 +826,7 @@ describe('MCP Tool Execution', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it.concurrent('should handle MCP block arguments format', async () => {
|
||||
it('should handle MCP block arguments format', async () => {
|
||||
global.fetch = Object.assign(
|
||||
vi.fn().mockImplementation(async (url, options) => {
|
||||
const body = JSON.parse(options?.body as string)
|
||||
@@ -852,7 +860,7 @@ describe('MCP Tool Execution', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it.concurrent('should handle agent block MCP arguments format', async () => {
|
||||
it('should handle agent block MCP arguments format', async () => {
|
||||
global.fetch = Object.assign(
|
||||
vi.fn().mockImplementation(async (url, options) => {
|
||||
const body = JSON.parse(options?.body as string)
|
||||
@@ -890,7 +898,7 @@ describe('MCP Tool Execution', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it.concurrent('should handle MCP tool execution errors', async () => {
|
||||
it('should handle MCP tool execution errors', async () => {
|
||||
global.fetch = Object.assign(
|
||||
vi.fn().mockImplementation(async () => ({
|
||||
ok: false,
|
||||
@@ -920,14 +928,14 @@ describe('MCP Tool Execution', () => {
|
||||
expect(result.timing).toBeDefined()
|
||||
})
|
||||
|
||||
it.concurrent('should require workspaceId for MCP tools', async () => {
|
||||
it('should require workspaceId for MCP tools', async () => {
|
||||
const result = await executeTool('mcp-123-test_tool', { param: 'value' })
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain('Missing workspaceId in execution context for MCP tool')
|
||||
})
|
||||
|
||||
it.concurrent('should handle invalid MCP tool ID format', async () => {
|
||||
it('should handle invalid MCP tool ID format', async () => {
|
||||
const mockContext6 = createMockExecutionContext()
|
||||
|
||||
const result = await executeTool(
|
||||
@@ -942,7 +950,7 @@ describe('MCP Tool Execution', () => {
|
||||
expect(result.error).toContain('Tool not found')
|
||||
})
|
||||
|
||||
it.concurrent('should handle MCP API network errors', async () => {
|
||||
it('should handle MCP API network errors', async () => {
|
||||
global.fetch = Object.assign(vi.fn().mockRejectedValue(new Error('Network error')), {
|
||||
preconnect: vi.fn(),
|
||||
}) as typeof fetch
|
||||
|
||||
@@ -408,6 +408,38 @@ function isErrorResponse(
|
||||
return { isError: false }
|
||||
}
|
||||
|
||||
/**
|
||||
* Add internal authentication token to headers if running on server
|
||||
* @param headers - Headers object to modify
|
||||
* @param isInternalRoute - Whether the target URL is an internal route
|
||||
* @param requestId - Request ID for logging
|
||||
* @param context - Context string for logging (e.g., toolId or 'proxy')
|
||||
*/
|
||||
async function addInternalAuthIfNeeded(
|
||||
headers: Headers | Record<string, string>,
|
||||
isInternalRoute: boolean,
|
||||
requestId: string,
|
||||
context: string
|
||||
): Promise<void> {
|
||||
if (typeof window === 'undefined') {
|
||||
if (isInternalRoute) {
|
||||
try {
|
||||
const internalToken = await generateInternalToken()
|
||||
if (headers instanceof Headers) {
|
||||
headers.set('Authorization', `Bearer ${internalToken}`)
|
||||
} else {
|
||||
headers.Authorization = `Bearer ${internalToken}`
|
||||
}
|
||||
logger.info(`[${requestId}] Added internal auth token for ${context}`)
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Failed to generate internal token for ${context}:`, error)
|
||||
}
|
||||
} else {
|
||||
logger.info(`[${requestId}] Skipping internal auth token for external URL: ${context}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an internal/direct tool request
|
||||
*/
|
||||
@@ -448,19 +480,7 @@ async function handleInternalRequest(
|
||||
}
|
||||
|
||||
const headers = new Headers(requestParams.headers)
|
||||
if (typeof window === 'undefined') {
|
||||
if (isInternalRoute) {
|
||||
try {
|
||||
const internalToken = await generateInternalToken()
|
||||
headers.set('Authorization', `Bearer ${internalToken}`)
|
||||
logger.info(`[${requestId}] Added internal auth token for ${toolId}`)
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Failed to generate internal token for ${toolId}:`, error)
|
||||
}
|
||||
} else {
|
||||
logger.info(`[${requestId}] Skipping internal auth token for external URL: ${endpointUrl}`)
|
||||
}
|
||||
}
|
||||
await addInternalAuthIfNeeded(headers, isInternalRoute, requestId, toolId)
|
||||
|
||||
// Prepare request options
|
||||
const requestOptions = {
|
||||
@@ -652,9 +672,12 @@ async function handleProxyRequest(
|
||||
const proxyUrl = new URL('/api/proxy', baseUrl).toString()
|
||||
|
||||
try {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
|
||||
await addInternalAuthIfNeeded(headers, true, requestId, `proxy:${toolId}`)
|
||||
|
||||
const response = await fetch(proxyUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers,
|
||||
body: JSON.stringify({ toolId, params, executionContext }),
|
||||
})
|
||||
|
||||
@@ -669,9 +692,7 @@ async function handleProxyRequest(
|
||||
let errorMessage = `HTTP error ${response.status}: ${response.statusText}`
|
||||
|
||||
try {
|
||||
// Try to parse as JSON for more details
|
||||
const errorJson = JSON.parse(errorText)
|
||||
// Enhanced error extraction to match internal API patterns
|
||||
errorMessage =
|
||||
// Primary error patterns
|
||||
errorJson.errors?.[0]?.message ||
|
||||
|
||||
@@ -127,10 +127,23 @@ export const imageTool: ToolConfig = {
|
||||
const proxyUrl = new URL('/api/proxy/image', baseUrl)
|
||||
proxyUrl.searchParams.append('url', imageUrl)
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
Accept: 'image/*, */*',
|
||||
}
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
const { generateInternalToken } = await import('@/lib/auth/internal')
|
||||
try {
|
||||
const token = await generateInternalToken()
|
||||
headers.Authorization = `Bearer ${token}`
|
||||
logger.info('Added internal auth token for image proxy request')
|
||||
} catch (error) {
|
||||
logger.error('Failed to generate internal token for image proxy:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const imageResponse = await fetch(proxyUrl.toString(), {
|
||||
headers: {
|
||||
Accept: 'image/*, */*',
|
||||
},
|
||||
headers,
|
||||
cache: 'no-store',
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user