feat(internal): added internal api base url for internal calls (#3212)

* feat(internal): added internal api base url for internal calls

* make validation on http more lax
This commit is contained in:
Waleed
2026-02-12 23:56:35 -08:00
committed by GitHub
parent 022e84c4b1
commit a337aa7dfe
22 changed files with 95 additions and 48 deletions

View File

@@ -13,6 +13,7 @@ BETTER_AUTH_URL=http://localhost:3000
# NextJS (Required)
NEXT_PUBLIC_APP_URL=http://localhost:3000
# INTERNAL_API_BASE_URL=http://sim-app.default.svc.cluster.local:3000 # Optional: internal URL for server-side /api self-calls; defaults to NEXT_PUBLIC_APP_URL
# Security (Required)
ENCRYPTION_KEY=your_encryption_key # Use `openssl rand -hex 32` to generate, used to encrypt environment variables

View File

@@ -1,7 +1,7 @@
import type { Artifact, Message, PushNotificationConfig, Task, TaskState } from '@a2a-js/sdk'
import { v4 as uuidv4 } from 'uuid'
import { generateInternalToken } from '@/lib/auth/internal'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { getInternalApiBaseUrl } from '@/lib/core/utils/urls'
/** A2A v0.3 JSON-RPC method names */
export const A2A_METHODS = {
@@ -118,7 +118,7 @@ export interface ExecuteRequestResult {
export async function buildExecuteRequest(
config: ExecuteRequestConfig
): Promise<ExecuteRequestResult> {
const url = `${getBaseUrl()}/api/workflows/${config.workflowId}/execute`
const url = `${getInternalApiBaseUrl()}/api/workflows/${config.workflowId}/execute`
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
let useInternalAuth = false

View File

@@ -18,9 +18,9 @@ describe('Copilot Checkpoints Revert API Route', () => {
setupCommonApiMocks()
mockCryptoUuid()
// Mock getBaseUrl to return localhost for tests
vi.doMock('@/lib/core/utils/urls', () => ({
getBaseUrl: vi.fn(() => 'http://localhost:3000'),
getInternalApiBaseUrl: vi.fn(() => 'http://localhost:3000'),
getBaseDomain: vi.fn(() => 'localhost:3000'),
getEmailDomain: vi.fn(() => 'localhost:3000'),
}))

View File

@@ -11,7 +11,7 @@ import {
createRequestTracker,
createUnauthorizedResponse,
} from '@/lib/copilot/request-helpers'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { getInternalApiBaseUrl } from '@/lib/core/utils/urls'
import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils'
import { isUuidV4 } from '@/executor/constants'
@@ -99,7 +99,7 @@ export async function POST(request: NextRequest) {
}
const stateResponse = await fetch(
`${getBaseUrl()}/api/workflows/${checkpoint.workflowId}/state`,
`${getInternalApiBaseUrl()}/api/workflows/${checkpoint.workflowId}/state`,
{
method: 'PUT',
headers: {

View File

@@ -72,6 +72,7 @@ describe('MCP Serve Route', () => {
}))
vi.doMock('@/lib/core/utils/urls', () => ({
getBaseUrl: () => 'http://localhost:3000',
getInternalApiBaseUrl: () => 'http://localhost:3000',
}))
vi.doMock('@/lib/core/execution-limits', () => ({
getMaxExecutionTimeout: () => 10_000,

View File

@@ -22,7 +22,7 @@ import { type NextRequest, NextResponse } from 'next/server'
import { type AuthResult, checkHybridAuth } from '@/lib/auth/hybrid'
import { generateInternalToken } from '@/lib/auth/internal'
import { getMaxExecutionTimeout } from '@/lib/core/execution-limits'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { getInternalApiBaseUrl } from '@/lib/core/utils/urls'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('WorkflowMcpServeAPI')
@@ -285,7 +285,7 @@ async function handleToolsCall(
)
}
const executeUrl = `${getBaseUrl()}/api/workflows/${tool.workflowId}/execute`
const executeUrl = `${getInternalApiBaseUrl()}/api/workflows/${tool.workflowId}/execute`
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
if (publicServerOwnerId) {

View File

@@ -6,7 +6,7 @@ import { type NextRequest, NextResponse } from 'next/server'
import { v4 as uuidv4 } from 'uuid'
import { getSession } from '@/lib/auth'
import { generateRequestId } from '@/lib/core/utils/request'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { getInternalApiBaseUrl } from '@/lib/core/utils/urls'
import {
type RegenerateStateInput,
regenerateWorkflowStateIds,
@@ -115,15 +115,18 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
// Step 3: Save the workflow state using the existing state endpoint (like imports do)
// Ensure variables in state are remapped for the new workflow as well
const workflowStateWithVariables = { ...workflowState, variables: remappedVariables }
const stateResponse = await fetch(`${getBaseUrl()}/api/workflows/${newWorkflowId}/state`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
// Forward the session cookie for authentication
cookie: request.headers.get('cookie') || '',
},
body: JSON.stringify(workflowStateWithVariables),
})
const stateResponse = await fetch(
`${getInternalApiBaseUrl()}/api/workflows/${newWorkflowId}/state`,
{
method: 'PUT',
headers: {
'Content-Type': 'application/json',
// Forward the session cookie for authentication
cookie: request.headers.get('cookie') || '',
},
body: JSON.stringify(workflowStateWithVariables),
}
)
if (!stateResponse.ok) {
logger.error(`[${requestId}] Failed to save workflow state for template use`)

View File

@@ -2,7 +2,7 @@ import { db } from '@sim/db'
import { account } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { getInternalApiBaseUrl } from '@/lib/core/utils/urls'
import { refreshTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import { generateRouterPrompt, generateRouterV2Prompt } from '@/blocks/blocks/router'
import type { BlockOutput } from '@/blocks/types'
@@ -79,7 +79,7 @@ export class RouterBlockHandler implements BlockHandler {
const providerId = getProviderFromModel(routerConfig.model)
try {
const url = new URL('/api/providers', getBaseUrl())
const url = new URL('/api/providers', getInternalApiBaseUrl())
if (ctx.userId) url.searchParams.set('userId', ctx.userId)
const messages = [{ role: 'user', content: routerConfig.prompt }]
@@ -209,7 +209,7 @@ export class RouterBlockHandler implements BlockHandler {
const providerId = getProviderFromModel(routerConfig.model)
try {
const url = new URL('/api/providers', getBaseUrl())
const url = new URL('/api/providers', getInternalApiBaseUrl())
if (ctx.userId) url.searchParams.set('userId', ctx.userId)
const messages = [{ role: 'user', content: routerConfig.context }]

View File

@@ -1,5 +1,5 @@
import { generateInternalToken } from '@/lib/auth/internal'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { getBaseUrl, getInternalApiBaseUrl } from '@/lib/core/utils/urls'
import { HTTP } from '@/executor/constants'
export async function buildAuthHeaders(): Promise<Record<string, string>> {
@@ -16,7 +16,8 @@ export async function buildAuthHeaders(): Promise<Record<string, string>> {
}
export function buildAPIUrl(path: string, params?: Record<string, string>): URL {
const url = new URL(path, getBaseUrl())
const baseUrl = path.startsWith('/api/') ? getInternalApiBaseUrl() : getBaseUrl()
const url = new URL(path, baseUrl)
if (params) {
for (const [key, value] of Object.entries(params)) {

View File

@@ -220,6 +220,7 @@ export const env = createEnv({
SOCKET_SERVER_URL: z.string().url().optional(), // WebSocket server URL for real-time features
SOCKET_PORT: z.number().optional(), // Port for WebSocket server
PORT: z.number().optional(), // Main application port
INTERNAL_API_BASE_URL: z.string().optional(), // Optional internal base URL for server-side self-calls; must include protocol if set (e.g., http://sim-app.namespace.svc.cluster.local:3000)
ALLOWED_ORIGINS: z.string().optional(), // CORS allowed origins
// OAuth Integration Credentials - All optional, enables third-party integrations

View File

@@ -1,6 +1,19 @@
import { getEnv } from '@/lib/core/config/env'
import { isProd } from '@/lib/core/config/feature-flags'
function hasHttpProtocol(url: string): boolean {
return /^https?:\/\//i.test(url)
}
function normalizeBaseUrl(url: string): string {
if (hasHttpProtocol(url)) {
return url
}
const protocol = isProd ? 'https://' : 'http://'
return `${protocol}${url}`
}
/**
* Returns the base URL of the application from NEXT_PUBLIC_APP_URL
* This ensures webhooks, callbacks, and other integrations always use the correct public URL
@@ -8,7 +21,7 @@ import { isProd } from '@/lib/core/config/feature-flags'
* @throws Error if NEXT_PUBLIC_APP_URL is not configured
*/
export function getBaseUrl(): string {
const baseUrl = getEnv('NEXT_PUBLIC_APP_URL')
const baseUrl = getEnv('NEXT_PUBLIC_APP_URL')?.trim()
if (!baseUrl) {
throw new Error(
@@ -16,12 +29,26 @@ export function getBaseUrl(): string {
)
}
if (baseUrl.startsWith('http://') || baseUrl.startsWith('https://')) {
return baseUrl
return normalizeBaseUrl(baseUrl)
}
/**
* Returns the base URL used by server-side internal API calls.
* Falls back to NEXT_PUBLIC_APP_URL when INTERNAL_API_BASE_URL is not set.
*/
export function getInternalApiBaseUrl(): string {
const internalBaseUrl = getEnv('INTERNAL_API_BASE_URL')?.trim()
if (!internalBaseUrl) {
return getBaseUrl()
}
const protocol = isProd ? 'https://' : 'http://'
return `${protocol}${baseUrl}`
if (!hasHttpProtocol(internalBaseUrl)) {
throw new Error(
'INTERNAL_API_BASE_URL must include protocol (http:// or https://), e.g. http://sim-app.default.svc.cluster.local:3000'
)
}
return internalBaseUrl
}
/**

View File

@@ -2,7 +2,7 @@ import { db } from '@sim/db'
import { account } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { getInternalApiBaseUrl } from '@/lib/core/utils/urls'
import { refreshTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import { executeProviderRequest } from '@/providers'
import { getProviderFromModel } from '@/providers/utils'
@@ -61,7 +61,7 @@ async function queryKnowledgeBase(
})
// Call the knowledge base search API directly
const searchUrl = `${getBaseUrl()}/api/knowledge/search`
const searchUrl = `${getInternalApiBaseUrl()}/api/knowledge/search`
const response = await fetch(searchUrl, {
method: 'POST',

View File

@@ -539,8 +539,8 @@ async function executeMistralOCRRequest(
const isInternalRoute = url.startsWith('/')
if (isInternalRoute) {
const { getBaseUrl } = await import('@/lib/core/utils/urls')
url = `${getBaseUrl()}${url}`
const { getInternalApiBaseUrl } = await import('@/lib/core/utils/urls')
url = `${getInternalApiBaseUrl()}${url}`
}
let headers =

View File

@@ -11,7 +11,7 @@ import { and, eq, isNull, or, sql } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import { isOrganizationOnTeamOrEnterprisePlan } from '@/lib/billing'
import { pollingIdempotency } from '@/lib/core/idempotency/service'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { getInternalApiBaseUrl } from '@/lib/core/utils/urls'
import { getOAuthToken, refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import type { GmailAttachment } from '@/tools/gmail/types'
import { downloadAttachments, extractAttachmentInfo } from '@/tools/gmail/utils'
@@ -691,7 +691,7 @@ async function processEmails(
`[${requestId}] Sending ${config.includeRawEmail ? 'simplified + raw' : 'simplified'} email payload for ${email.id}`
)
const webhookUrl = `${getBaseUrl()}/api/webhooks/trigger/${webhookData.path}`
const webhookUrl = `${getInternalApiBaseUrl()}/api/webhooks/trigger/${webhookData.path}`
const response = await fetch(webhookUrl, {
method: 'POST',

View File

@@ -7,7 +7,7 @@ import type { FetchMessageObject, MailboxLockObject } from 'imapflow'
import { ImapFlow } from 'imapflow'
import { nanoid } from 'nanoid'
import { pollingIdempotency } from '@/lib/core/idempotency/service'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { getInternalApiBaseUrl } from '@/lib/core/utils/urls'
import { MAX_CONSECUTIVE_FAILURES } from '@/triggers/constants'
const logger = createLogger('ImapPollingService')
@@ -639,7 +639,7 @@ async function processEmails(
timestamp: new Date().toISOString(),
}
const webhookUrl = `${getBaseUrl()}/api/webhooks/trigger/${webhookData.path}`
const webhookUrl = `${getInternalApiBaseUrl()}/api/webhooks/trigger/${webhookData.path}`
const response = await fetch(webhookUrl, {
method: 'POST',

View File

@@ -12,7 +12,7 @@ import { htmlToText } from 'html-to-text'
import { nanoid } from 'nanoid'
import { isOrganizationOnTeamOrEnterprisePlan } from '@/lib/billing'
import { pollingIdempotency } from '@/lib/core/idempotency'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { getInternalApiBaseUrl } from '@/lib/core/utils/urls'
import { getOAuthToken, refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import { MAX_CONSECUTIVE_FAILURES } from '@/triggers/constants'
@@ -601,7 +601,7 @@ async function processOutlookEmails(
`[${requestId}] Processing email: ${email.subject} from ${email.from?.emailAddress?.address}`
)
const webhookUrl = `${getBaseUrl()}/api/webhooks/trigger/${webhookData.path}`
const webhookUrl = `${getInternalApiBaseUrl()}/api/webhooks/trigger/${webhookData.path}`
const response = await fetch(webhookUrl, {
method: 'POST',

View File

@@ -9,7 +9,7 @@ import {
secureFetchWithPinnedIP,
validateUrlWithDNS,
} from '@/lib/core/security/input-validation.server'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { getInternalApiBaseUrl } from '@/lib/core/utils/urls'
import { MAX_CONSECUTIVE_FAILURES } from '@/triggers/constants'
const logger = createLogger('RssPollingService')
@@ -376,7 +376,7 @@ async function processRssItems(
timestamp: new Date().toISOString(),
}
const webhookUrl = `${getBaseUrl()}/api/webhooks/trigger/${webhookData.path}`
const webhookUrl = `${getInternalApiBaseUrl()}/api/webhooks/trigger/${webhookData.path}`
const response = await fetch(webhookUrl, {
method: 'POST',

View File

@@ -6,7 +6,7 @@ import {
validateUrlWithDNS,
} from '@/lib/core/security/input-validation.server'
import { generateRequestId } from '@/lib/core/utils/request'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { getBaseUrl, getInternalApiBaseUrl } from '@/lib/core/utils/urls'
import { parseMcpToolId } from '@/lib/mcp/utils'
import { isCustomTool, isMcpTool } from '@/executor/constants'
import { resolveSkillContent } from '@/executor/handlers/agent/skills-resolver'
@@ -285,7 +285,7 @@ export async function executeTool(
`[${requestId}] Tool ${toolId} needs access token for credential: ${contextParams.credential}`
)
try {
const baseUrl = getBaseUrl()
const baseUrl = getInternalApiBaseUrl()
const workflowId = contextParams._context?.workflowId
const userId = contextParams._context?.userId
@@ -597,12 +597,12 @@ async function executeToolRequest(
const requestParams = formatRequestParams(tool, params)
try {
const baseUrl = getBaseUrl()
const endpointUrl =
typeof tool.request.url === 'function' ? tool.request.url(params) : tool.request.url
const isInternalRoute = endpointUrl.startsWith('/api/')
const baseUrl = isInternalRoute ? getInternalApiBaseUrl() : getBaseUrl()
const fullUrlObj = new URL(endpointUrl, baseUrl)
const isInternalRoute = endpointUrl.startsWith('/api/')
if (isInternalRoute) {
const workflowId = params._context?.workflowId
@@ -922,7 +922,7 @@ async function executeMcpTool(
const { serverId, toolName } = parseMcpToolId(toolId)
const baseUrl = getBaseUrl()
const baseUrl = getInternalApiBaseUrl()
const headers: Record<string, string> = { 'Content-Type': 'application/json' }

View File

@@ -1,5 +1,5 @@
import { createLogger } from '@sim/logger'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { getInternalApiBaseUrl } from '@/lib/core/utils/urls'
import type { BaseImageRequestBody } from '@/tools/openai/types'
import type { ToolConfig } from '@/tools/types'
@@ -122,7 +122,7 @@ export const imageTool: ToolConfig = {
if (imageUrl && !base64Image) {
try {
logger.info('Fetching image from URL via proxy...')
const baseUrl = getBaseUrl()
const baseUrl = getInternalApiBaseUrl()
const proxyUrl = new URL('/api/tools/image', baseUrl)
proxyUrl.searchParams.append('url', imageUrl)

View File

@@ -1,6 +1,6 @@
import { createLogger } from '@sim/logger'
import { getMaxExecutionTimeout } from '@/lib/core/execution-limits'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { getInternalApiBaseUrl } from '@/lib/core/utils/urls'
import { AGENT, isCustomTool } from '@/executor/constants'
import { getCustomTool } from '@/hooks/queries/custom-tools'
import { useEnvironmentStore } from '@/stores/settings/environment'
@@ -373,7 +373,7 @@ async function fetchCustomToolFromAPI(
const identifier = customToolId.replace('custom_', '')
try {
const baseUrl = getBaseUrl()
const baseUrl = getInternalApiBaseUrl()
const url = new URL('/api/tools/custom', baseUrl)
if (workflowId) {

View File

@@ -120,6 +120,18 @@
"format": "uri",
"description": "Public application URL"
},
"INTERNAL_API_BASE_URL": {
"type": "string",
"anyOf": [
{
"format": "uri"
},
{
"const": ""
}
],
"description": "Optional server-side internal base URL for internal /api self-calls (must include http:// or https://); defaults to NEXT_PUBLIC_APP_URL when unset"
},
"BETTER_AUTH_URL": {
"type": "string",
"format": "uri",

View File

@@ -70,6 +70,7 @@ app:
# Application URLs
NEXT_PUBLIC_APP_URL: "http://localhost:3000"
BETTER_AUTH_URL: "http://localhost:3000"
INTERNAL_API_BASE_URL: "" # Optional server-side internal base URL for /api self-calls (include http:// or https://); falls back to NEXT_PUBLIC_APP_URL when empty
# SOCKET_SERVER_URL: Auto-detected when realtime.enabled=true (uses internal service)
# Only set this if using an external WebSocket service with realtime.enabled=false
NEXT_PUBLIC_SOCKET_URL: "http://localhost:3002" # Public WebSocket URL for browsers