diff --git a/.agents/skills/add-integration/SKILL.md b/.agents/skills/add-integration/SKILL.md index e72dd878bbe..f7bde0c0da1 100644 --- a/.agents/skills/add-integration/SKILL.md +++ b/.agents/skills/add-integration/SKILL.md @@ -872,7 +872,12 @@ export const {service}UploadTool: ToolConfig = { fileContent: { type: 'string', required: false, visibility: 'hidden' }, // Legacy }, request: { - url: '/api/tools/{service}/upload', // Internal route + // Internal route. A static string is a source literal, so the transport trusts it. When the + // path is dynamic, use `internalRoute` from '@/lib/core/utils/internal-route' instead of a template + // string — a builder's plain `/api/...` string is treated as external, because a caller- or + // model-supplied param can produce one: + // url: (params) => internalRoute`/api/tools/{service}/upload/${params.folderId}` + url: '/api/tools/{service}/upload', method: 'POST', body: (params) => ({ accessToken: params.accessToken, diff --git a/.agents/skills/add-tools/SKILL.md b/.agents/skills/add-tools/SKILL.md index 734c03bcd9c..cc92f099f1a 100644 --- a/.agents/skills/add-tools/SKILL.md +++ b/.agents/skills/add-tools/SKILL.md @@ -145,6 +145,37 @@ export const {serviceName}{Action}Tool: ToolConfig< - Always explicitly set `required: true` or `required: false` - Optional params should have `required: false` +## Internal Routes (calling Sim's own API) + +Most tools call a third-party service and `request.url` returns an absolute `https://...` URL. A +tool that instead calls Sim's own API — a `/api/tools/{service}/{action}` proxy route, or a platform +route like `/api/table/...` — must SAY SO, because the transport resolves those against the internal +base URL and signs them with an internal token for the executing user. + +That declaration comes from the tool's source, never from the resolved string: a `user-or-llm` param +can make any tool emit `/api/...` (the HTTP Request tool passes its `url` through verbatim, and a +self-hosted integration with a blank host param collapses `${host}/api/v2/x` to `/api/v2/x`). + +```typescript +import { internalRoute } from '@/lib/core/utils/internal-route' + +// ✓ Static route — a source literal no param can influence +url: '/api/tools/{service}/{action}', + +// ✓ Dynamic route — branded, and every interpolated id is percent-encoded for you +url: (params) => internalRoute`/api/table/${params.tableId}/rows`, + +// ✓ Query params via withQuery (accepts an object or URLSearchParams; skips undefined/null) +url: (params) => internalRoute`/api/logs`.withQuery({ workspaceId, limit: params.limit }), + +// ✗ Treated as EXTERNAL and will fail — a builder's plain string carries no provenance +url: (params) => `/api/table/${params.tableId}/rows`, +``` + +`internalRoute` throws on a path outside `/api/` and on a query string inside the template. Never +write `encodeURIComponent` inside the template — the tag already encodes each `${...}`, so doing it +yourself double-encodes the value. + ## Resolved Secrets and Provenance Boundaries - Leave ordinary external API inputs and third-party results unchanged. Add provenance handling only diff --git a/.agents/skills/validate-integration/SKILL.md b/.agents/skills/validate-integration/SKILL.md index da5ac1dd984..40a164127e3 100644 --- a/.agents/skills/validate-integration/SKILL.md +++ b/.agents/skills/validate-integration/SKILL.md @@ -71,6 +71,15 @@ For **every** tool file, check: - [ ] Tool `description` is a concise one-liner describing what it does - [ ] Tool `version` is set (`'1.0.0'` or `'2.0.0'` for V2) +### Request URL +- [ ] A tool calling a third-party service returns an ABSOLUTE `https://...` URL +- [ ] A tool calling Sim's own API declares it: a static `/api/...` string, or `internalRoute` from + `@/lib/core/utils/internal-route` when the path is dynamic (query params via `.withQuery({...})`) +- [ ] No builder returns a bare `` `/api/...` `` template string — that is treated as external and + will fail, because a `user-or-llm` param can produce the same string +- [ ] No `encodeURIComponent` inside an `internalRoute` template (the tag already encodes, so this + double-encodes the value) + ### Params - [ ] All required API params are marked `required: true` - [ ] All optional API params are marked `required: false` diff --git a/.claude/commands/add-integration.md b/.claude/commands/add-integration.md index 864dc9ab9b3..c8e18fe8de9 100644 --- a/.claude/commands/add-integration.md +++ b/.claude/commands/add-integration.md @@ -871,7 +871,12 @@ export const {service}UploadTool: ToolConfig = { fileContent: { type: 'string', required: false, visibility: 'hidden' }, // Legacy }, request: { - url: '/api/tools/{service}/upload', // Internal route + // Internal route. A static string is a source literal, so the transport trusts it. When the + // path is dynamic, use `internalRoute` from '@/lib/core/utils/internal-route' instead of a template + // string — a builder's plain `/api/...` string is treated as external, because a caller- or + // model-supplied param can produce one: + // url: (params) => internalRoute`/api/tools/{service}/upload/${params.folderId}` + url: '/api/tools/{service}/upload', method: 'POST', body: (params) => ({ accessToken: params.accessToken, diff --git a/.claude/commands/add-tools.md b/.claude/commands/add-tools.md index 6b390520b64..fd62574c70e 100644 --- a/.claude/commands/add-tools.md +++ b/.claude/commands/add-tools.md @@ -144,6 +144,37 @@ export const {serviceName}{Action}Tool: ToolConfig< - Always explicitly set `required: true` or `required: false` - Optional params should have `required: false` +## Internal Routes (calling Sim's own API) + +Most tools call a third-party service and `request.url` returns an absolute `https://...` URL. A +tool that instead calls Sim's own API — a `/api/tools/{service}/{action}` proxy route, or a platform +route like `/api/table/...` — must SAY SO, because the transport resolves those against the internal +base URL and signs them with an internal token for the executing user. + +That declaration comes from the tool's source, never from the resolved string: a `user-or-llm` param +can make any tool emit `/api/...` (the HTTP Request tool passes its `url` through verbatim, and a +self-hosted integration with a blank host param collapses `${host}/api/v2/x` to `/api/v2/x`). + +```typescript +import { internalRoute } from '@/lib/core/utils/internal-route' + +// ✓ Static route — a source literal no param can influence +url: '/api/tools/{service}/{action}', + +// ✓ Dynamic route — branded, and every interpolated id is percent-encoded for you +url: (params) => internalRoute`/api/table/${params.tableId}/rows`, + +// ✓ Query params via withQuery (accepts an object or URLSearchParams; skips undefined/null) +url: (params) => internalRoute`/api/logs`.withQuery({ workspaceId, limit: params.limit }), + +// ✗ Treated as EXTERNAL and will fail — a builder's plain string carries no provenance +url: (params) => `/api/table/${params.tableId}/rows`, +``` + +`internalRoute` throws on a path outside `/api/` and on a query string inside the template. Never +write `encodeURIComponent` inside the template — the tag already encodes each `${...}`, so doing it +yourself double-encodes the value. + ## Resolved Secrets and Provenance Boundaries - Leave ordinary external API inputs and third-party results unchanged. Add provenance handling only diff --git a/.claude/commands/validate-integration.md b/.claude/commands/validate-integration.md index 79276796280..9db397ab5ed 100644 --- a/.claude/commands/validate-integration.md +++ b/.claude/commands/validate-integration.md @@ -70,6 +70,15 @@ For **every** tool file, check: - [ ] Tool `description` is a concise one-liner describing what it does - [ ] Tool `version` is set (`'1.0.0'` or `'2.0.0'` for V2) +### Request URL +- [ ] A tool calling a third-party service returns an ABSOLUTE `https://...` URL +- [ ] A tool calling Sim's own API declares it: a static `/api/...` string, or `internalRoute` from + `@/lib/core/utils/internal-route` when the path is dynamic (query params via `.withQuery({...})`) +- [ ] No builder returns a bare `` `/api/...` `` template string — that is treated as external and + will fail, because a `user-or-llm` param can produce the same string +- [ ] No `encodeURIComponent` inside an `internalRoute` template (the tag already encodes, so this + double-encodes the value) + ### Params - [ ] All required API params are marked `required: true` - [ ] All optional API params are marked `required: false` diff --git a/.claude/rules/sim-integrations.md b/.claude/rules/sim-integrations.md index 0ac54ab9194..e1526e0e9d6 100644 --- a/.claude/rules/sim-integrations.md +++ b/.claude/rules/sim-integrations.md @@ -14,6 +14,7 @@ The full authoring instructions — tool/block/icon/trigger scaffolding, SubBloc ## Hard rules (don't get these wrong) - Tool IDs are `snake_case` (`service_action`). Register tools in `tools/registry.ts`, blocks in `blocks/registry-maps.ts` (the `BLOCK_REGISTRY` config map + `BLOCK_META_REGISTRY` catalog-meta map, alphabetically — `blocks/registry.ts` holds only the accessor functions), triggers in `triggers/registry.ts`. +- A tool that calls Sim's own API declares it: a static `request.url` string (`'/api/tools/{service}/{action}'`), or `` internalRoute`/api/table/${params.tableId}/rows` `` from `@/lib/core/utils/internal-route` when the path is dynamic (query params via `.withQuery({...})`). The transport signs internal requests with the executing user's token, so that decision follows the tool's source, never the resolved string — a builder returning a bare `/api/...` string is treated as EXTERNAL, because a `user-or-llm` param can produce one. `internalRoute` encodes every `${...}`; never add `encodeURIComponent` inside the template. - Type coercions (`Number()`, etc.) belong in `tools.config.params` (runs at execution, after variable resolution) — never in `tools.config.tool` (runs at serialization; coercing there destroys dynamic `` references). - `canonicalParamId` must NOT match any subblock's `id`, must be unique **block-wide** (groups are keyed by canonical id across every subblock and hold exactly one `basicId`, so two operations that each need a pair need two different canonical ids), and all subblocks in a canonical group must share the same `required` status. The `inputs` section and the params function reference canonical IDs, not raw subblock IDs — the serializer deletes the subblock IDs and republishes the active member's value under the canonical ID. - A canonical pair carries ONE concept. For files that is upload (basic) + file reference (advanced), as in Gmail attachments (`blocks/blocks/gmail.ts`). Never overload the advanced side with alternate identifiers (URL, provider asset ID) — give those their own subblocks, mark mutually exclusive sources `required: false`, and enforce "exactly one" at execution. diff --git a/.cursor/commands/add-integration.md b/.cursor/commands/add-integration.md index 40cc28d8b8f..8f7c54e84fa 100644 --- a/.cursor/commands/add-integration.md +++ b/.cursor/commands/add-integration.md @@ -866,7 +866,12 @@ export const {service}UploadTool: ToolConfig = { fileContent: { type: 'string', required: false, visibility: 'hidden' }, // Legacy }, request: { - url: '/api/tools/{service}/upload', // Internal route + // Internal route. A static string is a source literal, so the transport trusts it. When the + // path is dynamic, use `internalRoute` from '@/lib/core/utils/internal-route' instead of a template + // string — a builder's plain `/api/...` string is treated as external, because a caller- or + // model-supplied param can produce one: + // url: (params) => internalRoute`/api/tools/{service}/upload/${params.folderId}` + url: '/api/tools/{service}/upload', method: 'POST', body: (params) => ({ accessToken: params.accessToken, diff --git a/.cursor/commands/add-tools.md b/.cursor/commands/add-tools.md index c8611887dd8..45f078ead06 100644 --- a/.cursor/commands/add-tools.md +++ b/.cursor/commands/add-tools.md @@ -139,6 +139,37 @@ export const {serviceName}{Action}Tool: ToolConfig< - Always explicitly set `required: true` or `required: false` - Optional params should have `required: false` +## Internal Routes (calling Sim's own API) + +Most tools call a third-party service and `request.url` returns an absolute `https://...` URL. A +tool that instead calls Sim's own API — a `/api/tools/{service}/{action}` proxy route, or a platform +route like `/api/table/...` — must SAY SO, because the transport resolves those against the internal +base URL and signs them with an internal token for the executing user. + +That declaration comes from the tool's source, never from the resolved string: a `user-or-llm` param +can make any tool emit `/api/...` (the HTTP Request tool passes its `url` through verbatim, and a +self-hosted integration with a blank host param collapses `${host}/api/v2/x` to `/api/v2/x`). + +```typescript +import { internalRoute } from '@/lib/core/utils/internal-route' + +// ✓ Static route — a source literal no param can influence +url: '/api/tools/{service}/{action}', + +// ✓ Dynamic route — branded, and every interpolated id is percent-encoded for you +url: (params) => internalRoute`/api/table/${params.tableId}/rows`, + +// ✓ Query params via withQuery (accepts an object or URLSearchParams; skips undefined/null) +url: (params) => internalRoute`/api/logs`.withQuery({ workspaceId, limit: params.limit }), + +// ✗ Treated as EXTERNAL and will fail — a builder's plain string carries no provenance +url: (params) => `/api/table/${params.tableId}/rows`, +``` + +`internalRoute` throws on a path outside `/api/` and on a query string inside the template. Never +write `encodeURIComponent` inside the template — the tag already encodes each `${...}`, so doing it +yourself double-encodes the value. + ## Resolved Secrets and Provenance Boundaries - Leave ordinary external API inputs and third-party results unchanged. Add provenance handling only diff --git a/.cursor/commands/validate-integration.md b/.cursor/commands/validate-integration.md index 0c08276a7f1..fd2c5869ba6 100644 --- a/.cursor/commands/validate-integration.md +++ b/.cursor/commands/validate-integration.md @@ -65,6 +65,15 @@ For **every** tool file, check: - [ ] Tool `description` is a concise one-liner describing what it does - [ ] Tool `version` is set (`'1.0.0'` or `'2.0.0'` for V2) +### Request URL +- [ ] A tool calling a third-party service returns an ABSOLUTE `https://...` URL +- [ ] A tool calling Sim's own API declares it: a static `/api/...` string, or `internalRoute` from + `@/lib/core/utils/internal-route` when the path is dynamic (query params via `.withQuery({...})`) +- [ ] No builder returns a bare `` `/api/...` `` template string — that is treated as external and + will fail, because a `user-or-llm` param can produce the same string +- [ ] No `encodeURIComponent` inside an `internalRoute` template (the tag already encodes, so this + double-encodes the value) + ### Params - [ ] All required API params are marked `required: true` - [ ] All optional API params are marked `required: false` diff --git a/.cursor/rules/sim-integrations.mdc b/.cursor/rules/sim-integrations.mdc index ca3ad54a886..f6e5b1ab049 100644 --- a/.cursor/rules/sim-integrations.mdc +++ b/.cursor/rules/sim-integrations.mdc @@ -11,6 +11,7 @@ The full authoring instructions — tool/block/icon/trigger scaffolding, SubBloc ## Hard rules (don't get these wrong) - Tool IDs are `snake_case` (`service_action`). Register tools in `tools/registry.ts`, blocks in `blocks/registry-maps.ts` (the `BLOCK_REGISTRY` config map + `BLOCK_META_REGISTRY` catalog-meta map, alphabetically — `blocks/registry.ts` holds only the accessor functions), triggers in `triggers/registry.ts`. +- A tool that calls Sim's own API declares it: a static `request.url` string (`'/api/tools/{service}/{action}'`), or `` internalRoute`/api/table/${params.tableId}/rows` `` from `@/lib/core/utils/internal-route` when the path is dynamic (query params via `.withQuery({...})`). The transport signs internal requests with the executing user's token, so that decision follows the tool's source, never the resolved string — a builder returning a bare `/api/...` string is treated as EXTERNAL, because a `user-or-llm` param can produce one. `internalRoute` encodes every `${...}`; never add `encodeURIComponent` inside the template. - Type coercions (`Number()`, etc.) belong in `tools.config.params` (runs at execution, after variable resolution) — never in `tools.config.tool` (runs at serialization; coercing there destroys dynamic `` references). - `canonicalParamId` must NOT match any subblock's `id`, must be unique per operation/condition context, and all subblocks in a canonical group must share the same `required` status. The `inputs` section and the params function reference canonical IDs, not raw subblock IDs. - Blocks must also set the catalog/UI metadata fields `integrationType`, `tags`, `authMode`, `docsLink`, and export a `{Service}BlockMeta` — see the `/add-block` skill's BlockMeta section for details. diff --git a/AGENTS.md b/AGENTS.md index f36d633df61..d4dfc871fde 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -461,13 +461,35 @@ Use `@sim/testing` mocks/factories over local test data. New integrations are built in order: **Tools** → **Block** → **Icon** → (optional) **Trigger**. Always look up the service's API docs first. -Two hard rules that the skills assume: +Three hard rules that the skills assume: - **Tool IDs are `snake_case`** (`service_action`) and must be registered in `tools/registry.ts`; blocks register in `blocks/registry.ts` (alphabetically). - **`tools.config.tool` runs during serialization (before variable resolution)** — never do `Number()` or other type coercions there, or dynamic references like `` are destroyed. Put all type coercions in `tools.config.params`, which runs during execution after variables resolve. +- **A tool that calls Sim's own API declares it** — a static `request.url` string (`'/api/tools/{service}/{action}'`), or `internalRoute` from `@/lib/core/utils/internal-route` when the path is dynamic. A builder returning a bare `/api/...` string is treated as EXTERNAL and will fail, because params can produce that string. See "Internal tool routes" below. For the full authoring instructions — SubBlock property tables, `condition`/`dependsOn`/`required`/`mode`/`canonicalParamId` syntax, required block metadata (`integrationType`, `tags`, `authMode`, `docsLink`, `{Service}BlockMeta`), file-input/`normalizeFileInput` patterns, and checklists — use the skills: `/add-integration` (end-to-end), `/add-tools`, `/add-block`, `/add-trigger`. +### Internal tool routes + +The transport resolves a tool request against the internal base URL and signs it with an internal token for the executing user. That decision comes from the tool's source, never from the resolved URL string — a `user-or-llm` param can make any tool emit `/api/...` (the HTTP Request tool passes its `url` through verbatim; a self-hosted integration with a blank host param collapses `${host}/api/v2/x` to `/api/v2/x`). + +```typescript +// ✓ Static route — a source literal no param can influence +url: '/api/tools/{service}/{action}', + +// ✓ Dynamic route — branded, and every interpolated id is encoded for you +import { internalRoute } from '@/lib/core/utils/internal-route' +url: (params) => internalRoute`/api/table/${params.tableId}/rows`, + +// ✓ Query params go through withQuery, not the template +url: (params) => internalRoute`/api/logs`.withQuery({ workspaceId, limit: params.limit }), + +// ✗ Treated as EXTERNAL — a builder's plain string carries no provenance +url: (params) => `/api/table/${params.tableId}/rows`, +``` + +`internalRoute` rejects a path outside `/api/`, rejects a query string in the template, and percent-encodes every `${...}` so an id can fill a segment but never widen the path into another route. Never hand-roll `encodeURIComponent` inside the template — that double-encodes. + ## Tables Table column types are registry entries in `apps/sim/lib/table/column-types/` — one file per type owning its label, icon, storage cast, coercion, validation, conversion compatibility, formatting, and editor. `Record` on `registry.ts` and `registry.server.ts` is a compile-time completeness gate: adding a type to the union errors until both entries exist. diff --git a/CLAUDE.md b/CLAUDE.md index fc63380d153..5001dc37a75 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -478,13 +478,35 @@ Use `@sim/testing` mocks/factories over local test data. New integrations are built in order: **Tools** → **Block** → **Icon** → (optional) **Trigger**. Always look up the service's API docs first. -Two hard rules that the skills assume: +Three hard rules that the skills assume: - **Tool IDs are `snake_case`** (`service_action`) and must be registered in `tools/registry.ts`; blocks register in `blocks/registry-maps.ts` — the `BLOCK_REGISTRY` config map and `BLOCK_META_REGISTRY` catalog-meta map (alphabetically). `blocks/registry.ts` holds only the accessor functions (`getBlock`, `getAllBlocks`, …). - **`tools.config.tool` runs during serialization (before variable resolution)** — never do `Number()` or other type coercions there, or dynamic references like `` are destroyed. Put all type coercions in `tools.config.params`, which runs during execution after variables resolve. +- **A tool that calls Sim's own API declares it** — a static `request.url` string (`'/api/tools/{service}/{action}'`), or `internalRoute` from `@/lib/core/utils/internal-route` when the path is dynamic. A builder returning a bare `/api/...` string is treated as EXTERNAL and will fail, because params can produce that string. See "Internal tool routes" below. For the full authoring instructions — SubBlock property tables, `condition`/`dependsOn`/`required`/`mode`/`canonicalParamId` syntax, required block metadata (`integrationType`, `tags`, `authMode`, `docsLink`, `{Service}BlockMeta`), file-input/`normalizeFileInput` patterns, and checklists — use the skills: `/add-integration` (end-to-end), `/add-tools`, `/add-block`, `/add-trigger`. +### Internal tool routes + +The transport resolves a tool request against the internal base URL and signs it with an internal token for the executing user. That decision comes from the tool's source, never from the resolved URL string — a `user-or-llm` param can make any tool emit `/api/...` (the HTTP Request tool passes its `url` through verbatim; a self-hosted integration with a blank host param collapses `${host}/api/v2/x` to `/api/v2/x`). + +```typescript +// ✓ Static route — a source literal no param can influence +url: '/api/tools/{service}/{action}', + +// ✓ Dynamic route — branded, and every interpolated id is encoded for you +import { internalRoute } from '@/lib/core/utils/internal-route' +url: (params) => internalRoute`/api/table/${params.tableId}/rows`, + +// ✓ Query params go through withQuery, not the template +url: (params) => internalRoute`/api/logs`.withQuery({ workspaceId, limit: params.limit }), + +// ✗ Treated as EXTERNAL — a builder's plain string carries no provenance +url: (params) => `/api/table/${params.tableId}/rows`, +``` + +`internalRoute` rejects a path outside `/api/`, rejects a query string in the template, and percent-encodes every `${...}` so an id can fill a segment but never widen the path into another route. Never hand-roll `encodeURIComponent` inside the template — that double-encodes. + ## Tables Table column types are registry entries in `apps/sim/lib/table/column-types/` — one file per type owning its label, icon, storage cast, coercion, validation, conversion compatibility, formatting, and editor. `Record` on `registry.ts` and `registry.server.ts` is a compile-time completeness gate: adding a type to the union errors until both entries exist. diff --git a/apps/sim/app/api/copilot/checkpoints/revert/route.ts b/apps/sim/app/api/copilot/checkpoints/revert/route.ts index f784dc48d84..e289ff80db3 100644 --- a/apps/sim/app/api/copilot/checkpoints/revert/route.ts +++ b/apps/sim/app/api/copilot/checkpoints/revert/route.ts @@ -15,9 +15,10 @@ import { createRequestTracker, createUnauthorizedResponse, } from '@/lib/copilot/request/http' -import { getInternalApiBaseUrl } from '@/lib/core/utils/urls' +import { internalRoute } from '@/lib/core/utils/internal-route' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { isUuidV4 } from '@/executor/constants' +import { buildInternalApiUrl } from '@/executor/utils/http' const logger = createLogger('CheckpointRevertAPI') @@ -122,7 +123,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } const stateResponse = await fetch( - `${getInternalApiBaseUrl()}/api/workflows/${checkpoint.workflowId}/state`, + buildInternalApiUrl(internalRoute`/api/workflows/${checkpoint.workflowId}/state`).toString(), { method: 'PUT', headers: { diff --git a/apps/sim/app/api/mcp/serve/[serverId]/route.ts b/apps/sim/app/api/mcp/serve/[serverId]/route.ts index 5f185a5d6ec..ae03b280de4 100644 --- a/apps/sim/app/api/mcp/serve/[serverId]/route.ts +++ b/apps/sim/app/api/mcp/serve/[serverId]/route.ts @@ -45,6 +45,7 @@ import { serializeBillingAttributionHeader, } from '@/lib/billing/core/billing-attribution' import { getMaxExecutionTimeout } from '@/lib/core/execution-limits' +import { internalRoute } from '@/lib/core/utils/internal-route' import { assertContentLengthWithinLimit, assertKnownSizeWithinLimit, @@ -52,7 +53,6 @@ import { readResponseTextWithLimit, readStreamToBufferWithLimit, } from '@/lib/core/utils/stream-limits' -import { getInternalApiBaseUrl } from '@/lib/core/utils/urls' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { SIM_VIA_HEADER } from '@/lib/execution/call-chain' import { @@ -72,6 +72,7 @@ import { } from '@/lib/mcp/constants' import { getMeaningfulWorkflowDescription } from '@/lib/mcp/workflow-tool-schema' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' +import { buildInternalApiUrl } from '@/executor/utils/http' import { projectResolvedSecretModelContent } from '@/executor/utils/resolved-secret-content-projection' import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' @@ -881,7 +882,9 @@ async function handleToolsCall( wf.workspaceId ) - const executeUrl = `${getInternalApiBaseUrl()}/api/workflows/${tool.workflowId}/execute` + const executeUrl = buildInternalApiUrl( + internalRoute`/api/workflows/${tool.workflowId}/execute` + ).toString() const headers: Record = { 'Content-Type': 'application/json', [BILLING_ATTRIBUTION_HEADER]: serializeBillingAttributionHeader(billingAttribution), diff --git a/apps/sim/app/workspace/[workspaceId]/home/prefetch.ts b/apps/sim/app/workspace/[workspaceId]/home/prefetch.ts index f08791c0bbd..9a9ecc13886 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/prefetch.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/prefetch.ts @@ -1,6 +1,7 @@ import type { QueryClient } from '@tanstack/react-query' import type { FolderApi } from '@/lib/api/contracts' import type { ListWorkspaceFilesResponse } from '@/lib/api/contracts/workspace-files' +import { internalRoute } from '@/lib/core/utils/internal-route' import { prefetchInternalJson } from '@/app/workspace/[workspaceId]/lib/prefetch-internal-fetch' import { FOLDER_LIST_STALE_TIME, folderKeys, mapFolder } from '@/hooks/queries/utils/folder-keys' import { @@ -30,7 +31,11 @@ export async function prefetchHomeLists( queryKey: folderKeys.list(workspaceId, 'active', 'workflow'), queryFn: async () => { const { folders } = await prefetchInternalJson<{ folders?: FolderApi[] }>( - `/api/folders?workspaceId=${workspaceId}&scope=active&resourceType=workflow` + internalRoute`/api/folders`.withQuery({ + workspaceId, + scope: 'active', + resourceType: 'workflow', + }) ) return (folders ?? []).map(mapFolder) }, @@ -40,7 +45,7 @@ export async function prefetchHomeLists( queryKey: workspaceFilesKeys.list(workspaceId, 'active'), queryFn: async () => { const data = await prefetchInternalJson( - `/api/workspaces/${workspaceId}/files?scope=active` + internalRoute`/api/workspaces/${workspaceId}/files`.withQuery({ scope: 'active' }) ) return data.success ? data.files : [] }, diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/prefetch.ts b/apps/sim/app/workspace/[workspaceId]/knowledge/prefetch.ts index 7c9d45cb668..247bfb14139 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/prefetch.ts +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/prefetch.ts @@ -1,6 +1,7 @@ import type { QueryClient } from '@tanstack/react-query' import type { FolderApi } from '@/lib/api/contracts/folders' import type { KnowledgeBaseData } from '@/lib/api/contracts/knowledge' +import { internalRoute } from '@/lib/core/utils/internal-route' import { prefetchInternalJson } from '@/app/workspace/[workspaceId]/lib/prefetch-internal-fetch' import { prefetchResourceListChrome } from '@/app/workspace/[workspaceId]/lib/prefetch-resource-list-chrome' import { FOLDER_LIST_STALE_TIME, folderKeys, mapFolder } from '@/hooks/queries/utils/folder-keys' @@ -29,7 +30,7 @@ export async function prefetchKnowledgeBases( queryKey: knowledgeKeys.list(workspaceId, 'active'), queryFn: async () => { const result = await prefetchInternalJson<{ data: KnowledgeBaseData[] }>( - `/api/knowledge?workspaceId=${workspaceId}&scope=active` + internalRoute`/api/knowledge`.withQuery({ workspaceId, scope: 'active' }) ) return result.data }, @@ -39,7 +40,11 @@ export async function prefetchKnowledgeBases( queryKey: folderKeys.list(workspaceId, 'active', 'knowledge_base'), queryFn: async () => { const { folders } = await prefetchInternalJson<{ folders?: FolderApi[] }>( - `/api/folders?workspaceId=${workspaceId}&scope=active&resourceType=knowledge_base` + internalRoute`/api/folders`.withQuery({ + workspaceId, + scope: 'active', + resourceType: 'knowledge_base', + }) ) return (folders ?? []).map(mapFolder) }, diff --git a/apps/sim/app/workspace/[workspaceId]/lib/prefetch-internal-fetch.ts b/apps/sim/app/workspace/[workspaceId]/lib/prefetch-internal-fetch.ts index 4ba194395e6..38bc435d53d 100644 --- a/apps/sim/app/workspace/[workspaceId]/lib/prefetch-internal-fetch.ts +++ b/apps/sim/app/workspace/[workspaceId]/lib/prefetch-internal-fetch.ts @@ -1,4 +1,5 @@ import { headers } from 'next/headers' +import type { InternalRoute } from '@/lib/core/utils/internal-route' import { getInternalApiBaseUrl } from '@/lib/core/utils/urls' /** @@ -12,14 +13,14 @@ import { getInternalApiBaseUrl } from '@/lib/core/utils/urls' * still on this helper have not been converted; a converted one must prove the * viewer itself, since the route's own authorization no longer runs. */ -export async function prefetchInternalJson(path: string): Promise { +export async function prefetchInternalJson(route: InternalRoute): Promise { const cookie = (await headers()).get('cookie') // boundary-raw-fetch: server-side RSC prefetch forwarding the session cookie to an internal API route; requestJson is client-only and cannot run here - const response = await fetch(`${getInternalApiBaseUrl()}${path}`, { + const response = await fetch(`${getInternalApiBaseUrl()}${route.path}`, { headers: cookie ? { cookie } : {}, }) if (!response.ok) { - throw new Error(`Prefetch failed for ${path}: ${response.status}`) + throw new Error(`Prefetch failed for ${route.path}: ${response.status}`) } return response.json() as Promise } diff --git a/apps/sim/app/workspace/[workspaceId]/lib/prefetch-resource-list-chrome.ts b/apps/sim/app/workspace/[workspaceId]/lib/prefetch-resource-list-chrome.ts index 5d9241aa23f..90aa720a8d0 100644 --- a/apps/sim/app/workspace/[workspaceId]/lib/prefetch-resource-list-chrome.ts +++ b/apps/sim/app/workspace/[workspaceId]/lib/prefetch-resource-list-chrome.ts @@ -1,5 +1,6 @@ import type { QueryClient } from '@tanstack/react-query' import type { PinnedItemApi, PinnedResourceType } from '@/lib/api/contracts/pinned-items' +import { internalRoute } from '@/lib/core/utils/internal-route' import { prefetchInternalJson } from '@/app/workspace/[workspaceId]/lib/prefetch-internal-fetch' import { PINNED_ITEMS_STALE_TIME, pinnedItemKeys } from '@/hooks/queries/utils/pinned-item-keys' import { @@ -30,7 +31,7 @@ export async function prefetchResourceListChrome( queryKey: pinnedItemKeys.list(workspaceId, type), queryFn: async () => { const { pinnedItems } = await prefetchInternalJson<{ pinnedItems: PinnedItemApi[] }>( - `/api/pinned-items?workspaceId=${workspaceId}&resourceType=${type}` + internalRoute`/api/pinned-items`.withQuery({ workspaceId, resourceType: type }) ) return pinnedItems }, @@ -44,7 +45,7 @@ export async function prefetchResourceListChrome( queryKey: workspaceKeys.members(workspaceId), queryFn: async () => { const { members } = await prefetchInternalJson<{ members: WorkspaceMember[] }>( - `/api/workspaces/${workspaceId}/members` + internalRoute`/api/workspaces/${workspaceId}/members` ) return members }, diff --git a/apps/sim/app/workspace/[workspaceId]/lib/prefetch.test.ts b/apps/sim/app/workspace/[workspaceId]/lib/prefetch.test.ts index 7d701d22066..b0cdc5506c4 100644 --- a/apps/sim/app/workspace/[workspaceId]/lib/prefetch.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/lib/prefetch.test.ts @@ -53,6 +53,11 @@ function makeClient() { return new QueryClient({ defaultOptions: { queries: { retry: false } } }) } +/** Prefetches now pass a branded InternalRoute; assert on the relative path it resolves to. */ +function prefetchedPaths(): string[] { + return mockPrefetchInternalJson.mock.calls.map(([route]) => (route as { path: string }).path) +} + describe('workspace list prefetches', () => { beforeEach(() => { vi.clearAllMocks() @@ -69,9 +74,7 @@ describe('workspace list prefetches', () => { await prefetchKnowledgeBases(client, WORKSPACE_ID) - expect(mockPrefetchInternalJson).toHaveBeenCalledWith( - `/api/knowledge?workspaceId=${WORKSPACE_ID}&scope=active` - ) + expect(prefetchedPaths()).toContain(`/api/knowledge?workspaceId=${WORKSPACE_ID}&scope=active`) expect(client.getQueryData(knowledgeKeys.list(WORKSPACE_ID, 'active'))).toEqual(bases) }) }) @@ -84,9 +87,7 @@ describe('workspace list prefetches', () => { await prefetchTables(client, WORKSPACE_ID) - expect(mockPrefetchInternalJson).toHaveBeenCalledWith( - `/api/table?workspaceId=${WORKSPACE_ID}&scope=active` - ) + expect(prefetchedPaths()).toContain(`/api/table?workspaceId=${WORKSPACE_ID}&scope=active`) expect(client.getQueryData(tableKeys.list(WORKSPACE_ID, 'active'))).toEqual(tables) }) }) @@ -152,7 +153,8 @@ describe('workspace list prefetches', () => { it(`primes pinned ids (${resourceType} + folder) and members for ${name}`, async () => { const pinnedItems = [{ id: 'p-1', resourceId: 'r-1' }] const members = [{ userId: 'u-1', name: 'Ada' }] - mockPrefetchInternalJson.mockImplementation(async (path: string) => { + mockPrefetchInternalJson.mockImplementation(async (route: { path: string }) => { + const { path } = route if (path.startsWith('/api/pinned-items')) return { pinnedItems } if (path.endsWith('/members')) return { members } if (path.includes('/folders')) return { folders: [] } @@ -162,15 +164,13 @@ describe('workspace list prefetches', () => { await run(client) - expect(mockPrefetchInternalJson).toHaveBeenCalledWith( + expect(prefetchedPaths()).toContain( `/api/pinned-items?workspaceId=${WORKSPACE_ID}&resourceType=${resourceType}` ) - expect(mockPrefetchInternalJson).toHaveBeenCalledWith( + expect(prefetchedPaths()).toContain( `/api/pinned-items?workspaceId=${WORKSPACE_ID}&resourceType=folder` ) - expect(mockPrefetchInternalJson).toHaveBeenCalledWith( - `/api/workspaces/${WORKSPACE_ID}/members` - ) + expect(prefetchedPaths()).toContain(`/api/workspaces/${WORKSPACE_ID}/members`) expect(client.getQueryData(pinnedItemKeys.list(WORKSPACE_ID, resourceType))).toEqual( pinnedItems ) @@ -198,14 +198,14 @@ describe('workspace list prefetches', () => { deletedAt: null, } const files = [{ id: 'f-1' }] - mockPrefetchInternalJson.mockImplementation(async (path: string) => - path.startsWith('/api/folders') ? { folders: [folderRow] } : { success: true, files } + mockPrefetchInternalJson.mockImplementation(async (route: { path: string }) => + route.path.startsWith('/api/folders') ? { folders: [folderRow] } : { success: true, files } ) const client = makeClient() await prefetchHomeLists(client, WORKSPACE_ID) - expect(mockPrefetchInternalJson).toHaveBeenCalledWith( + expect(prefetchedPaths()).toContain( `/api/folders?workspaceId=${WORKSPACE_ID}&scope=active&resourceType=workflow` ) const cachedFolders = client.getQueryData(folderKeys.list(WORKSPACE_ID, 'active')) as Array<{ diff --git a/apps/sim/app/workspace/[workspaceId]/tables/prefetch.ts b/apps/sim/app/workspace/[workspaceId]/tables/prefetch.ts index 5a548885511..9d490c6259b 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/prefetch.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/prefetch.ts @@ -1,5 +1,6 @@ import type { QueryClient } from '@tanstack/react-query' import type { FolderApi } from '@/lib/api/contracts/folders' +import { internalRoute } from '@/lib/core/utils/internal-route' import type { TableDefinition } from '@/lib/table' import { prefetchInternalJson } from '@/app/workspace/[workspaceId]/lib/prefetch-internal-fetch' import { prefetchResourceListChrome } from '@/app/workspace/[workspaceId]/lib/prefetch-resource-list-chrome' @@ -25,7 +26,7 @@ export async function prefetchTables(queryClient: QueryClient, workspaceId: stri queryKey: tableKeys.list(workspaceId, 'active'), queryFn: async () => { const response = await prefetchInternalJson<{ data: { tables: TableDefinition[] } }>( - `/api/table?workspaceId=${workspaceId}&scope=active` + internalRoute`/api/table`.withQuery({ workspaceId, scope: 'active' }) ) return response.data.tables }, @@ -35,7 +36,11 @@ export async function prefetchTables(queryClient: QueryClient, workspaceId: stri queryKey: folderKeys.list(workspaceId, 'active', 'table'), queryFn: async () => { const { folders } = await prefetchInternalJson<{ folders?: FolderApi[] }>( - `/api/folders?workspaceId=${workspaceId}&scope=active&resourceType=table` + internalRoute`/api/folders`.withQuery({ + workspaceId, + scope: 'active', + resourceType: 'table', + }) ) return (folders ?? []).map(mapFolder) }, diff --git a/apps/sim/executor/handlers/agent/agent-handler.test.ts b/apps/sim/executor/handlers/agent/agent-handler.test.ts index 7c13d17db5f..c3dbfbdc2ff 100644 --- a/apps/sim/executor/handlers/agent/agent-handler.test.ts +++ b/apps/sim/executor/handlers/agent/agent-handler.test.ts @@ -91,17 +91,9 @@ vi.mock('@/providers', () => ({ vi.mock('@/executor/utils/http', () => ({ buildAuthHeaders: vi.fn().mockResolvedValue({ 'Content-Type': 'application/json' }), - buildAPIUrl: vi.fn((path: string, params?: Record) => { - const url = new URL(path, 'http://localhost:3000') - if (params) { - for (const [key, value] of Object.entries(params)) { - if (value !== undefined && value !== null) { - url.searchParams.set(key, value) - } - } - } - return url - }), + buildInternalApiUrl: vi.fn( + (route: { path: string }) => new URL(route.path, 'http://localhost:3000') + ), extractAPIErrorMessage: vi.fn(async (response: Response) => { const defaultMessage = `API request failed with status ${response.status}` try { diff --git a/apps/sim/executor/handlers/agent/agent-handler.ts b/apps/sim/executor/handlers/agent/agent-handler.ts index d1e972180da..4b4c1692e74 100644 --- a/apps/sim/executor/handlers/agent/agent-handler.ts +++ b/apps/sim/executor/handlers/agent/agent-handler.ts @@ -6,6 +6,7 @@ import { sleep } from '@sim/utils/helpers' import { isPlainRecord } from '@sim/utils/object' import { truncate } from '@sim/utils/string' import { and, eq, inArray, isNull } from 'drizzle-orm' +import { internalRoute } from '@/lib/core/utils/internal-route' import { normalizeStringRecord, normalizeWorkflowVariables } from '@/lib/core/utils/records' import { projectModelSchemaAnnotations, @@ -59,7 +60,7 @@ import type { import { parseResponseFormat } from '@/executor/handlers/shared/response-format' import type { BlockHandler, ExecutionContext, StreamingExecution } from '@/executor/types' import { collectBlockData } from '@/executor/utils/block-data' -import { buildAPIUrl, buildAuthHeaders } from '@/executor/utils/http' +import { buildAuthHeaders, buildInternalApiUrl } from '@/executor/utils/http' import { stringifyJSON } from '@/executor/utils/json' import { projectResolvedSecretDiagnosticContent } from '@/executor/utils/resolved-secret-content-projection' import { prepareResolvedSecretProjectedInputs } from '@/executor/utils/resolved-secret-input-projection' @@ -1212,12 +1213,14 @@ export class AgentBlockHandler implements BlockHandler { } const headers = await buildAuthHeaders(ctx.userId) - const url = buildAPIUrl('/api/mcp/tools/discover', { - serverId, - workspaceId: ctx.workspaceId, - workflowId: ctx.workflowId, - ...(ctx.userId ? { userId: ctx.userId } : {}), - }) + const url = buildInternalApiUrl( + internalRoute`/api/mcp/tools/discover`.withQuery({ + serverId, + workspaceId: ctx.workspaceId, + workflowId: ctx.workflowId, + userId: ctx.userId || undefined, + }) + ) const maxAttempts = 2 for (let attempt = 0; attempt < maxAttempts; attempt++) { diff --git a/apps/sim/executor/handlers/evaluator/evaluator-handler.ts b/apps/sim/executor/handlers/evaluator/evaluator-handler.ts index 16ba1c8e831..a11f8966c1e 100644 --- a/apps/sim/executor/handlers/evaluator/evaluator-handler.ts +++ b/apps/sim/executor/handlers/evaluator/evaluator-handler.ts @@ -1,4 +1,5 @@ import { createLogger } from '@sim/logger' +import { internalRoute } from '@/lib/core/utils/internal-route' import { addModelInputProvenanceToRequest, createModelInputProvenanceRequestMetadata, @@ -15,7 +16,11 @@ import type { BlockOutput } from '@/blocks/types' import { validateModelProvider } from '@/ee/access-control/utils/permission-check' import { BlockType, DEFAULTS, EVALUATOR } from '@/executor/constants' import type { BlockHandler, ExecutionContext } from '@/executor/types' -import { buildAPIUrl, buildAuthHeaders, extractAPIErrorMessage } from '@/executor/utils/http' +import { + buildAuthHeaders, + buildInternalApiUrl, + extractAPIErrorMessage, +} from '@/executor/utils/http' import { isJSONString, parseJSON, stringifyJSON } from '@/executor/utils/json' import { projectResolvedSecretDiagnosticError } from '@/executor/utils/resolved-secret-content-projection' import type { @@ -186,7 +191,9 @@ export class EvaluatorBlockHandler implements BlockHandler { } try { - const url = buildAPIUrl('/api/providers', ctx.userId ? { userId: ctx.userId } : {}) + const url = buildInternalApiUrl( + internalRoute`/api/providers`.withQuery({ userId: ctx.userId || undefined }) + ) const providerRequest: ProviderRequest = { model, diff --git a/apps/sim/executor/handlers/mothership/mothership-handler.test.ts b/apps/sim/executor/handlers/mothership/mothership-handler.test.ts index 6c9a752ec05..a8ab94f152f 100644 --- a/apps/sim/executor/handlers/mothership/mothership-handler.test.ts +++ b/apps/sim/executor/handlers/mothership/mothership-handler.test.ts @@ -32,7 +32,7 @@ const PRIVATE_PROVENANCE = { const { mockAreModelSafeWorkspaceFileKeys, mockBuildAuthHeaders, - mockBuildAPIUrl, + mockBuildInternalApiUrl, mockExtractAPIErrorMessage, mockGenerateId, mockIsExecutionCancelled, @@ -41,7 +41,7 @@ const { } = vi.hoisted(() => ({ mockAreModelSafeWorkspaceFileKeys: vi.fn(), mockBuildAuthHeaders: vi.fn(), - mockBuildAPIUrl: vi.fn(), + mockBuildInternalApiUrl: vi.fn(), mockExtractAPIErrorMessage: vi.fn(), mockGenerateId: vi.fn(), mockIsExecutionCancelled: vi.fn(), @@ -57,7 +57,7 @@ vi.mock('@/lib/uploads/contexts/workspace/workspace-file-secret-provenance', () vi.mock('@/executor/utils/http', () => ({ buildAuthHeaders: mockBuildAuthHeaders, - buildAPIUrl: mockBuildAPIUrl, + buildInternalApiUrl: mockBuildInternalApiUrl, extractAPIErrorMessage: mockExtractAPIErrorMessage, })) @@ -155,7 +155,9 @@ describe('MothershipBlockHandler', () => { vi.stubGlobal('fetch', fetchMock) mockBuildAuthHeaders.mockResolvedValue({ Authorization: 'Bearer internal' }) - mockBuildAPIUrl.mockReturnValue(new URL('/api/mothership/execute', 'http://localhost:3000')) + mockBuildInternalApiUrl.mockReturnValue( + new URL('/api/mothership/execute', 'http://localhost:3000') + ) mockExtractAPIErrorMessage.mockResolvedValue('boom') mockGenerateId.mockReset() mockIsExecutionCancelled.mockReset() diff --git a/apps/sim/executor/handlers/mothership/mothership-handler.ts b/apps/sim/executor/handlers/mothership/mothership-handler.ts index 1c77fdaccb7..bc43ef65a92 100644 --- a/apps/sim/executor/handlers/mothership/mothership-handler.ts +++ b/apps/sim/executor/handlers/mothership/mothership-handler.ts @@ -8,6 +8,7 @@ import { } from '@/lib/billing/core/billing-attribution' import { normalizeSecretMountPolicy } from '@/lib/copilot/secret-mount-policy' import { env } from '@/lib/core/config/env' +import { internalRoute } from '@/lib/core/utils/internal-route' import { isExecutionCancelled, isRedisCancellationEnabled } from '@/lib/execution/cancellation' import { projectModelSchemaAnnotations, @@ -42,7 +43,11 @@ import type { NormalizedBlockOutput, StreamingExecution, } from '@/executor/types' -import { buildAPIUrl, buildAuthHeaders, extractAPIErrorMessage } from '@/executor/utils/http' +import { + buildAuthHeaders, + buildInternalApiUrl, + extractAPIErrorMessage, +} from '@/executor/utils/http' import type { ResolvedSecretInputPath, ResolvedSecretTraceRegistry, @@ -796,7 +801,7 @@ export class MothershipBlockHandler implements BlockHandler { requestId ) - const url = buildAPIUrl('/api/mothership/execute') + const url = buildInternalApiUrl(internalRoute`/api/mothership/execute`) const headers = await buildAuthHeaders(ctx.userId) headers.Accept = 'application/x-ndjson' headers[MOTHERSHIP_EXECUTE_STREAM_HEADER] = MOTHERSHIP_EXECUTE_STREAM_VALUE diff --git a/apps/sim/executor/handlers/router/router-handler.ts b/apps/sim/executor/handlers/router/router-handler.ts index 365453e64db..a1092fb2231 100644 --- a/apps/sim/executor/handlers/router/router-handler.ts +++ b/apps/sim/executor/handlers/router/router-handler.ts @@ -1,5 +1,5 @@ import { createLogger } from '@sim/logger' -import { getInternalApiBaseUrl } from '@/lib/core/utils/urls' +import { internalRoute } from '@/lib/core/utils/internal-route' import { addModelInputProvenanceToRequest, createModelInputProvenanceRequestMetadata, @@ -23,7 +23,7 @@ import { ROUTER, } from '@/executor/constants' import type { BlockHandler, ExecutionContext } from '@/executor/types' -import { buildAuthHeaders } from '@/executor/utils/http' +import { buildAuthHeaders, buildInternalApiUrl } from '@/executor/utils/http' import type { ResolvedSecretInputPath } from '@/executor/utils/resolved-secret-trace-registry' import { resolveVertexCredential } from '@/executor/utils/vertex-credential' import { resolveProxiedModelCost } from '@/providers/cost-policy' @@ -97,8 +97,9 @@ export class RouterBlockHandler implements BlockHandler { } try { - const url = new URL('/api/providers', getInternalApiBaseUrl()) - if (ctx.userId) url.searchParams.set('userId', ctx.userId) + const url = buildInternalApiUrl( + internalRoute`/api/providers`.withQuery({ userId: ctx.userId || undefined }) + ) const messages = [{ role: 'user', content: routerConfig.prompt }] const systemPrompt = generateRouterPrompt(routerConfig.prompt, targetBlocks) @@ -275,8 +276,9 @@ export class RouterBlockHandler implements BlockHandler { } try { - const url = new URL('/api/providers', getInternalApiBaseUrl()) - if (ctx.userId) url.searchParams.set('userId', ctx.userId) + const url = buildInternalApiUrl( + internalRoute`/api/providers`.withQuery({ userId: ctx.userId || undefined }) + ) const messages = [{ role: 'user', content: routerConfig.context }] const systemPrompt = generateRouterV2Prompt(routerConfig.context, modelRoutes) diff --git a/apps/sim/executor/handlers/workflow/workflow-handler.test.ts b/apps/sim/executor/handlers/workflow/workflow-handler.test.ts index 1f50fd48702..1a926e23b19 100644 --- a/apps/sim/executor/handlers/workflow/workflow-handler.test.ts +++ b/apps/sim/executor/handlers/workflow/workflow-handler.test.ts @@ -183,7 +183,9 @@ vi.mock('@/lib/auth/internal', () => ({ vi.mock('@/executor/utils/http', () => ({ buildAuthHeaders: vi.fn().mockResolvedValue({ 'Content-Type': 'application/json' }), - buildAPIUrl: vi.fn((path: string) => new URL(path, 'http://localhost:3000')), + buildInternalApiUrl: vi.fn( + (route: { path: string }) => new URL(route.path, 'http://localhost:3000') + ), extractAPIErrorMessage: vi.fn(async (response: Response) => { const defaultMessage = `API request failed with status ${response.status}` try { diff --git a/apps/sim/executor/handlers/workflow/workflow-handler.ts b/apps/sim/executor/handlers/workflow/workflow-handler.ts index e795c2b99e9..8211f4bf30f 100644 --- a/apps/sim/executor/handlers/workflow/workflow-handler.ts +++ b/apps/sim/executor/handlers/workflow/workflow-handler.ts @@ -4,6 +4,7 @@ import { generateId } from '@sim/utils/id' import { isRecordLike } from '@sim/utils/object' import { resolveBillingAttribution } from '@/lib/billing/core/billing-attribution' import { getExecutionDeadlineAt } from '@/lib/core/execution-limits' +import { internalRoute } from '@/lib/core/utils/internal-route' import { getPersonalAndWorkspaceEnv } from '@/lib/environment/utils' import { buildNextCallChain, validateCallChain } from '@/lib/execution/call-chain' import { LoggingSession } from '@/lib/logs/execution/logging-session' @@ -42,7 +43,7 @@ import { type StreamingExecution, } from '@/executor/types' import { hasExecutionResult } from '@/executor/utils/errors' -import { buildAPIUrl, buildAuthHeaders } from '@/executor/utils/http' +import { buildAuthHeaders, buildInternalApiUrl } from '@/executor/utils/http' import { getIterationContext } from '@/executor/utils/iteration-context' import { parseJSON } from '@/executor/utils/json' import { lazyCleanupInputMapping } from '@/executor/utils/lazy-cleanup' @@ -952,7 +953,7 @@ export class WorkflowBlockHandler implements BlockHandler { private async loadChildWorkflow(workflowId: string, userId?: string) { const headers = await buildAuthHeaders(userId) - const url = buildAPIUrl(`/api/workflows/${workflowId}`) + const url = buildInternalApiUrl(internalRoute`/api/workflows/${workflowId}`) const response = await fetch(url.toString(), { headers }) @@ -1015,7 +1016,7 @@ export class WorkflowBlockHandler implements BlockHandler { private async checkChildDeployment(workflowId: string, userId?: string): Promise { try { const headers = await buildAuthHeaders(userId) - const url = buildAPIUrl(`/api/workflows/${workflowId}/deployed`) + const url = buildInternalApiUrl(internalRoute`/api/workflows/${workflowId}/deployed`) const response = await fetch(url.toString(), { headers, @@ -1037,7 +1038,7 @@ export class WorkflowBlockHandler implements BlockHandler { private async loadChildWorkflowDeployed(workflowId: string, userId?: string) { const headers = await buildAuthHeaders(userId) - const deployedUrl = buildAPIUrl(`/api/workflows/${workflowId}/deployed`) + const deployedUrl = buildInternalApiUrl(internalRoute`/api/workflows/${workflowId}/deployed`) const deployedRes = await fetch(deployedUrl.toString(), { headers, @@ -1058,7 +1059,7 @@ export class WorkflowBlockHandler implements BlockHandler { throw new Error(`Deployed state missing or invalid for child workflow ${workflowId}`) } - const metaUrl = buildAPIUrl(`/api/workflows/${workflowId}`) + const metaUrl = buildInternalApiUrl(internalRoute`/api/workflows/${workflowId}`) const metaRes = await fetch(metaUrl.toString(), { headers, cache: 'no-store', diff --git a/apps/sim/executor/utils/http.ts b/apps/sim/executor/utils/http.ts index 57ea632a41b..9c85812c4f4 100644 --- a/apps/sim/executor/utils/http.ts +++ b/apps/sim/executor/utils/http.ts @@ -1,5 +1,6 @@ import { generateInternalToken } from '@/lib/auth/internal' -import { getBaseUrl, getInternalApiBaseUrl } from '@/lib/core/utils/urls' +import type { InternalRoute } from '@/lib/core/utils/internal-route' +import { getInternalApiBaseUrl } from '@/lib/core/utils/urls' import { HTTP } from '@/executor/constants' export async function buildAuthHeaders(userId?: string): Promise> { @@ -15,19 +16,16 @@ export async function buildAuthHeaders(userId?: string): Promise): URL { - const baseUrl = path.startsWith('/api/') ? getInternalApiBaseUrl() : getBaseUrl() - const url = new URL(path, baseUrl) - - if (params) { - for (const [key, value] of Object.entries(params)) { - if (value !== undefined && value !== null) { - url.searchParams.set(key, value) - } - } - } - - return url +/** + * Resolves a declared internal route against the internal base URL. + * + * Callers pair this with {@link buildAuthHeaders}, so the request carries an internal token — which + * is why the route must be an {@link InternalRoute} rather than a string. The brand can only come + * from an `internalRoute` template, whose literal segments are fixed at author time and whose + * interpolated ids are percent-encoded, so an id can never widen the path into a different route. + */ +export function buildInternalApiUrl(route: InternalRoute): URL { + return new URL(route.path, getInternalApiBaseUrl()) } export async function extractAPIErrorMessage(response: Response): Promise { diff --git a/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts b/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts index 843a61e5794..6812ab79683 100644 --- a/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts +++ b/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts @@ -21,6 +21,7 @@ import { type BaseServerTool, type ServerToolContext, } from '@/lib/copilot/tools/server/base-tool' +import { type InternalRoute, internalRoute } from '@/lib/core/utils/internal-route' import { getInternalApiBaseUrl } from '@/lib/core/utils/urls' import { KNOWLEDGE_TAG_DISPLAY_NAME_MAX_LENGTH } from '@/lib/knowledge/constants' import { @@ -971,7 +972,7 @@ export const knowledgeBaseServerTool: BaseServerTool, billingAttribution?: BillingAttributionSnapshot @@ -1175,7 +1176,7 @@ async function connectorApiCall( const token = await generateInternalToken(userId) const baseUrl = getInternalApiBaseUrl() - const res = await fetch(`${baseUrl}${path}`, { + const res = await fetch(`${baseUrl}${route.path}`, { method, headers: { 'Content-Type': 'application/json', diff --git a/apps/sim/lib/core/utils/internal-route.test.ts b/apps/sim/lib/core/utils/internal-route.test.ts new file mode 100644 index 00000000000..be4e041d80e --- /dev/null +++ b/apps/sim/lib/core/utils/internal-route.test.ts @@ -0,0 +1,70 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { InternalRoute, internalRoute } from '@/lib/core/utils/internal-route' + +describe('internalRoute', () => { + it('builds a route from its literal segments', () => { + expect(internalRoute`/api/table/${'t-1'}/rows`.path).toBe('/api/table/t-1/rows') + }) + + it('encodes an interpolated value so it cannot widen the path', () => { + expect(internalRoute`/api/table/${'../../admin'}/rows`.path).toBe( + '/api/table/..%2F..%2Fadmin/rows' + ) + }) + + it('encodes a query-shaped value instead of letting it add params', () => { + expect(internalRoute`/api/table/${'t-1?workspaceId=other'}`.path).toBe( + '/api/table/t-1%3FworkspaceId%3Dother' + ) + }) + + it('rejects a route outside /api/', () => { + expect(() => internalRoute`/health`).toThrow(/must start with \/api\//) + }) + + it('rejects an interpolated absolute URL', () => { + expect(() => internalRoute`${'https://attacker.example/api/x'}`).toThrow( + /must start with \/api\// + ) + }) + + it('rejects a query string in the template', () => { + expect(() => internalRoute`/api/logs?limit=1`).toThrow(/belong in withQuery/) + }) + + describe('withQuery', () => { + it('appends and encodes params', () => { + const route = internalRoute`/api/logs`.withQuery({ search: 'a b&c', limit: 10 }) + + expect(route.path).toBe('/api/logs?search=a+b%26c&limit=10') + }) + + it('accepts URLSearchParams', () => { + const route = internalRoute`/api/logs`.withQuery(new URLSearchParams({ level: 'error' })) + + expect(route.path).toBe('/api/logs?level=error') + }) + + it('skips undefined and null values', () => { + const route = internalRoute`/api/logs`.withQuery({ a: undefined, b: null, c: 'keep' }) + + expect(route.path).toBe('/api/logs?c=keep') + }) + + it('does not mutate the route it was called on', () => { + const base = internalRoute`/api/logs` + base.withQuery({ a: '1' }) + + expect(base.path).toBe('/api/logs') + }) + }) + + it('cannot be constructed from data a param can carry', () => { + const fromParams: unknown = structuredClone({ path: '/api/admin', pathname: '/api/admin' }) + + expect(fromParams).not.toBeInstanceOf(InternalRoute) + }) +}) diff --git a/apps/sim/lib/core/utils/internal-route.ts b/apps/sim/lib/core/utils/internal-route.ts new file mode 100644 index 00000000000..da1019e86cf --- /dev/null +++ b/apps/sim/lib/core/utils/internal-route.ts @@ -0,0 +1,63 @@ +type QueryValue = string | number | boolean | null | undefined + +/** + * A route on Sim's own API, tagged so the transport can recognize it by identity. + * + * The transport resolves an internal route against the internal base URL and signs it with an + * internal token for the executing user, so the decision to route internally must come from a + * tool's source and never from its params. A branded value carries that provenance: params arrive + * as strings and JSON and can never construct one, whereas a `/api/...` string a builder returns + * is indistinguishable from one a caller supplied. + */ +export class InternalRoute { + constructor( + private readonly pathname: string, + private readonly query: URLSearchParams = new URLSearchParams() + ) {} + + /** Adds query params to the route, ignoring `undefined` and `null` values. */ + withQuery(query: URLSearchParams | Record): InternalRoute { + const next = new URLSearchParams(this.query) + const entries = query instanceof URLSearchParams ? [...query] : Object.entries(query) + for (const [key, value] of entries) { + if (value !== undefined && value !== null) next.set(key, String(value)) + } + return new InternalRoute(this.pathname, next) + } + + /** The relative request path, including any query string. */ + get path(): string { + const search = this.query.toString() + return search ? `${this.pathname}?${search}` : this.pathname + } +} + +/** + * Declares a route on Sim's own API, as a tagged template: + * + * ```ts + * url: (params) => internalRoute`/api/table/${params.tableId}/rows`.withQuery({ limit: params.limit }) + * ``` + * + * The literal segments are fixed at author time and every interpolated value is percent-encoded, + * so a param can fill a path segment but can never widen the path into a different route. Query + * params go through {@link InternalRoute.withQuery} rather than the template, so they are encoded + * as values instead of being spliced into the path. + * + * @throws when the resolved path is not a relative `/api/` path. + */ +export function internalRoute(segments: TemplateStringsArray, ...values: unknown[]): InternalRoute { + let pathname = segments[0] + for (const [index, value] of values.entries()) { + pathname += encodeURIComponent(String(value)) + segments[index + 1] + } + + if (!pathname.startsWith('/api/')) { + throw new Error(`Internal route must start with /api/: ${pathname}`) + } + if (pathname.includes('?')) { + throw new Error(`Internal route query params belong in withQuery(): ${pathname}`) + } + + return new InternalRoute(pathname) +} diff --git a/apps/sim/providers/utils.ts b/apps/sim/providers/utils.ts index c6017dba864..08902557c21 100644 --- a/apps/sim/providers/utils.ts +++ b/apps/sim/providers/utils.ts @@ -6,6 +6,7 @@ import type { BillingAttributionSnapshot } from '@/lib/billing/core/billing-attr import { formatCreditCost } from '@/lib/billing/credits/conversion' import { env } from '@/lib/core/config/env' import { getBlacklistedProvidersFromEnv, isHosted } from '@/lib/core/config/env-flags' +import { internalRoute } from '@/lib/core/utils/internal-route' import { normalizeRecord, normalizeStringRecord, @@ -84,10 +85,10 @@ async function fetchWorkflowMetadata( workflowId: string ): Promise<{ name: string; description: string | null } | null> { try { - const { buildAuthHeaders, buildAPIUrl } = await import('@/executor/utils/http') + const { buildAuthHeaders, buildInternalApiUrl } = await import('@/executor/utils/http') const headers = await buildAuthHeaders() - const url = buildAPIUrl(`/api/workflows/${workflowId}`) + const url = buildInternalApiUrl(internalRoute`/api/workflows/${workflowId}`) const response = await fetch(url.toString(), { headers }) if (!response.ok) { diff --git a/apps/sim/tools/agiloft/attachment_info.ts b/apps/sim/tools/agiloft/attachment_info.ts index 4a577ca90db..048cb2fcd79 100644 --- a/apps/sim/tools/agiloft/attachment_info.ts +++ b/apps/sim/tools/agiloft/attachment_info.ts @@ -59,7 +59,7 @@ export const agiloftAttachmentInfoTool: ToolConfig< }, request: { - url: () => '/api/tools/agiloft/attachment_info', + url: '/api/tools/agiloft/attachment_info', method: 'POST', headers: () => ({ 'Content-Type': 'application/json' }), body: (params) => ({ diff --git a/apps/sim/tools/agiloft/create_record.ts b/apps/sim/tools/agiloft/create_record.ts index 216008e354a..c40569a2286 100644 --- a/apps/sim/tools/agiloft/create_record.ts +++ b/apps/sim/tools/agiloft/create_record.ts @@ -49,7 +49,7 @@ export const agiloftCreateRecordTool: ToolConfig '/api/tools/agiloft/create_record', + url: '/api/tools/agiloft/create_record', method: 'POST', headers: () => ({ 'Content-Type': 'application/json' }), body: (params) => ({ diff --git a/apps/sim/tools/agiloft/delete_record.ts b/apps/sim/tools/agiloft/delete_record.ts index f0599da85ef..e520651beac 100644 --- a/apps/sim/tools/agiloft/delete_record.ts +++ b/apps/sim/tools/agiloft/delete_record.ts @@ -48,7 +48,7 @@ export const agiloftDeleteRecordTool: ToolConfig '/api/tools/agiloft/delete_record', + url: '/api/tools/agiloft/delete_record', method: 'POST', headers: () => ({ 'Content-Type': 'application/json' }), body: (params) => ({ diff --git a/apps/sim/tools/agiloft/get_choice_line_id.ts b/apps/sim/tools/agiloft/get_choice_line_id.ts index d2568933123..c4c45e18985 100644 --- a/apps/sim/tools/agiloft/get_choice_line_id.ts +++ b/apps/sim/tools/agiloft/get_choice_line_id.ts @@ -60,7 +60,7 @@ export const agiloftGetChoiceLineIdTool: ToolConfig< }, request: { - url: () => '/api/tools/agiloft/get_choice_line_id', + url: '/api/tools/agiloft/get_choice_line_id', method: 'POST', headers: () => ({ 'Content-Type': 'application/json' }), body: (params) => ({ diff --git a/apps/sim/tools/agiloft/lock_record.ts b/apps/sim/tools/agiloft/lock_record.ts index 4497d88da69..fb35b676041 100644 --- a/apps/sim/tools/agiloft/lock_record.ts +++ b/apps/sim/tools/agiloft/lock_record.ts @@ -53,7 +53,7 @@ export const agiloftLockRecordTool: ToolConfig '/api/tools/agiloft/lock_record', + url: '/api/tools/agiloft/lock_record', method: 'POST', headers: () => ({ 'Content-Type': 'application/json' }), body: (params) => ({ diff --git a/apps/sim/tools/agiloft/read_record.ts b/apps/sim/tools/agiloft/read_record.ts index dcb495c61dc..ef3d84e66e0 100644 --- a/apps/sim/tools/agiloft/read_record.ts +++ b/apps/sim/tools/agiloft/read_record.ts @@ -53,7 +53,7 @@ export const agiloftReadRecordTool: ToolConfig '/api/tools/agiloft/read_record', + url: '/api/tools/agiloft/read_record', method: 'POST', headers: () => ({ 'Content-Type': 'application/json' }), body: (params) => ({ diff --git a/apps/sim/tools/agiloft/remove_attachment.ts b/apps/sim/tools/agiloft/remove_attachment.ts index 8eabc5e476b..65741329657 100644 --- a/apps/sim/tools/agiloft/remove_attachment.ts +++ b/apps/sim/tools/agiloft/remove_attachment.ts @@ -65,7 +65,7 @@ export const agiloftRemoveAttachmentTool: ToolConfig< }, request: { - url: () => '/api/tools/agiloft/remove_attachment', + url: '/api/tools/agiloft/remove_attachment', method: 'POST', headers: () => ({ 'Content-Type': 'application/json' }), body: (params) => ({ diff --git a/apps/sim/tools/agiloft/saved_search.ts b/apps/sim/tools/agiloft/saved_search.ts index 6199c18ef69..0349d840805 100644 --- a/apps/sim/tools/agiloft/saved_search.ts +++ b/apps/sim/tools/agiloft/saved_search.ts @@ -44,7 +44,7 @@ export const agiloftSavedSearchTool: ToolConfig< }, request: { - url: () => '/api/tools/agiloft/saved_search', + url: '/api/tools/agiloft/saved_search', method: 'POST', headers: () => ({ 'Content-Type': 'application/json' }), body: (params) => ({ diff --git a/apps/sim/tools/agiloft/search_records.ts b/apps/sim/tools/agiloft/search_records.ts index 8cbc759f3a4..c8be4799e8a 100644 --- a/apps/sim/tools/agiloft/search_records.ts +++ b/apps/sim/tools/agiloft/search_records.ts @@ -69,7 +69,7 @@ export const agiloftSearchRecordsTool: ToolConfig< }, request: { - url: () => '/api/tools/agiloft/search_records', + url: '/api/tools/agiloft/search_records', method: 'POST', headers: () => ({ 'Content-Type': 'application/json' }), body: (params) => ({ diff --git a/apps/sim/tools/agiloft/select_records.ts b/apps/sim/tools/agiloft/select_records.ts index 72af1539720..e5f7e34d741 100644 --- a/apps/sim/tools/agiloft/select_records.ts +++ b/apps/sim/tools/agiloft/select_records.ts @@ -51,7 +51,7 @@ export const agiloftSelectRecordsTool: ToolConfig< }, request: { - url: () => '/api/tools/agiloft/select_records', + url: '/api/tools/agiloft/select_records', method: 'POST', headers: () => ({ 'Content-Type': 'application/json' }), body: (params) => ({ diff --git a/apps/sim/tools/agiloft/update_record.ts b/apps/sim/tools/agiloft/update_record.ts index 4b887e50fa3..b8dfd1bab73 100644 --- a/apps/sim/tools/agiloft/update_record.ts +++ b/apps/sim/tools/agiloft/update_record.ts @@ -55,7 +55,7 @@ export const agiloftUpdateRecordTool: ToolConfig '/api/tools/agiloft/update_record', + url: '/api/tools/agiloft/update_record', method: 'POST', headers: () => ({ 'Content-Type': 'application/json' }), body: (params) => ({ diff --git a/apps/sim/tools/confluence/add_label.ts b/apps/sim/tools/confluence/add_label.ts index db931b3cf0d..ee83acc0dc2 100644 --- a/apps/sim/tools/confluence/add_label.ts +++ b/apps/sim/tools/confluence/add_label.ts @@ -75,7 +75,7 @@ export const confluenceAddLabelTool: ToolConfig< }, request: { - url: () => '/api/tools/confluence/labels', + url: '/api/tools/confluence/labels', method: 'POST', headers: (params: ConfluenceAddLabelParams) => ({ Accept: 'application/json', diff --git a/apps/sim/tools/confluence/create_blogpost.ts b/apps/sim/tools/confluence/create_blogpost.ts index b39e91b7f18..5044bbc12a6 100644 --- a/apps/sim/tools/confluence/create_blogpost.ts +++ b/apps/sim/tools/confluence/create_blogpost.ts @@ -91,7 +91,7 @@ export const confluenceCreateBlogPostTool: ToolConfig< }, request: { - url: () => '/api/tools/confluence/blogposts', + url: '/api/tools/confluence/blogposts', method: 'POST', headers: (params: ConfluenceCreateBlogPostParams) => ({ Accept: 'application/json', diff --git a/apps/sim/tools/confluence/create_comment.ts b/apps/sim/tools/confluence/create_comment.ts index aa18a5c4a0b..dfba3559897 100644 --- a/apps/sim/tools/confluence/create_comment.ts +++ b/apps/sim/tools/confluence/create_comment.ts @@ -66,7 +66,7 @@ export const confluenceCreateCommentTool: ToolConfig< }, request: { - url: () => '/api/tools/confluence/comments', + url: '/api/tools/confluence/comments', method: 'POST', headers: (params: ConfluenceCreateCommentParams) => { return { diff --git a/apps/sim/tools/confluence/create_page.ts b/apps/sim/tools/confluence/create_page.ts index 7a4fec8a846..a96bebd61a3 100644 --- a/apps/sim/tools/confluence/create_page.ts +++ b/apps/sim/tools/confluence/create_page.ts @@ -87,7 +87,7 @@ export const confluenceCreatePageTool: ToolConfig< }, request: { - url: () => '/api/tools/confluence/create-page', + url: '/api/tools/confluence/create-page', method: 'POST', headers: (params: ConfluenceCreatePageParams) => { return { diff --git a/apps/sim/tools/confluence/create_page_property.ts b/apps/sim/tools/confluence/create_page_property.ts index 36ebfb04a06..f1f0393ab86 100644 --- a/apps/sim/tools/confluence/create_page_property.ts +++ b/apps/sim/tools/confluence/create_page_property.ts @@ -79,7 +79,7 @@ export const confluenceCreatePagePropertyTool: ToolConfig< }, request: { - url: () => '/api/tools/confluence/page-properties', + url: '/api/tools/confluence/page-properties', method: 'POST', headers: (params: ConfluenceCreatePagePropertyParams) => ({ Accept: 'application/json', diff --git a/apps/sim/tools/confluence/create_space.ts b/apps/sim/tools/confluence/create_space.ts index f2d8b8a734a..cbf0dd2c3f4 100644 --- a/apps/sim/tools/confluence/create_space.ts +++ b/apps/sim/tools/confluence/create_space.ts @@ -80,7 +80,7 @@ export const confluenceCreateSpaceTool: ToolConfig< }, request: { - url: () => '/api/tools/confluence/space', + url: '/api/tools/confluence/space', method: 'POST', headers: (params: ConfluenceCreateSpaceParams) => ({ Accept: 'application/json', diff --git a/apps/sim/tools/confluence/create_space_property.ts b/apps/sim/tools/confluence/create_space_property.ts index c702f63539a..7b865f5c11c 100644 --- a/apps/sim/tools/confluence/create_space_property.ts +++ b/apps/sim/tools/confluence/create_space_property.ts @@ -76,7 +76,7 @@ export const confluenceCreateSpacePropertyTool: ToolConfig< }, request: { - url: () => '/api/tools/confluence/space-properties', + url: '/api/tools/confluence/space-properties', method: 'POST', headers: (params: ConfluenceCreateSpacePropertyParams) => ({ Accept: 'application/json', diff --git a/apps/sim/tools/confluence/delete_attachment.ts b/apps/sim/tools/confluence/delete_attachment.ts index 37d2d093d80..dd38ad325e4 100644 --- a/apps/sim/tools/confluence/delete_attachment.ts +++ b/apps/sim/tools/confluence/delete_attachment.ts @@ -59,7 +59,7 @@ export const confluenceDeleteAttachmentTool: ToolConfig< }, request: { - url: () => '/api/tools/confluence/attachment', + url: '/api/tools/confluence/attachment', method: 'DELETE', headers: (params: ConfluenceDeleteAttachmentParams) => { return { diff --git a/apps/sim/tools/confluence/delete_blogpost.ts b/apps/sim/tools/confluence/delete_blogpost.ts index c53562cf283..ed0efca33c3 100644 --- a/apps/sim/tools/confluence/delete_blogpost.ts +++ b/apps/sim/tools/confluence/delete_blogpost.ts @@ -60,7 +60,7 @@ export const confluenceDeleteBlogPostTool: ToolConfig< }, request: { - url: () => '/api/tools/confluence/blogposts', + url: '/api/tools/confluence/blogposts', method: 'DELETE', headers: (params: ConfluenceDeleteBlogPostParams) => ({ Accept: 'application/json', diff --git a/apps/sim/tools/confluence/delete_comment.ts b/apps/sim/tools/confluence/delete_comment.ts index 6564181dfe0..e9fafe04c8e 100644 --- a/apps/sim/tools/confluence/delete_comment.ts +++ b/apps/sim/tools/confluence/delete_comment.ts @@ -59,7 +59,7 @@ export const confluenceDeleteCommentTool: ToolConfig< }, request: { - url: () => '/api/tools/confluence/comment', + url: '/api/tools/confluence/comment', method: 'DELETE', headers: (params: ConfluenceDeleteCommentParams) => { return { diff --git a/apps/sim/tools/confluence/delete_label.ts b/apps/sim/tools/confluence/delete_label.ts index 2f92766fc67..0f368e8310e 100644 --- a/apps/sim/tools/confluence/delete_label.ts +++ b/apps/sim/tools/confluence/delete_label.ts @@ -68,7 +68,7 @@ export const confluenceDeleteLabelTool: ToolConfig< }, request: { - url: () => '/api/tools/confluence/labels', + url: '/api/tools/confluence/labels', method: 'DELETE', headers: (params: ConfluenceDeleteLabelParams) => ({ Accept: 'application/json', diff --git a/apps/sim/tools/confluence/delete_page.ts b/apps/sim/tools/confluence/delete_page.ts index a648a2b37c5..69c9b6fb8c2 100644 --- a/apps/sim/tools/confluence/delete_page.ts +++ b/apps/sim/tools/confluence/delete_page.ts @@ -68,7 +68,7 @@ export const confluenceDeletePageTool: ToolConfig< }, request: { - url: (params: ConfluenceDeletePageParams) => '/api/tools/confluence/page', + url: '/api/tools/confluence/page', method: 'DELETE', headers: (params: ConfluenceDeletePageParams) => { return { diff --git a/apps/sim/tools/confluence/delete_page_property.ts b/apps/sim/tools/confluence/delete_page_property.ts index d7b6c5fbb49..84d6a48412d 100644 --- a/apps/sim/tools/confluence/delete_page_property.ts +++ b/apps/sim/tools/confluence/delete_page_property.ts @@ -68,7 +68,7 @@ export const confluenceDeletePagePropertyTool: ToolConfig< }, request: { - url: () => '/api/tools/confluence/page-properties', + url: '/api/tools/confluence/page-properties', method: 'DELETE', headers: (params: ConfluenceDeletePagePropertyParams) => ({ Accept: 'application/json', diff --git a/apps/sim/tools/confluence/delete_space.ts b/apps/sim/tools/confluence/delete_space.ts index e6a2fc9d172..bf0420b8148 100644 --- a/apps/sim/tools/confluence/delete_space.ts +++ b/apps/sim/tools/confluence/delete_space.ts @@ -62,7 +62,7 @@ export const confluenceDeleteSpaceTool: ToolConfig< }, request: { - url: () => '/api/tools/confluence/space', + url: '/api/tools/confluence/space', method: 'DELETE', headers: (params: ConfluenceDeleteSpaceParams) => ({ Accept: 'application/json', diff --git a/apps/sim/tools/confluence/delete_space_property.ts b/apps/sim/tools/confluence/delete_space_property.ts index 9c69431aac4..b2b86da16bd 100644 --- a/apps/sim/tools/confluence/delete_space_property.ts +++ b/apps/sim/tools/confluence/delete_space_property.ts @@ -68,7 +68,7 @@ export const confluenceDeleteSpacePropertyTool: ToolConfig< }, request: { - url: () => '/api/tools/confluence/space-properties', + url: '/api/tools/confluence/space-properties', method: 'POST', headers: (params: ConfluenceDeleteSpacePropertyParams) => ({ Accept: 'application/json', diff --git a/apps/sim/tools/confluence/get_blogpost.ts b/apps/sim/tools/confluence/get_blogpost.ts index 94c9b02de7c..cb3af928b40 100644 --- a/apps/sim/tools/confluence/get_blogpost.ts +++ b/apps/sim/tools/confluence/get_blogpost.ts @@ -84,7 +84,7 @@ export const confluenceGetBlogPostTool: ToolConfig< }, request: { - url: () => '/api/tools/confluence/blogposts', + url: '/api/tools/confluence/blogposts', method: 'POST', headers: (params: ConfluenceGetBlogPostParams) => ({ Accept: 'application/json', diff --git a/apps/sim/tools/confluence/get_page_ancestors.ts b/apps/sim/tools/confluence/get_page_ancestors.ts index 20b7be3ca2c..4f5d41b089b 100644 --- a/apps/sim/tools/confluence/get_page_ancestors.ts +++ b/apps/sim/tools/confluence/get_page_ancestors.ts @@ -74,7 +74,7 @@ export const confluenceGetPageAncestorsTool: ToolConfig< }, request: { - url: () => '/api/tools/confluence/page-ancestors', + url: '/api/tools/confluence/page-ancestors', method: 'POST', headers: (params: ConfluenceGetPageAncestorsParams) => ({ Accept: 'application/json', diff --git a/apps/sim/tools/confluence/get_page_children.ts b/apps/sim/tools/confluence/get_page_children.ts index 7ca7ca10eda..773848ebcc2 100644 --- a/apps/sim/tools/confluence/get_page_children.ts +++ b/apps/sim/tools/confluence/get_page_children.ts @@ -83,7 +83,7 @@ export const confluenceGetPageChildrenTool: ToolConfig< }, request: { - url: () => '/api/tools/confluence/page-children', + url: '/api/tools/confluence/page-children', method: 'POST', headers: (params: ConfluenceGetPageChildrenParams) => ({ Accept: 'application/json', diff --git a/apps/sim/tools/confluence/get_page_descendants.ts b/apps/sim/tools/confluence/get_page_descendants.ts index a9e0bc5a323..471451d2299 100644 --- a/apps/sim/tools/confluence/get_page_descendants.ts +++ b/apps/sim/tools/confluence/get_page_descendants.ts @@ -84,7 +84,7 @@ export const confluenceGetPageDescendantsTool: ToolConfig< }, request: { - url: () => '/api/tools/confluence/page-descendants', + url: '/api/tools/confluence/page-descendants', method: 'POST', headers: (params: ConfluenceGetPageDescendantsParams) => ({ Accept: 'application/json', diff --git a/apps/sim/tools/confluence/get_page_version.ts b/apps/sim/tools/confluence/get_page_version.ts index dc496b38a24..29f657b04fc 100644 --- a/apps/sim/tools/confluence/get_page_version.ts +++ b/apps/sim/tools/confluence/get_page_version.ts @@ -89,7 +89,7 @@ export const confluenceGetPageVersionTool: ToolConfig< }, request: { - url: () => '/api/tools/confluence/page-versions', + url: '/api/tools/confluence/page-versions', method: 'POST', headers: (params: ConfluenceGetPageVersionParams) => ({ Accept: 'application/json', diff --git a/apps/sim/tools/confluence/get_pages_by_label.ts b/apps/sim/tools/confluence/get_pages_by_label.ts index af67210a0b0..06f3382c924 100644 --- a/apps/sim/tools/confluence/get_pages_by_label.ts +++ b/apps/sim/tools/confluence/get_pages_by_label.ts @@ -1,3 +1,4 @@ +import { internalRoute } from '@/lib/core/utils/internal-route' import { PAGE_ITEM_PROPERTIES, TIMESTAMP_OUTPUT } from '@/tools/confluence/types' import type { ToolConfig } from '@/tools/types' @@ -101,7 +102,7 @@ export const confluenceGetPagesByLabelTool: ToolConfig< if (params.cloudId) { query.set('cloudId', params.cloudId) } - return `/api/tools/confluence/pages-by-label?${query.toString()}` + return internalRoute`/api/tools/confluence/pages-by-label`.withQuery(query) }, method: 'GET', headers: (params: ConfluenceGetPagesByLabelParams) => ({ diff --git a/apps/sim/tools/confluence/get_space.ts b/apps/sim/tools/confluence/get_space.ts index fbadd7a6575..8b135891bc6 100644 --- a/apps/sim/tools/confluence/get_space.ts +++ b/apps/sim/tools/confluence/get_space.ts @@ -1,3 +1,4 @@ +import { internalRoute } from '@/lib/core/utils/internal-route' import { SPACE_DESCRIPTION_OUTPUT_PROPERTIES, TIMESTAMP_OUTPUT } from '@/tools/confluence/types' import type { ToolConfig } from '@/tools/types' @@ -80,7 +81,7 @@ export const confluenceGetSpaceTool: ToolConfig< if (params.cloudId) { query.set('cloudId', params.cloudId) } - return `/api/tools/confluence/space?${query.toString()}` + return internalRoute`/api/tools/confluence/space`.withQuery(query) }, method: 'GET', headers: (params: ConfluenceGetSpaceParams) => { diff --git a/apps/sim/tools/confluence/get_task.ts b/apps/sim/tools/confluence/get_task.ts index cf0b6177654..d096cca913f 100644 --- a/apps/sim/tools/confluence/get_task.ts +++ b/apps/sim/tools/confluence/get_task.ts @@ -70,7 +70,7 @@ export const confluenceGetTaskTool: ToolConfig '/api/tools/confluence/tasks', + url: '/api/tools/confluence/tasks', method: 'POST', headers: (params: ConfluenceGetTaskParams) => ({ Accept: 'application/json', diff --git a/apps/sim/tools/confluence/get_user.ts b/apps/sim/tools/confluence/get_user.ts index 23048361356..9c851c1522e 100644 --- a/apps/sim/tools/confluence/get_user.ts +++ b/apps/sim/tools/confluence/get_user.ts @@ -62,7 +62,7 @@ export const confluenceGetUserTool: ToolConfig '/api/tools/confluence/user', + url: '/api/tools/confluence/user', method: 'POST', headers: (params: ConfluenceGetUserParams) => ({ Accept: 'application/json', diff --git a/apps/sim/tools/confluence/list_attachments.ts b/apps/sim/tools/confluence/list_attachments.ts index 932aa9b6876..2649def1b27 100644 --- a/apps/sim/tools/confluence/list_attachments.ts +++ b/apps/sim/tools/confluence/list_attachments.ts @@ -1,3 +1,4 @@ +import { internalRoute } from '@/lib/core/utils/internal-route' import { ATTACHMENTS_OUTPUT, TIMESTAMP_OUTPUT } from '@/tools/confluence/types' import type { ToolConfig } from '@/tools/types' @@ -93,7 +94,7 @@ export const confluenceListAttachmentsTool: ToolConfig< if (params.cloudId) { query.set('cloudId', params.cloudId) } - return `/api/tools/confluence/attachments?${query.toString()}` + return internalRoute`/api/tools/confluence/attachments`.withQuery(query) }, method: 'GET', headers: (params: ConfluenceListAttachmentsParams) => { diff --git a/apps/sim/tools/confluence/list_blogposts.ts b/apps/sim/tools/confluence/list_blogposts.ts index a6b78e2b5e4..2bd953f7c61 100644 --- a/apps/sim/tools/confluence/list_blogposts.ts +++ b/apps/sim/tools/confluence/list_blogposts.ts @@ -1,3 +1,4 @@ +import { internalRoute } from '@/lib/core/utils/internal-route' import { TIMESTAMP_OUTPUT, VERSION_OUTPUT_PROPERTIES } from '@/tools/confluence/types' import type { ToolConfig } from '@/tools/types' @@ -113,7 +114,7 @@ export const confluenceListBlogPostsTool: ToolConfig< if (params.cloudId) { query.set('cloudId', params.cloudId) } - return `/api/tools/confluence/blogposts?${query.toString()}` + return internalRoute`/api/tools/confluence/blogposts`.withQuery(query) }, method: 'GET', headers: (params: ConfluenceListBlogPostsParams) => ({ diff --git a/apps/sim/tools/confluence/list_blogposts_in_space.ts b/apps/sim/tools/confluence/list_blogposts_in_space.ts index d32fcd9f4da..0c925bb9fa6 100644 --- a/apps/sim/tools/confluence/list_blogposts_in_space.ts +++ b/apps/sim/tools/confluence/list_blogposts_in_space.ts @@ -108,7 +108,7 @@ export const confluenceListBlogPostsInSpaceTool: ToolConfig< }, request: { - url: () => '/api/tools/confluence/space-blogposts', + url: '/api/tools/confluence/space-blogposts', method: 'POST', headers: (params: ConfluenceListBlogPostsInSpaceParams) => ({ Accept: 'application/json', diff --git a/apps/sim/tools/confluence/list_comments.ts b/apps/sim/tools/confluence/list_comments.ts index d841dd6607c..cbf17c3fd14 100644 --- a/apps/sim/tools/confluence/list_comments.ts +++ b/apps/sim/tools/confluence/list_comments.ts @@ -1,3 +1,4 @@ +import { internalRoute } from '@/lib/core/utils/internal-route' import { COMMENTS_OUTPUT, TIMESTAMP_OUTPUT } from '@/tools/confluence/types' import type { ToolConfig } from '@/tools/types' @@ -103,7 +104,7 @@ export const confluenceListCommentsTool: ToolConfig< if (params.cloudId) { query.set('cloudId', params.cloudId) } - return `/api/tools/confluence/comments?${query.toString()}` + return internalRoute`/api/tools/confluence/comments`.withQuery(query) }, method: 'GET', headers: (params: ConfluenceListCommentsParams) => { diff --git a/apps/sim/tools/confluence/list_labels.ts b/apps/sim/tools/confluence/list_labels.ts index c28bac10bcf..bdf91a88c48 100644 --- a/apps/sim/tools/confluence/list_labels.ts +++ b/apps/sim/tools/confluence/list_labels.ts @@ -1,3 +1,4 @@ +import { internalRoute } from '@/lib/core/utils/internal-route' import { LABEL_ITEM_PROPERTIES } from '@/tools/confluence/types' import type { ToolConfig } from '@/tools/types' @@ -91,7 +92,7 @@ export const confluenceListLabelsTool: ToolConfig< if (params.cloudId) { query.set('cloudId', params.cloudId) } - return `/api/tools/confluence/labels?${query.toString()}` + return internalRoute`/api/tools/confluence/labels`.withQuery(query) }, method: 'GET', headers: (params: ConfluenceListLabelsParams) => { diff --git a/apps/sim/tools/confluence/list_page_properties.ts b/apps/sim/tools/confluence/list_page_properties.ts index cd26739c44c..fcc11e478c3 100644 --- a/apps/sim/tools/confluence/list_page_properties.ts +++ b/apps/sim/tools/confluence/list_page_properties.ts @@ -1,3 +1,4 @@ +import { internalRoute } from '@/lib/core/utils/internal-route' import { TIMESTAMP_OUTPUT, VERSION_OUTPUT_PROPERTIES } from '@/tools/confluence/types' import type { ToolConfig } from '@/tools/types' @@ -97,7 +98,7 @@ export const confluenceListPagePropertiesTool: ToolConfig< if (params.cloudId) { query.set('cloudId', params.cloudId) } - return `/api/tools/confluence/page-properties?${query.toString()}` + return internalRoute`/api/tools/confluence/page-properties`.withQuery(query) }, method: 'GET', headers: (params: ConfluenceListPagePropertiesParams) => ({ diff --git a/apps/sim/tools/confluence/list_page_versions.ts b/apps/sim/tools/confluence/list_page_versions.ts index 8e97f9fdec1..1d48d32dec3 100644 --- a/apps/sim/tools/confluence/list_page_versions.ts +++ b/apps/sim/tools/confluence/list_page_versions.ts @@ -81,7 +81,7 @@ export const confluenceListPageVersionsTool: ToolConfig< }, request: { - url: () => '/api/tools/confluence/page-versions', + url: '/api/tools/confluence/page-versions', method: 'POST', headers: (params: ConfluenceListPageVersionsParams) => ({ Accept: 'application/json', diff --git a/apps/sim/tools/confluence/list_pages_in_space.ts b/apps/sim/tools/confluence/list_pages_in_space.ts index 558a8c50e39..6ed5da21cc4 100644 --- a/apps/sim/tools/confluence/list_pages_in_space.ts +++ b/apps/sim/tools/confluence/list_pages_in_space.ts @@ -111,7 +111,7 @@ export const confluenceListPagesInSpaceTool: ToolConfig< }, request: { - url: () => '/api/tools/confluence/space-pages', + url: '/api/tools/confluence/space-pages', method: 'POST', headers: (params: ConfluenceListPagesInSpaceParams) => ({ Accept: 'application/json', diff --git a/apps/sim/tools/confluence/list_space_labels.ts b/apps/sim/tools/confluence/list_space_labels.ts index d30990d06ed..25e48ea45ea 100644 --- a/apps/sim/tools/confluence/list_space_labels.ts +++ b/apps/sim/tools/confluence/list_space_labels.ts @@ -1,3 +1,4 @@ +import { internalRoute } from '@/lib/core/utils/internal-route' import { LABEL_ITEM_PROPERTIES, TIMESTAMP_OUTPUT } from '@/tools/confluence/types' import type { ToolConfig } from '@/tools/types' @@ -92,7 +93,7 @@ export const confluenceListSpaceLabelsTool: ToolConfig< if (params.cloudId) { query.set('cloudId', params.cloudId) } - return `/api/tools/confluence/space-labels?${query.toString()}` + return internalRoute`/api/tools/confluence/space-labels`.withQuery(query) }, method: 'GET', headers: (params: ConfluenceListSpaceLabelsParams) => ({ diff --git a/apps/sim/tools/confluence/list_space_permissions.ts b/apps/sim/tools/confluence/list_space_permissions.ts index 3d8fe00f2b2..ad37fd9e03d 100644 --- a/apps/sim/tools/confluence/list_space_permissions.ts +++ b/apps/sim/tools/confluence/list_space_permissions.ts @@ -83,7 +83,7 @@ export const confluenceListSpacePermissionsTool: ToolConfig< }, request: { - url: () => '/api/tools/confluence/space-permissions', + url: '/api/tools/confluence/space-permissions', method: 'POST', headers: (params: ConfluenceListSpacePermissionsParams) => ({ Accept: 'application/json', diff --git a/apps/sim/tools/confluence/list_space_properties.ts b/apps/sim/tools/confluence/list_space_properties.ts index d47c4570b08..a209731f3b3 100644 --- a/apps/sim/tools/confluence/list_space_properties.ts +++ b/apps/sim/tools/confluence/list_space_properties.ts @@ -79,7 +79,7 @@ export const confluenceListSpacePropertiesTool: ToolConfig< }, request: { - url: () => '/api/tools/confluence/space-properties', + url: '/api/tools/confluence/space-properties', method: 'POST', headers: (params: ConfluenceListSpacePropertiesParams) => ({ Accept: 'application/json', diff --git a/apps/sim/tools/confluence/list_spaces.ts b/apps/sim/tools/confluence/list_spaces.ts index 3859aad2bc4..b4692bb3253 100644 --- a/apps/sim/tools/confluence/list_spaces.ts +++ b/apps/sim/tools/confluence/list_spaces.ts @@ -1,3 +1,4 @@ +import { internalRoute } from '@/lib/core/utils/internal-route' import { SPACES_OUTPUT, TIMESTAMP_OUTPUT } from '@/tools/confluence/types' import type { ToolConfig } from '@/tools/types' @@ -85,7 +86,7 @@ export const confluenceListSpacesTool: ToolConfig< if (params.cloudId) { query.set('cloudId', params.cloudId) } - return `/api/tools/confluence/spaces?${query.toString()}` + return internalRoute`/api/tools/confluence/spaces`.withQuery(query) }, method: 'GET', headers: (params: ConfluenceListSpacesParams) => { diff --git a/apps/sim/tools/confluence/list_tasks.ts b/apps/sim/tools/confluence/list_tasks.ts index 4f44678a89f..ddce4b3ef0a 100644 --- a/apps/sim/tools/confluence/list_tasks.ts +++ b/apps/sim/tools/confluence/list_tasks.ts @@ -111,7 +111,7 @@ export const confluenceListTasksTool: ToolConfig< }, request: { - url: () => '/api/tools/confluence/tasks', + url: '/api/tools/confluence/tasks', method: 'POST', headers: (params: ConfluenceListTasksParams) => ({ Accept: 'application/json', diff --git a/apps/sim/tools/confluence/retrieve.ts b/apps/sim/tools/confluence/retrieve.ts index ded0fda90cf..49b9a16efad 100644 --- a/apps/sim/tools/confluence/retrieve.ts +++ b/apps/sim/tools/confluence/retrieve.ts @@ -1,3 +1,4 @@ +import { internalRoute } from '@/lib/core/utils/internal-route' import type { ConfluenceRetrieveParams, ConfluenceRetrieveResponse } from '@/tools/confluence/types' import { BODY_FORMAT_PROPERTIES, @@ -51,7 +52,7 @@ export const confluenceRetrieveTool: ToolConfig< request: { url: (params: ConfluenceRetrieveParams) => { - return '/api/tools/confluence/page' + return internalRoute`/api/tools/confluence/page` }, method: 'POST', headers: (params: ConfluenceRetrieveParams) => { diff --git a/apps/sim/tools/confluence/search.ts b/apps/sim/tools/confluence/search.ts index 9b551ade892..48f87a97bcc 100644 --- a/apps/sim/tools/confluence/search.ts +++ b/apps/sim/tools/confluence/search.ts @@ -69,7 +69,7 @@ export const confluenceSearchTool: ToolConfig '/api/tools/confluence/search', + url: '/api/tools/confluence/search', method: 'POST', headers: (params: ConfluenceSearchParams) => { return { diff --git a/apps/sim/tools/confluence/search_in_space.ts b/apps/sim/tools/confluence/search_in_space.ts index 5b10a5c6294..633251faad9 100644 --- a/apps/sim/tools/confluence/search_in_space.ts +++ b/apps/sim/tools/confluence/search_in_space.ts @@ -91,7 +91,7 @@ export const confluenceSearchInSpaceTool: ToolConfig< }, request: { - url: () => '/api/tools/confluence/search-in-space', + url: '/api/tools/confluence/search-in-space', method: 'POST', headers: (params: ConfluenceSearchInSpaceParams) => ({ Accept: 'application/json', diff --git a/apps/sim/tools/confluence/update.ts b/apps/sim/tools/confluence/update.ts index e5daa6628fd..d33eb117d5b 100644 --- a/apps/sim/tools/confluence/update.ts +++ b/apps/sim/tools/confluence/update.ts @@ -1,3 +1,4 @@ +import { internalRoute } from '@/lib/core/utils/internal-route' import type { ConfluenceUpdateParams, ConfluenceUpdateResponse } from '@/tools/confluence/types' import { CONTENT_BODY_OUTPUT_PROPERTIES, VERSION_OUTPUT_PROPERTIES } from '@/tools/confluence/types' import type { ToolConfig } from '@/tools/types' @@ -55,7 +56,7 @@ export const confluenceUpdateTool: ToolConfig { - return '/api/tools/confluence/page' + return internalRoute`/api/tools/confluence/page` }, method: 'PUT', headers: (params: ConfluenceUpdateParams) => { diff --git a/apps/sim/tools/confluence/update_blogpost.ts b/apps/sim/tools/confluence/update_blogpost.ts index ea873cea179..b0167aaa02b 100644 --- a/apps/sim/tools/confluence/update_blogpost.ts +++ b/apps/sim/tools/confluence/update_blogpost.ts @@ -78,7 +78,7 @@ export const confluenceUpdateBlogPostTool: ToolConfig< }, request: { - url: () => '/api/tools/confluence/blogposts', + url: '/api/tools/confluence/blogposts', method: 'PUT', headers: (params: ConfluenceUpdateBlogPostParams) => ({ Accept: 'application/json', diff --git a/apps/sim/tools/confluence/update_comment.ts b/apps/sim/tools/confluence/update_comment.ts index 897517f8b6d..139e3e49da7 100644 --- a/apps/sim/tools/confluence/update_comment.ts +++ b/apps/sim/tools/confluence/update_comment.ts @@ -66,7 +66,7 @@ export const confluenceUpdateCommentTool: ToolConfig< }, request: { - url: () => '/api/tools/confluence/comment', + url: '/api/tools/confluence/comment', method: 'PUT', headers: (params: ConfluenceUpdateCommentParams) => { return { diff --git a/apps/sim/tools/confluence/update_space.ts b/apps/sim/tools/confluence/update_space.ts index c1cc6bd6dbf..050ae037b92 100644 --- a/apps/sim/tools/confluence/update_space.ts +++ b/apps/sim/tools/confluence/update_space.ts @@ -79,7 +79,7 @@ export const confluenceUpdateSpaceTool: ToolConfig< }, request: { - url: () => '/api/tools/confluence/space', + url: '/api/tools/confluence/space', method: 'PUT', headers: (params: ConfluenceUpdateSpaceParams) => ({ Accept: 'application/json', diff --git a/apps/sim/tools/confluence/update_task.ts b/apps/sim/tools/confluence/update_task.ts index d7d87387eb9..180761a07d2 100644 --- a/apps/sim/tools/confluence/update_task.ts +++ b/apps/sim/tools/confluence/update_task.ts @@ -79,7 +79,7 @@ export const confluenceUpdateTaskTool: ToolConfig< }, request: { - url: () => '/api/tools/confluence/tasks', + url: '/api/tools/confluence/tasks', method: 'POST', headers: (params: ConfluenceUpdateTaskParams) => ({ Accept: 'application/json', diff --git a/apps/sim/tools/confluence/upload_attachment.ts b/apps/sim/tools/confluence/upload_attachment.ts index e8fabd5c0bb..b5ba96d2004 100644 --- a/apps/sim/tools/confluence/upload_attachment.ts +++ b/apps/sim/tools/confluence/upload_attachment.ts @@ -84,7 +84,7 @@ export const confluenceUploadAttachmentTool: ToolConfig< }, request: { - url: () => '/api/tools/confluence/upload-attachment', + url: '/api/tools/confluence/upload-attachment', method: 'POST', headers: (params: ConfluenceUploadAttachmentParams) => { return { diff --git a/apps/sim/tools/deployments/get_version.ts b/apps/sim/tools/deployments/get_version.ts index 088af15188c..cb91c97194c 100644 --- a/apps/sim/tools/deployments/get_version.ts +++ b/apps/sim/tools/deployments/get_version.ts @@ -1,3 +1,4 @@ +import { internalRoute } from '@/lib/core/utils/internal-route' import type { DeploymentsGetVersionParams, DeploymentsGetVersionResponse, @@ -40,7 +41,7 @@ export const deploymentsGetVersionTool: ToolConfig< workspaceId, version: String(params.version), }) - return `/api/tools/deployments/version?${qs.toString()}` + return internalRoute`/api/tools/deployments/version`.withQuery(qs) }, method: 'GET', headers: () => ({ 'Content-Type': 'application/json' }), diff --git a/apps/sim/tools/deployments/list_versions.ts b/apps/sim/tools/deployments/list_versions.ts index ce349bd6723..9b0a7094d56 100644 --- a/apps/sim/tools/deployments/list_versions.ts +++ b/apps/sim/tools/deployments/list_versions.ts @@ -1,3 +1,4 @@ +import { internalRoute } from '@/lib/core/utils/internal-route' import type { DeploymentsListVersionsParams, DeploymentsListVersionsResponse, @@ -30,7 +31,7 @@ export const deploymentsListVersionsTool: ToolConfig< throw new Error('workspaceId is required in execution context') } const qs = new URLSearchParams({ workflowId: params.workflowId, workspaceId }) - return `/api/tools/deployments/versions?${qs.toString()}` + return internalRoute`/api/tools/deployments/versions`.withQuery(qs) }, method: 'GET', headers: () => ({ 'Content-Type': 'application/json' }), diff --git a/apps/sim/tools/google_drive/upload.ts b/apps/sim/tools/google_drive/upload.ts index 03cb3ed44f8..7c7b1556b50 100644 --- a/apps/sim/tools/google_drive/upload.ts +++ b/apps/sim/tools/google_drive/upload.ts @@ -1,4 +1,5 @@ import { createLogger } from '@sim/logger' +import { internalRoute } from '@/lib/core/utils/internal-route' import type { GoogleDriveToolParams, GoogleDriveUploadResponse } from '@/tools/google_drive/types' import { ALL_FILE_FIELDS, @@ -70,7 +71,7 @@ export const uploadTool: ToolConfig { // Use custom API route if file is provided, otherwise use Google Drive API directly if (params.file) { - return '/api/tools/google_drive/upload' + return internalRoute`/api/tools/google_drive/upload` } return 'https://www.googleapis.com/drive/v3/files?supportsAllDrives=true' }, diff --git a/apps/sim/tools/grafana/update_alert_rule.ts b/apps/sim/tools/grafana/update_alert_rule.ts index 1afe913709d..92545a0e48f 100644 --- a/apps/sim/tools/grafana/update_alert_rule.ts +++ b/apps/sim/tools/grafana/update_alert_rule.ts @@ -131,7 +131,7 @@ export const updateAlertRuleTool: ToolConfig '/api/tools/grafana/update_alert_rule', + url: '/api/tools/grafana/update_alert_rule', method: 'POST', headers: () => ({ 'Content-Type': 'application/json' }), body: (params) => ({ diff --git a/apps/sim/tools/grafana/update_dashboard.ts b/apps/sim/tools/grafana/update_dashboard.ts index 70026f73d6f..13b8b5e4993 100644 --- a/apps/sim/tools/grafana/update_dashboard.ts +++ b/apps/sim/tools/grafana/update_dashboard.ts @@ -85,7 +85,7 @@ export const updateDashboardTool: ToolConfig '/api/tools/grafana/update_dashboard', + url: '/api/tools/grafana/update_dashboard', method: 'POST', headers: () => ({ 'Content-Type': 'application/json' }), body: (params) => ({ diff --git a/apps/sim/tools/grafana/update_folder.ts b/apps/sim/tools/grafana/update_folder.ts index 3c4b16c0889..ea34ed7c3b9 100644 --- a/apps/sim/tools/grafana/update_folder.ts +++ b/apps/sim/tools/grafana/update_folder.ts @@ -41,7 +41,7 @@ export const updateFolderTool: ToolConfig '/api/tools/grafana/update_folder', + url: '/api/tools/grafana/update_folder', method: 'POST', headers: () => ({ 'Content-Type': 'application/json' }), body: (params) => ({ diff --git a/apps/sim/tools/index.test.ts b/apps/sim/tools/index.test.ts index ad87e5608d7..62d0d2b9a2c 100644 --- a/apps/sim/tools/index.test.ts +++ b/apps/sim/tools/index.test.ts @@ -351,6 +351,7 @@ vi.mock('@/tools/utils.server', async (importOriginal) => { }) import type { QueryClient } from '@tanstack/react-query' +import { internalRoute } from '@/lib/core/utils/internal-route' import * as getQueryClientModule from '@/app/_shell/providers/get-query-client' import { executeTool, postProcessToolOutput } from '@/tools' import { tools } from '@/tools/registry' @@ -3158,7 +3159,7 @@ describe('Automatic Internal Route Detection', () => { resourceId: { type: 'string', required: true }, }, request: { - url: (params: any) => `/api/resources/${params.resourceId}`, + url: (params: any) => internalRoute`/api/resources/${params.resourceId}`, method: 'GET', headers: () => ({ 'Content-Type': 'application/json' }), }, @@ -3196,6 +3197,8 @@ describe('Automatic Internal Route Detection', () => { expect(result.success).toBe(true) expect(result.output.result).toBe('Dynamic internal route success') expect(mockTool.transformResponse).toHaveBeenCalled() + expect(global.fetch).toHaveBeenCalled() + expect(mockSecureFetchWithPinnedIP).not.toHaveBeenCalled() Object.assign(tools, originalTools) }) @@ -4593,6 +4596,32 @@ describe('MCP Tool Execution', () => { }) describe('Tool request retries', () => { + const internalRetryTool = { + id: 'test_internal_retry', + name: 'Test Internal Retry Tool', + description: 'An internal tool used to exercise retry pacing', + version: '1.0.0', + params: {}, + request: { + url: '/api/test', + method: 'GET', + headers: () => ({ 'Content-Type': 'application/json' }), + retry: { enabled: true, retryIdempotentOnly: true }, + }, + transformResponse: async (response: Response) => ({ + success: response.ok, + output: { status: response.status }, + }), + } + + beforeEach(() => { + ;(tools as Record).test_internal_retry = internalRetryTool + }) + + afterEach(() => { + ;(tools as Record).test_internal_retry = undefined + }) + function makeJsonResponse( status: number, body: unknown, @@ -4611,7 +4640,7 @@ describe('MCP Tool Execution', () => { } } - it('retries on 5xx responses for http_request', async () => { + it('retries on 5xx responses', async () => { global.fetch = Object.assign( vi .fn() @@ -4620,8 +4649,7 @@ describe('MCP Tool Execution', () => { { preconnect: vi.fn() } ) as typeof fetch - const result = await executeTool('http_request', { - url: '/api/test', + const result = await executeTool('test_internal_retry', { method: 'GET', retries: 2, retryDelayMs: 0, @@ -4639,8 +4667,7 @@ describe('MCP Tool Execution', () => { { preconnect: vi.fn() } ) as typeof fetch - const result = await executeTool('http_request', { - url: '/api/test', + const result = await executeTool('test_internal_retry', { method: 'GET', }) @@ -4648,14 +4675,13 @@ describe('MCP Tool Execution', () => { expect(result.success).toBe(false) }) - it('stops retrying after max attempts for http_request', async () => { + it('stops retrying after max attempts', async () => { global.fetch = Object.assign( vi.fn().mockResolvedValue(makeJsonResponse(502, { error: 'bad gateway' })), { preconnect: vi.fn() } ) as typeof fetch - const result = await executeTool('http_request', { - url: '/api/test', + const result = await executeTool('test_internal_retry', { method: 'GET', retries: 2, retryDelayMs: 0, @@ -4666,14 +4692,13 @@ describe('MCP Tool Execution', () => { expect(result.success).toBe(false) }) - it('does not retry on 4xx responses for http_request', async () => { + it('does not retry on 4xx responses', async () => { global.fetch = Object.assign( vi.fn().mockResolvedValue(makeJsonResponse(400, { error: 'bad request' })), { preconnect: vi.fn() } ) as typeof fetch - const result = await executeTool('http_request', { - url: '/api/test', + const result = await executeTool('test_internal_retry', { method: 'GET', retries: 5, retryDelayMs: 0, @@ -4693,8 +4718,7 @@ describe('MCP Tool Execution', () => { { preconnect: vi.fn() } ) as typeof fetch - const result = await executeTool('http_request', { - url: '/api/test', + const result = await executeTool('test_internal_retry', { method: 'POST', retries: 2, retryDelayMs: 0, @@ -4714,8 +4738,7 @@ describe('MCP Tool Execution', () => { { preconnect: vi.fn() } ) as typeof fetch - const result = await executeTool('http_request', { - url: '/api/test', + const result = await executeTool('test_internal_retry', { method: 'POST', retries: 1, retryNonIdempotent: true, @@ -4728,7 +4751,7 @@ describe('MCP Tool Execution', () => { expect((result.output as any).status).toBe(200) }) - it('retries on timeout errors for http_request', async () => { + it('retries on timeout errors', async () => { const abortError = Object.assign(new Error('Aborted'), { name: 'AbortError' }) global.fetch = Object.assign( vi @@ -4738,8 +4761,7 @@ describe('MCP Tool Execution', () => { { preconnect: vi.fn() } ) as typeof fetch - const result = await executeTool('http_request', { - url: '/api/test', + const result = await executeTool('test_internal_retry', { method: 'GET', retries: 1, retryDelayMs: 0, @@ -4761,8 +4783,7 @@ describe('MCP Tool Execution', () => { { preconnect: vi.fn() } ) as typeof fetch - const result = await executeTool('http_request', { - url: '/api/test', + const result = await executeTool('test_internal_retry', { method: 'GET', retries: 3, retryMaxDelayMs: 5000, @@ -4783,8 +4804,7 @@ describe('MCP Tool Execution', () => { { preconnect: vi.fn() } ) as typeof fetch - const result = await executeTool('http_request', { - url: '/api/test', + const result = await executeTool('test_internal_retry', { method: 'GET', retries: 3, retryMaxDelayMs: 40000, @@ -4805,8 +4825,7 @@ describe('MCP Tool Execution', () => { { preconnect: vi.fn() } ) as typeof fetch - const result = await executeTool('http_request', { - url: '/api/test', + const result = await executeTool('test_internal_retry', { method: 'GET', retries: 2, retryDelayMs: 0, @@ -4817,7 +4836,7 @@ describe('MCP Tool Execution', () => { expect(result.success).toBe(true) }) - it('retries on ETIMEDOUT errors for http_request', async () => { + it('retries on ETIMEDOUT errors', async () => { const etimedoutError = Object.assign(new Error('connect ETIMEDOUT 10.0.0.1:443'), { code: 'ETIMEDOUT', }) @@ -4829,8 +4848,7 @@ describe('MCP Tool Execution', () => { { preconnect: vi.fn() } ) as typeof fetch - const result = await executeTool('http_request', { - url: '/api/test', + const result = await executeTool('test_internal_retry', { method: 'GET', retries: 1, retryDelayMs: 0, diff --git a/apps/sim/tools/knowledge/create_document.ts b/apps/sim/tools/knowledge/create_document.ts index 002cfe3e95a..489edcd1bae 100644 --- a/apps/sim/tools/knowledge/create_document.ts +++ b/apps/sim/tools/knowledge/create_document.ts @@ -1,3 +1,4 @@ +import { internalRoute } from '@/lib/core/utils/internal-route' import { selectKnowledgeDocumentWriteSecretProvenance } from '@/tools/knowledge/secret-provenance' import { inferDocumentFileInfo, @@ -48,7 +49,7 @@ export const knowledgeCreateDocumentTool: ToolConfig `/api/knowledge/${params.knowledgeBaseId}/documents`, + url: (params) => internalRoute`/api/knowledge/${params.knowledgeBaseId}/documents`, method: 'POST', secretProvenance: { request: selectKnowledgeDocumentWriteSecretProvenance, diff --git a/apps/sim/tools/knowledge/delete_chunk.ts b/apps/sim/tools/knowledge/delete_chunk.ts index 3bc759af63f..ca6917c8912 100644 --- a/apps/sim/tools/knowledge/delete_chunk.ts +++ b/apps/sim/tools/knowledge/delete_chunk.ts @@ -1,3 +1,4 @@ +import { internalRoute } from '@/lib/core/utils/internal-route' import type { KnowledgeDeleteChunkResponse } from '@/tools/knowledge/types' import type { ToolConfig } from '@/tools/types' @@ -30,7 +31,7 @@ export const knowledgeDeleteChunkTool: ToolConfig - `/api/knowledge/${params.knowledgeBaseId}/documents/${params.documentId}/chunks/${params.chunkId}`, + internalRoute`/api/knowledge/${params.knowledgeBaseId}/documents/${params.documentId}/chunks/${params.chunkId}`, method: 'DELETE', headers: () => ({ 'Content-Type': 'application/json', diff --git a/apps/sim/tools/knowledge/delete_document.ts b/apps/sim/tools/knowledge/delete_document.ts index 39493e38283..8557a595bdc 100644 --- a/apps/sim/tools/knowledge/delete_document.ts +++ b/apps/sim/tools/knowledge/delete_document.ts @@ -1,3 +1,4 @@ +import { internalRoute } from '@/lib/core/utils/internal-route' import type { KnowledgeDeleteDocumentResponse } from '@/tools/knowledge/types' import type { ToolConfig } from '@/tools/types' @@ -23,7 +24,8 @@ export const knowledgeDeleteDocumentTool: ToolConfig `/api/knowledge/${params.knowledgeBaseId}/documents/${params.documentId}`, + url: (params) => + internalRoute`/api/knowledge/${params.knowledgeBaseId}/documents/${params.documentId}`, method: 'DELETE', headers: () => ({ 'Content-Type': 'application/json', diff --git a/apps/sim/tools/knowledge/get_connector.ts b/apps/sim/tools/knowledge/get_connector.ts index 9ae1e03e548..49cd46dd22a 100644 --- a/apps/sim/tools/knowledge/get_connector.ts +++ b/apps/sim/tools/knowledge/get_connector.ts @@ -1,3 +1,4 @@ +import { internalRoute } from '@/lib/core/utils/internal-route' import type { KnowledgeGetConnectorResponse } from '@/tools/knowledge/types' import type { ToolConfig } from '@/tools/types' @@ -24,7 +25,8 @@ export const knowledgeGetConnectorTool: ToolConfig `/api/knowledge/${params.knowledgeBaseId}/connectors/${params.connectorId}`, + url: (params) => + internalRoute`/api/knowledge/${params.knowledgeBaseId}/connectors/${params.connectorId}`, method: 'GET', headers: () => ({ 'Content-Type': 'application/json', diff --git a/apps/sim/tools/knowledge/get_document.ts b/apps/sim/tools/knowledge/get_document.ts index 2ac840ca032..686584e1af6 100644 --- a/apps/sim/tools/knowledge/get_document.ts +++ b/apps/sim/tools/knowledge/get_document.ts @@ -1,3 +1,4 @@ +import { internalRoute } from '@/lib/core/utils/internal-route' import type { KnowledgeGetDocumentResponse } from '@/tools/knowledge/types' import type { ToolConfig } from '@/tools/types' @@ -24,7 +25,8 @@ export const knowledgeGetDocumentTool: ToolConfig `/api/knowledge/${params.knowledgeBaseId}/documents/${params.documentId}`, + url: (params) => + internalRoute`/api/knowledge/${params.knowledgeBaseId}/documents/${params.documentId}`, method: 'GET', secretProvenance: { response: { incomplete: 'reject' } }, headers: () => ({ diff --git a/apps/sim/tools/knowledge/list_chunks.ts b/apps/sim/tools/knowledge/list_chunks.ts index 7198b63fa6b..f0ec8803cc1 100644 --- a/apps/sim/tools/knowledge/list_chunks.ts +++ b/apps/sim/tools/knowledge/list_chunks.ts @@ -1,3 +1,4 @@ +import { internalRoute } from '@/lib/core/utils/internal-route' import type { KnowledgeListChunksResponse } from '@/tools/knowledge/types' import type { ToolConfig } from '@/tools/types' @@ -55,8 +56,9 @@ export const knowledgeListChunksTool: ToolConfig `/api/knowledge/${params.knowledgeBaseId}/connectors`, + url: (params) => internalRoute`/api/knowledge/${params.knowledgeBaseId}/connectors`, method: 'GET', headers: () => ({ 'Content-Type': 'application/json', diff --git a/apps/sim/tools/knowledge/list_documents.ts b/apps/sim/tools/knowledge/list_documents.ts index 6bf491a6521..04ca9684186 100644 --- a/apps/sim/tools/knowledge/list_documents.ts +++ b/apps/sim/tools/knowledge/list_documents.ts @@ -1,3 +1,4 @@ +import { internalRoute } from '@/lib/core/utils/internal-route' import type { KnowledgeListDocumentsResponse } from '@/tools/knowledge/types' import type { ToolConfig } from '@/tools/types' @@ -47,8 +48,9 @@ export const knowledgeListDocumentsTool: ToolConfig = }, request: { - url: (params) => `/api/knowledge/${params.knowledgeBaseId}/tag-definitions`, + url: (params) => internalRoute`/api/knowledge/${params.knowledgeBaseId}/tag-definitions`, method: 'GET', headers: () => ({ 'Content-Type': 'application/json', diff --git a/apps/sim/tools/knowledge/search.ts b/apps/sim/tools/knowledge/search.ts index 33f7cd3b6cd..553c87e545d 100644 --- a/apps/sim/tools/knowledge/search.ts +++ b/apps/sim/tools/knowledge/search.ts @@ -85,7 +85,7 @@ export const knowledgeSearchTool: ToolConfig = { }, request: { - url: () => '/api/knowledge/search', + url: '/api/knowledge/search', method: 'POST', modelInput: { mode: 'private-provenance', diff --git a/apps/sim/tools/knowledge/trigger_sync.ts b/apps/sim/tools/knowledge/trigger_sync.ts index 127c37a0c1a..4796bcf3775 100644 --- a/apps/sim/tools/knowledge/trigger_sync.ts +++ b/apps/sim/tools/knowledge/trigger_sync.ts @@ -1,3 +1,4 @@ +import { internalRoute } from '@/lib/core/utils/internal-route' import type { KnowledgeTriggerSyncResponse } from '@/tools/knowledge/types' import type { ToolConfig } from '@/tools/types' @@ -24,7 +25,7 @@ export const knowledgeTriggerSyncTool: ToolConfig - `/api/knowledge/${params.knowledgeBaseId}/connectors/${params.connectorId}/sync`, + internalRoute`/api/knowledge/${params.knowledgeBaseId}/connectors/${params.connectorId}/sync`, method: 'POST', headers: () => ({ 'Content-Type': 'application/json', diff --git a/apps/sim/tools/knowledge/update_chunk.ts b/apps/sim/tools/knowledge/update_chunk.ts index 7333567bff6..141228b8e44 100644 --- a/apps/sim/tools/knowledge/update_chunk.ts +++ b/apps/sim/tools/knowledge/update_chunk.ts @@ -1,3 +1,4 @@ +import { internalRoute } from '@/lib/core/utils/internal-route' import type { KnowledgeUpdateChunkResponse } from '@/tools/knowledge/types' import type { ToolConfig } from '@/tools/types' @@ -42,7 +43,7 @@ export const knowledgeUpdateChunkTool: ToolConfig - `/api/knowledge/${params.knowledgeBaseId}/documents/${params.documentId}/chunks/${params.chunkId}`, + internalRoute`/api/knowledge/${params.knowledgeBaseId}/documents/${params.documentId}/chunks/${params.chunkId}`, method: 'PUT', secretProvenance: { request: (params) => diff --git a/apps/sim/tools/knowledge/upload_chunk.ts b/apps/sim/tools/knowledge/upload_chunk.ts index 5701bac0839..5b07fd8634b 100644 --- a/apps/sim/tools/knowledge/upload_chunk.ts +++ b/apps/sim/tools/knowledge/upload_chunk.ts @@ -1,3 +1,4 @@ +import { internalRoute } from '@/lib/core/utils/internal-route' import type { KnowledgeUploadChunkResponse } from '@/tools/knowledge/types' import type { ToolConfig } from '@/tools/types' @@ -30,7 +31,7 @@ export const knowledgeUploadChunkTool: ToolConfig - `/api/knowledge/${params.knowledgeBaseId}/documents/${params.documentId}/chunks`, + internalRoute`/api/knowledge/${params.knowledgeBaseId}/documents/${params.documentId}/chunks`, method: 'POST', secretProvenance: { request: () => [{ key: 'chunk-content', inputPaths: [['content']] }], diff --git a/apps/sim/tools/knowledge/upsert_document.ts b/apps/sim/tools/knowledge/upsert_document.ts index 2cfec98070e..8e7359e6d70 100644 --- a/apps/sim/tools/knowledge/upsert_document.ts +++ b/apps/sim/tools/knowledge/upsert_document.ts @@ -1,3 +1,4 @@ +import { internalRoute } from '@/lib/core/utils/internal-route' import { selectKnowledgeDocumentWriteSecretProvenance } from '@/tools/knowledge/secret-provenance' import { inferDocumentFileInfo, @@ -60,7 +61,7 @@ export const knowledgeUpsertDocumentTool: ToolConfig< }, request: { - url: (params) => `/api/knowledge/${params.knowledgeBaseId}/documents/upsert`, + url: (params) => internalRoute`/api/knowledge/${params.knowledgeBaseId}/documents/upsert`, method: 'POST', secretProvenance: { request: selectKnowledgeDocumentWriteSecretProvenance, diff --git a/apps/sim/tools/llm/chat.ts b/apps/sim/tools/llm/chat.ts index 9d4362be13a..32a81862bc0 100644 --- a/apps/sim/tools/llm/chat.ts +++ b/apps/sim/tools/llm/chat.ts @@ -128,7 +128,7 @@ export const llmChatTool: ToolConfig = { }, request: { - url: () => '/api/providers', + url: '/api/providers', method: 'POST', headers: () => ({ 'Content-Type': 'application/json', diff --git a/apps/sim/tools/logs/get_execution.ts b/apps/sim/tools/logs/get_execution.ts index a62eef0525b..69fc29771ca 100644 --- a/apps/sim/tools/logs/get_execution.ts +++ b/apps/sim/tools/logs/get_execution.ts @@ -1,3 +1,4 @@ +import { internalRoute } from '@/lib/core/utils/internal-route' import type { LogsGetExecutionParams, LogsGetExecutionResponse } from '@/tools/logs/types' import type { ToolConfig } from '@/tools/types' @@ -18,7 +19,7 @@ export const logsGetExecutionTool: ToolConfig `/api/logs/execution/${encodeURIComponent(params.executionId)}`, + url: (params) => internalRoute`/api/logs/execution/${params.executionId}`, method: 'GET', headers: () => ({ 'Content-Type': 'application/json', diff --git a/apps/sim/tools/logs/get_log.ts b/apps/sim/tools/logs/get_log.ts index 92e41e79b83..8730cb3c286 100644 --- a/apps/sim/tools/logs/get_log.ts +++ b/apps/sim/tools/logs/get_log.ts @@ -1,3 +1,4 @@ +import { internalRoute } from '@/lib/core/utils/internal-route' import type { LogsGetParams, LogsGetResponse } from '@/tools/logs/types' import type { ToolConfig } from '@/tools/types' @@ -22,8 +23,7 @@ export const logsGetTool: ToolConfig = { if (!workspaceId) { throw new Error('workspaceId is required in execution context') } - const qs = new URLSearchParams({ workspaceId }) - return `/api/logs/${encodeURIComponent(params.id)}?${qs.toString()}` + return internalRoute`/api/logs/${params.id}`.withQuery({ workspaceId }) }, method: 'GET', headers: () => ({ diff --git a/apps/sim/tools/logs/get_run_details.ts b/apps/sim/tools/logs/get_run_details.ts index 6326e3afcbd..00b2c158dc1 100644 --- a/apps/sim/tools/logs/get_run_details.ts +++ b/apps/sim/tools/logs/get_run_details.ts @@ -1,5 +1,6 @@ import type { WorkflowLogDetail } from '@/lib/api/contracts/logs' import { dollarsToCredits } from '@/lib/billing/credits/conversion' +import { internalRoute } from '@/lib/core/utils/internal-route' import type { LogsGetRunDetailsParams, LogsGetRunDetailsResponse } from '@/tools/logs/types' import type { ToolConfig } from '@/tools/types' @@ -26,8 +27,7 @@ export const logsGetRunDetailsTool: ToolConfig ({ diff --git a/apps/sim/tools/logs/query.ts b/apps/sim/tools/logs/query.ts index 8ea660ee29a..a0de88813d4 100644 --- a/apps/sim/tools/logs/query.ts +++ b/apps/sim/tools/logs/query.ts @@ -1,3 +1,4 @@ +import { internalRoute } from '@/lib/core/utils/internal-route' import type { LogsQueryParams, LogsQueryResponse } from '@/tools/logs/types' import type { ToolConfig } from '@/tools/types' @@ -97,7 +98,7 @@ export const logsQueryTool: ToolConfig = { if (params.limit !== undefined && params.limit !== null) { qs.set('limit', String(params.limit)) } - return `/api/logs?${qs.toString()}` + return internalRoute`/api/logs`.withQuery(qs) }, method: 'GET', headers: () => ({ diff --git a/apps/sim/tools/logs/query_runs.ts b/apps/sim/tools/logs/query_runs.ts index ae73a32a73e..048b83e077c 100644 --- a/apps/sim/tools/logs/query_runs.ts +++ b/apps/sim/tools/logs/query_runs.ts @@ -1,4 +1,5 @@ import { creditsToDollars } from '@/lib/billing/credits/conversion' +import { internalRoute } from '@/lib/core/utils/internal-route' import type { LogsQueryRunsParams, LogsQueryRunsResponse } from '@/tools/logs/types' import type { ToolConfig } from '@/tools/types' @@ -130,7 +131,7 @@ export const logsQueryRunsTool: ToolConfig ({ diff --git a/apps/sim/tools/memory/delete.ts b/apps/sim/tools/memory/delete.ts index b32d1fcbf19..89366e8336e 100644 --- a/apps/sim/tools/memory/delete.ts +++ b/apps/sim/tools/memory/delete.ts @@ -1,3 +1,4 @@ +import { internalRoute } from '@/lib/core/utils/internal-route' import type { MemoryResponse } from '@/tools/memory/types' import type { ToolConfig } from '@/tools/types' @@ -36,11 +37,7 @@ export const memoryDeleteTool: ToolConfig = { throw new Error('conversationId or id is required') } - const url = new URL('/api/memory', 'http://dummy') - url.searchParams.set('workspaceId', workspaceId) - url.searchParams.set('conversationId', conversationId) - - return url.pathname + url.search + return internalRoute`/api/memory`.withQuery({ workspaceId, conversationId }) }, method: 'DELETE', headers: () => ({ diff --git a/apps/sim/tools/memory/get.test.ts b/apps/sim/tools/memory/get.test.ts index 862ca367d1d..80f9671206c 100644 --- a/apps/sim/tools/memory/get.test.ts +++ b/apps/sim/tools/memory/get.test.ts @@ -2,6 +2,7 @@ * @vitest-environment node */ import { describe, expect, it } from 'vitest' +import type { InternalRoute } from '@/lib/core/utils/internal-route' import { memoryGetTool } from '@/tools/memory/get' interface MemoryGetParams { @@ -13,14 +14,14 @@ interface MemoryGetParams { } describe('memoryGetTool', () => { - const buildUrl = memoryGetTool.request.url as (params: MemoryGetParams) => string + const buildRoute = memoryGetTool.request.url as (params: MemoryGetParams) => InternalRoute const transformResponse = memoryGetTool.transformResponse! it('builds an exact memory lookup URL', () => { - const url = buildUrl({ + const url = buildRoute({ _context: { workspaceId: 'workspace-1' }, conversationId: 'user-123', - }) + }).path expect(url).toBe('/api/memory/user-123?workspaceId=workspace-1') expect(url).not.toContain('query=') @@ -28,10 +29,10 @@ describe('memoryGetTool', () => { }) it('encodes legacy id values in the path', () => { - const url = buildUrl({ + const url = buildRoute({ _context: { workspaceId: 'workspace-1' }, id: 'team/user 123', - }) + }).path expect(url).toBe('/api/memory/team%2Fuser%20123?workspaceId=workspace-1') }) diff --git a/apps/sim/tools/memory/get.ts b/apps/sim/tools/memory/get.ts index 7d523ecfcf6..a238558f0ec 100644 --- a/apps/sim/tools/memory/get.ts +++ b/apps/sim/tools/memory/get.ts @@ -1,3 +1,4 @@ +import { internalRoute } from '@/lib/core/utils/internal-route' import type { MemoryResponse } from '@/tools/memory/types' import type { ToolConfig } from '@/tools/types' @@ -35,10 +36,7 @@ export const memoryGetTool: ToolConfig = { if (!conversationId) { throw new Error('conversationId or id is required') } - const url = new URL(`/api/memory/${encodeURIComponent(conversationId)}`, 'http://dummy') - url.searchParams.set('workspaceId', workspaceId) - - return url.pathname + url.search + return internalRoute`/api/memory/${conversationId}`.withQuery({ workspaceId }) }, method: 'GET', secretProvenance: { response: { incomplete: 'reject' } }, diff --git a/apps/sim/tools/memory/get_all.ts b/apps/sim/tools/memory/get_all.ts index cf2423f023d..f83238a1e0e 100644 --- a/apps/sim/tools/memory/get_all.ts +++ b/apps/sim/tools/memory/get_all.ts @@ -1,3 +1,4 @@ +import { internalRoute } from '@/lib/core/utils/internal-route' import type { MemoryResponse } from '@/tools/memory/types' import type { ToolConfig } from '@/tools/types' @@ -16,7 +17,7 @@ export const memoryGetAllTool: ToolConfig = { throw new Error('workspaceId is required in execution context') } - return `/api/memory?workspaceId=${encodeURIComponent(workspaceId)}` + return internalRoute`/api/memory`.withQuery({ workspaceId }) }, method: 'GET', secretProvenance: { response: { incomplete: 'reject' } }, diff --git a/apps/sim/tools/microsoft_teams/delete_chat_message.ts b/apps/sim/tools/microsoft_teams/delete_chat_message.ts index 4ba46075944..d4fe7f86abc 100644 --- a/apps/sim/tools/microsoft_teams/delete_chat_message.ts +++ b/apps/sim/tools/microsoft_teams/delete_chat_message.ts @@ -1,3 +1,4 @@ +import { internalRoute } from '@/lib/core/utils/internal-route' import type { MicrosoftTeamsDeleteMessageParams, MicrosoftTeamsDeleteResponse, @@ -53,7 +54,7 @@ export const deleteChatMessageTool: ToolConfig< if (!chatId || !messageId) { throw new Error('Chat ID and Message ID are required') } - return '/api/tools/microsoft_teams/delete_chat_message' + return internalRoute`/api/tools/microsoft_teams/delete_chat_message` }, method: 'POST', headers: (params) => { diff --git a/apps/sim/tools/microsoft_teams/write_channel.ts b/apps/sim/tools/microsoft_teams/write_channel.ts index c4bffeb35e9..922190ce361 100644 --- a/apps/sim/tools/microsoft_teams/write_channel.ts +++ b/apps/sim/tools/microsoft_teams/write_channel.ts @@ -1,3 +1,4 @@ +import { internalRoute } from '@/lib/core/utils/internal-route' import type { MicrosoftTeamsToolParams, MicrosoftTeamsWriteResponse, @@ -75,13 +76,13 @@ export const writeChannelTool: ToolConfig 0) { - return '/api/tools/microsoft_teams/write_channel' + return internalRoute`/api/tools/microsoft_teams/write_channel` } // If content contains mentions, use custom API route for mention resolution const hasMentions = /[^<]+<\/at>/i.test(params.content || '') if (hasMentions) { - return '/api/tools/microsoft_teams/write_channel' + return internalRoute`/api/tools/microsoft_teams/write_channel` } const encodedTeamId = encodeURIComponent(teamId) diff --git a/apps/sim/tools/microsoft_teams/write_chat.ts b/apps/sim/tools/microsoft_teams/write_chat.ts index e5c9b0c2c89..d6f24b2bbaa 100644 --- a/apps/sim/tools/microsoft_teams/write_chat.ts +++ b/apps/sim/tools/microsoft_teams/write_chat.ts @@ -1,3 +1,4 @@ +import { internalRoute } from '@/lib/core/utils/internal-route' import type { MicrosoftTeamsToolParams, MicrosoftTeamsWriteResponse, @@ -63,13 +64,13 @@ export const writeChatTool: ToolConfig 0) { - return '/api/tools/microsoft_teams/write_chat' + return internalRoute`/api/tools/microsoft_teams/write_chat` } // If content contains mentions, use custom API route for mention resolution const hasMentions = /[^<]+<\/at>/i.test(params.content || '') if (hasMentions) { - return '/api/tools/microsoft_teams/write_chat' + return internalRoute`/api/tools/microsoft_teams/write_chat` } return `https://graph.microsoft.com/v1.0/chats/${encodeURIComponent(chatId)}/messages` diff --git a/apps/sim/tools/onedrive/upload.ts b/apps/sim/tools/onedrive/upload.ts index 9478fdca285..fe6710ff91b 100644 --- a/apps/sim/tools/onedrive/upload.ts +++ b/apps/sim/tools/onedrive/upload.ts @@ -1,4 +1,5 @@ import { createLogger } from '@sim/logger' +import { internalRoute } from '@/lib/core/utils/internal-route' import type { OneDriveToolParams, OneDriveUploadResponse } from '@/tools/onedrive/types' import type { ToolConfig } from '@/tools/types' @@ -60,7 +61,7 @@ export const uploadTool: ToolConfig const isExcelFile = params.mimeType === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' if (params.file || isExcelFile) { - return '/api/tools/onedrive/upload' + return internalRoute`/api/tools/onedrive/upload` } let fileName = params.fileName || 'untitled' diff --git a/apps/sim/tools/params.ts b/apps/sim/tools/params.ts index ebccb601991..5f959f70e58 100644 --- a/apps/sim/tools/params.ts +++ b/apps/sim/tools/params.ts @@ -1,4 +1,5 @@ import { createLogger } from '@sim/logger' +import { internalRoute } from '@/lib/core/utils/internal-route' import { extractInputFieldsFromBlocks } from '@/lib/workflows/input-format' import { buildCanonicalIndex, @@ -776,10 +777,10 @@ async function fetchWorkflowInputFields( workflowId: string ): Promise> { try { - const { buildAuthHeaders, buildAPIUrl } = await import('@/executor/utils/http') + const { buildAuthHeaders, buildInternalApiUrl } = await import('@/executor/utils/http') const headers = await buildAuthHeaders() - const url = buildAPIUrl(`/api/workflows/${workflowId}`) + const url = buildInternalApiUrl(internalRoute`/api/workflows/${workflowId}`) const response = await fetch(url.toString(), { headers }) if (!response.ok) { diff --git a/apps/sim/tools/request-transport.test.ts b/apps/sim/tools/request-transport.test.ts index bfa21b1fc76..a562d22fea8 100644 --- a/apps/sim/tools/request-transport.test.ts +++ b/apps/sim/tools/request-transport.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest' +import { internalRoute } from '@/lib/core/utils/internal-route' import { PRIVATE_MODEL_INPUT_PROVENANCE_HEADER } from '@/lib/execution/model-input-provenance' import { RESOLVED_SECRET_PROVENANCE_FIELD, @@ -59,3 +60,86 @@ describe('private-provenance tool registry invariant', () => { } ) }) + +function buildProbeTool(request: Partial): ToolConfig { + return { + id: 'probe_tool', + name: 'Probe', + description: 'probe', + version: '1.0.0', + params: { url: { type: 'string', visibility: 'user-or-llm' } }, + request: { url: '/api/probe', method: 'GET', headers: () => ({}), ...request }, + } as ToolConfig +} + +describe('internal transport selection', () => { + it('trusts a static internal URL from the tool config', () => { + expect(prepareToolRequest(buildProbeTool({ url: '/api/probe' }), {}).isInternalRoute).toBe(true) + }) + + it('trusts a builder that returns an internal route', () => { + const prepared = prepareToolRequest( + buildProbeTool({ url: (p: Record) => internalRoute`/api/table/${p.id}/rows` }), + { id: 't-1' } + ) + + expect(prepared.isInternalRoute).toBe(true) + expect(prepared.url).toBe('/api/table/t-1/rows') + }) + + it('does not trust a bare internal path a builder returned', () => { + const prepared = prepareToolRequest( + buildProbeTool({ url: (p: Record) => p.url }), + { url: '/api/auth/oauth/token' } + ) + + expect(prepared.isInternalRoute).toBe(false) + }) + + it('rejects private provenance when the tool is not internally routed', () => { + const tool = buildProbeTool({ + url: (p: Record) => p.url, + body: () => ({ probe: true }), + modelInput: { mode: 'private-provenance', inputPaths: () => [] }, + }) + + expect(() => + prepareToolRequest(tool, { url: '/api/probe' }, new ResolvedSecretTraceRegistry()) + ).toThrow(/internal routes/) + }) +}) + +describe('caller-supplied URLs never reach the internal transport', () => { + it.each(['http_request', 'webhook_request'])('%s cannot route a relative URL inward', (id) => { + const prepared = prepareToolRequest(tools[id], { + url: '/api/auth/oauth/token', + method: 'POST', + body: { credentialId: 'cred-1' }, + }) + + expect(prepared.url).toMatch(/^\/api\//) + expect(prepared.isInternalRoute).toBe(false) + }) + + it.each([ + ['grafana_list_folders', { baseUrl: '', serviceAccountToken: 't' }], + ['dynatrace_list_problems', { environmentUrl: '', apiToken: 't' }], + ])('%s stays external when its host param is blank', (id, params) => { + const prepared = prepareToolRequest(tools[id], params) + + expect(prepared.url).toMatch(/^\/api\//) + expect(prepared.isInternalRoute).toBe(false) + }) + + const workspaceContext = { _context: { workspaceId: 'ws-1', userId: 'user-1' } } + + it.each([ + ['function_execute', { code: 'return 1' }], + ['knowledge_search', { knowledgeBaseIds: ['kb-1'], query: 'q' }], + ['memory_get_all', workspaceContext], + ['table_list', workspaceContext], + ['workflow_executor', { workflowId: 'wf-1' }], + ])('%s still routes internally', (id, params) => { + expect(prepareToolRequest(tools[id], params).isInternalRoute).toBe(true) + }) +}) diff --git a/apps/sim/tools/request-transport.ts b/apps/sim/tools/request-transport.ts index 64d1f3c20ab..325a4ecbce4 100644 --- a/apps/sim/tools/request-transport.ts +++ b/apps/sim/tools/request-transport.ts @@ -1,6 +1,7 @@ import { isDeepStrictEqual } from 'node:util' import { isPlainRecord } from '@sim/utils/object' import { getMaxExecutionTimeout } from '@/lib/core/execution-limits' +import { InternalRoute } from '@/lib/core/utils/internal-route' import { addModelInputProvenanceToRequest, createModelInputProvenanceRequestMetadata, @@ -121,8 +122,32 @@ export function projectToolModelInputParams( } } +/** + * Resolves a tool's request URL and whether it may use the pre-authenticated internal transport. + * + * Internal routing follows the tool's source, never the resolved string. A static config URL is a + * source literal that no param can influence; a builder must return an {@link InternalRoute}, + * which params cannot construct. A builder returning a bare `/api/...` string is therefore + * external — the HTTP Request tool returns its caller-supplied `url` verbatim, and a self-hosted + * integration with a blank host param collapses `${host}/api/x` to `/api/x`. + */ +function resolveToolUrl( + tool: ToolConfig, + params: Record +): { url: string; isInternalRoute: boolean } { + const configured = tool.request.url + if (typeof configured === 'string') { + return { url: configured, isInternalRoute: configured.startsWith('/api/') } + } + + const resolved = configured(params) + return resolved instanceof InternalRoute + ? { url: resolved.path, isInternalRoute: true } + : { url: resolved, isInternalRoute: false } +} + function formatToolRequest(tool: ToolConfig, params: Record): PreparedToolRequest { - const url = typeof tool.request.url === 'function' ? tool.request.url(params) : tool.request.url + const { url, isInternalRoute } = resolveToolUrl(tool, params) const method = typeof tool.request.method === 'function' ? tool.request.method(params) @@ -169,7 +194,7 @@ function formatToolRequest(tool: ToolConfig, params: Record): Prepa timeout: validTimeout, proxyUrl, stripAuthOnRedirect: tool.request.stripAuthOnRedirect, - isInternalRoute: url.startsWith('/api/'), + isInternalRoute, } } @@ -179,18 +204,17 @@ export function prepareToolRequest( params: Record, registry?: ResolvedSecretTraceRegistry ): PreparedToolRequest { - const configuredUrl = - typeof tool.request.url === 'function' ? tool.request.url(params) : tool.request.url + const configured = resolveToolUrl(tool, params) const modelInput = tool.request.modelInput const secretProvenance = tool.request.secretProvenance const hasPrivateModelInputProvenance = modelInput?.mode === 'private-provenance' || (modelInput?.mode === 'project' && modelInput.privateInputPaths !== undefined) - if (hasPrivateModelInputProvenance && !configuredUrl.startsWith('/api/')) { + if (hasPrivateModelInputProvenance && !configured.isInternalRoute) { throw new Error(PRIVATE_MODEL_INPUT_EXTERNAL_URL_ERROR_MESSAGE) } - if (secretProvenance && !configuredUrl.startsWith('/api/')) { + if (secretProvenance && !configured.isInternalRoute) { throw new Error(PRIVATE_SECRET_PROVENANCE_EXTERNAL_URL_ERROR_MESSAGE) } diff --git a/apps/sim/tools/schema-enrichers.test.ts b/apps/sim/tools/schema-enrichers.test.ts index f7542177272..398e8c7c847 100644 --- a/apps/sim/tools/schema-enrichers.test.ts +++ b/apps/sim/tools/schema-enrichers.test.ts @@ -3,20 +3,18 @@ */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const { mockBuildAPIUrl, mockBuildAuthHeaders, mockExtractAPIErrorMessage } = vi.hoisted(() => ({ - mockBuildAPIUrl: vi.fn((path: string, params?: Record) => { - const url = new URL(path, 'http://localhost:3000') - for (const [key, value] of Object.entries(params ?? {})) { - url.searchParams.set(key, value) - } - return url - }), - mockBuildAuthHeaders: vi.fn(), - mockExtractAPIErrorMessage: vi.fn(), -})) +const { mockBuildInternalApiUrl, mockBuildAuthHeaders, mockExtractAPIErrorMessage } = vi.hoisted( + () => ({ + mockBuildInternalApiUrl: vi.fn( + (route: { path: string }) => new URL(route.path, 'http://localhost:3000') + ), + mockBuildAuthHeaders: vi.fn(), + mockExtractAPIErrorMessage: vi.fn(), + }) +) vi.mock('@/executor/utils/http', () => ({ - buildAPIUrl: mockBuildAPIUrl, + buildInternalApiUrl: mockBuildInternalApiUrl, buildAuthHeaders: mockBuildAuthHeaders, extractAPIErrorMessage: mockExtractAPIErrorMessage, })) diff --git a/apps/sim/tools/schema-enrichers.ts b/apps/sim/tools/schema-enrichers.ts index 27f91fd50f7..f478691a792 100644 --- a/apps/sim/tools/schema-enrichers.ts +++ b/apps/sim/tools/schema-enrichers.ts @@ -1,5 +1,6 @@ import { createLogger } from '@sim/logger' import { isRecordLike } from '@sim/utils/object' +import { internalRoute } from '@/lib/core/utils/internal-route' import { isColumnType } from '@/lib/table/column-types' import { enrichTableToolDescription, enrichTableToolParameters } from '@/lib/table/llm/enrichment' import type { TableSummary } from '@/lib/table/types' @@ -18,12 +19,14 @@ async function fetchTableSchema( throw new Error(`User ID is required to enrich table tool schema for ${tableId}`) } - const { buildAuthHeaders, buildAPIUrl, extractAPIErrorMessage } = await import( + const { buildAuthHeaders, buildInternalApiUrl, extractAPIErrorMessage } = await import( '@/executor/utils/http' ) const headers = await buildAuthHeaders(context.userId) - const url = buildAPIUrl(`/api/table/${tableId}`, { workspaceId: context.workspaceId }) + const url = buildInternalApiUrl( + internalRoute`/api/table/${tableId}`.withQuery({ workspaceId: context.workspaceId }) + ) const response = await fetch(url.toString(), { headers }) if (!response.ok) { @@ -127,10 +130,12 @@ async function fetchTagDefinitions( } try { - const { buildAuthHeaders, buildAPIUrl } = await import('@/executor/utils/http') + const { buildAuthHeaders, buildInternalApiUrl } = await import('@/executor/utils/http') const headers = await buildAuthHeaders(context.userId) - const url = buildAPIUrl(`/api/knowledge/${knowledgeBaseId}/tag-definitions`) + const url = buildInternalApiUrl( + internalRoute`/api/knowledge/${knowledgeBaseId}/tag-definitions` + ) logger.info(`Fetching tag definitions for KB ${knowledgeBaseId} from ${url.toString()}`) diff --git a/apps/sim/tools/search/tool.ts b/apps/sim/tools/search/tool.ts index 540a3383134..76cf3f171fa 100644 --- a/apps/sim/tools/search/tool.ts +++ b/apps/sim/tools/search/tool.ts @@ -22,7 +22,7 @@ export const searchTool: ToolConfig = { mode: 'project', select: (params) => ({ query: params.query }), }, - url: () => '/api/tools/search', + url: '/api/tools/search', method: 'POST', headers: () => ({ 'Content-Type': 'application/json', diff --git a/apps/sim/tools/table/batch_insert_rows.ts b/apps/sim/tools/table/batch_insert_rows.ts index 5911b2d4532..907eb7e6f97 100644 --- a/apps/sim/tools/table/batch_insert_rows.ts +++ b/apps/sim/tools/table/batch_insert_rows.ts @@ -1,3 +1,4 @@ +import { internalRoute } from '@/lib/core/utils/internal-route' import { TABLE_LIMITS } from '@/lib/table/constants' import { selectTableRowSecretProvenance } from '@/lib/table/secret-provenance-selection' import { enrichTableToolSchema } from '@/tools/schema-enrichers' @@ -39,7 +40,7 @@ export const tableBatchInsertRowsTool: ToolConfig< request: (params) => selectTableRowSecretProvenance(params.rows, 'rows'), response: { incomplete: 'propagate' }, }, - url: (params: TableBatchInsertParams) => `/api/table/${params.tableId}/rows`, + url: (params: TableBatchInsertParams) => internalRoute`/api/table/${params.tableId}/rows`, method: 'POST', headers: () => ({ 'Content-Type': 'application/json', diff --git a/apps/sim/tools/table/delete_row.ts b/apps/sim/tools/table/delete_row.ts index 47d46d699aa..bc5ddb845d9 100644 --- a/apps/sim/tools/table/delete_row.ts +++ b/apps/sim/tools/table/delete_row.ts @@ -1,3 +1,4 @@ +import { internalRoute } from '@/lib/core/utils/internal-route' import type { TableDeleteResponse, TableRowDeleteParams } from '@/tools/table/types' import type { ToolConfig } from '@/tools/types' @@ -23,7 +24,8 @@ export const tableDeleteRowTool: ToolConfig `/api/table/${params.tableId}/rows/${params.rowId}`, + url: (params: TableRowDeleteParams) => + internalRoute`/api/table/${params.tableId}/rows/${params.rowId}`, method: 'DELETE', headers: () => ({ 'Content-Type': 'application/json', diff --git a/apps/sim/tools/table/delete_rows_by_filter.ts b/apps/sim/tools/table/delete_rows_by_filter.ts index cad82ba66aa..2348c2ebf1a 100644 --- a/apps/sim/tools/table/delete_rows_by_filter.ts +++ b/apps/sim/tools/table/delete_rows_by_filter.ts @@ -1,3 +1,4 @@ +import { internalRoute } from '@/lib/core/utils/internal-route' import { TABLE_LIMITS } from '@/lib/table/constants' import { enrichTableToolSchema } from '@/tools/schema-enrichers' import type { TableBulkOperationResponse, TableDeleteByFilterParams } from '@/tools/table/types' @@ -42,7 +43,7 @@ export const tableDeleteRowsByFilterTool: ToolConfig< }, request: { - url: (params: TableDeleteByFilterParams) => `/api/table/${params.tableId}/rows`, + url: (params: TableDeleteByFilterParams) => internalRoute`/api/table/${params.tableId}/rows`, method: 'DELETE', headers: () => ({ 'Content-Type': 'application/json', diff --git a/apps/sim/tools/table/get_row.ts b/apps/sim/tools/table/get_row.ts index 7b76e605fda..54bf2773efb 100644 --- a/apps/sim/tools/table/get_row.ts +++ b/apps/sim/tools/table/get_row.ts @@ -1,3 +1,4 @@ +import { internalRoute } from '@/lib/core/utils/internal-route' import type { TableRowGetParams, TableRowResponse } from '@/tools/table/types' import type { ToolConfig } from '@/tools/types' @@ -30,7 +31,9 @@ export const tableGetRowTool: ToolConfig = throw new Error('Workspace ID is required in execution context') } - return `/api/table/${params.tableId}/rows/${params.rowId}?workspaceId=${encodeURIComponent(workspaceId)}` + return internalRoute`/api/table/${params.tableId}/rows/${params.rowId}`.withQuery({ + workspaceId, + }) }, method: 'GET', headers: () => ({ diff --git a/apps/sim/tools/table/get_schema.ts b/apps/sim/tools/table/get_schema.ts index 7f96f0dd065..62fee12ef79 100644 --- a/apps/sim/tools/table/get_schema.ts +++ b/apps/sim/tools/table/get_schema.ts @@ -1,3 +1,4 @@ +import { internalRoute } from '@/lib/core/utils/internal-route' import { getColumnId } from '@/lib/table/column-keys' import type { ColumnDefinition } from '@/lib/table/types' import type { TableGetSchemaParams, TableGetSchemaResponse } from '@/tools/table/types' @@ -25,7 +26,7 @@ export const tableGetSchemaTool: ToolConfig ({ diff --git a/apps/sim/tools/table/insert_row.ts b/apps/sim/tools/table/insert_row.ts index b8751610019..7ec57397d1c 100644 --- a/apps/sim/tools/table/insert_row.ts +++ b/apps/sim/tools/table/insert_row.ts @@ -1,3 +1,4 @@ +import { internalRoute } from '@/lib/core/utils/internal-route' import { selectTableRowSecretProvenance } from '@/lib/table/secret-provenance-selection' import { enrichTableToolSchema } from '@/tools/schema-enrichers' import type { TableRowInsertParams, TableRowResponse } from '@/tools/table/types' @@ -36,7 +37,7 @@ export const tableInsertRowTool: ToolConfig selectTableRowSecretProvenance([params.data]), response: { incomplete: 'propagate' }, }, - url: (params: TableRowInsertParams) => `/api/table/${params.tableId}/rows`, + url: (params: TableRowInsertParams) => internalRoute`/api/table/${params.tableId}/rows`, method: 'POST', headers: () => ({ 'Content-Type': 'application/json', diff --git a/apps/sim/tools/table/list.ts b/apps/sim/tools/table/list.ts index 18718c8071b..1a5563d0d5e 100644 --- a/apps/sim/tools/table/list.ts +++ b/apps/sim/tools/table/list.ts @@ -1,3 +1,4 @@ +import { internalRoute } from '@/lib/core/utils/internal-route' import type { TableListParams, TableListResponse } from '@/tools/table/types' import type { ToolConfig } from '@/tools/types' @@ -15,7 +16,7 @@ export const tableListTool: ToolConfig = { if (!workspaceId) { throw new Error('Workspace ID is required in execution context') } - return `/api/table?workspaceId=${encodeURIComponent(workspaceId)}` + return internalRoute`/api/table`.withQuery({ workspaceId }) }, method: 'GET', headers: () => ({ diff --git a/apps/sim/tools/table/query_rows.ts b/apps/sim/tools/table/query_rows.ts index 828c9f775b5..73440df52b0 100644 --- a/apps/sim/tools/table/query_rows.ts +++ b/apps/sim/tools/table/query_rows.ts @@ -1,3 +1,4 @@ +import { internalRoute } from '@/lib/core/utils/internal-route' import { TABLE_LIMITS } from '@/lib/table/constants' import { enrichTableToolSchema } from '@/tools/schema-enrichers' import type { TableQueryResponse, TableRowQueryParams } from '@/tools/table/types' @@ -74,7 +75,7 @@ export const tableQueryRowsTool: ToolConfig ({ diff --git a/apps/sim/tools/table/query_rows_v2.ts b/apps/sim/tools/table/query_rows_v2.ts index 3483774b0b3..42b67a0bd52 100644 --- a/apps/sim/tools/table/query_rows_v2.ts +++ b/apps/sim/tools/table/query_rows_v2.ts @@ -1,3 +1,4 @@ +import { internalRoute } from '@/lib/core/utils/internal-route' import { normalizeTablePredicate } from '@/lib/table/query-builder/predicate' import { validatePredicateShape } from '@/lib/table/query-builder/validate' import type { TableQueryV2Response, TableRowQueryV2Params } from '@/tools/table/types' @@ -58,7 +59,7 @@ export const tableQueryRowsV2Tool: ToolConfig `/api/table/${params.tableId}/query`, + url: (params: TableRowQueryV2Params) => internalRoute`/api/table/${params.tableId}/query`, method: 'POST', headers: () => ({ 'Content-Type': 'application/json' }), body: (params: TableRowQueryV2Params) => { diff --git a/apps/sim/tools/table/update_row.ts b/apps/sim/tools/table/update_row.ts index c9792f95680..b5d89c3f4ba 100644 --- a/apps/sim/tools/table/update_row.ts +++ b/apps/sim/tools/table/update_row.ts @@ -1,3 +1,4 @@ +import { internalRoute } from '@/lib/core/utils/internal-route' import { selectTableRowSecretProvenance } from '@/lib/table/secret-provenance-selection' import { enrichTableToolSchema } from '@/tools/schema-enrichers' import type { TableRowResponse, TableRowUpdateParams } from '@/tools/table/types' @@ -42,7 +43,8 @@ export const tableUpdateRowTool: ToolConfig selectTableRowSecretProvenance([params.data]), response: { incomplete: 'propagate' }, }, - url: (params: TableRowUpdateParams) => `/api/table/${params.tableId}/rows/${params.rowId}`, + url: (params: TableRowUpdateParams) => + internalRoute`/api/table/${params.tableId}/rows/${params.rowId}`, method: 'PATCH', headers: () => ({ 'Content-Type': 'application/json', diff --git a/apps/sim/tools/table/update_rows_by_filter.ts b/apps/sim/tools/table/update_rows_by_filter.ts index d1f2b759eba..de1ca9a6d44 100644 --- a/apps/sim/tools/table/update_rows_by_filter.ts +++ b/apps/sim/tools/table/update_rows_by_filter.ts @@ -1,3 +1,4 @@ +import { internalRoute } from '@/lib/core/utils/internal-route' import { TABLE_LIMITS } from '@/lib/table/constants' import { selectTableRowSecretProvenance } from '@/lib/table/secret-provenance-selection' import { enrichTableToolSchema } from '@/tools/schema-enrichers' @@ -52,7 +53,7 @@ export const tableUpdateRowsByFilterTool: ToolConfig< secretProvenance: { request: (params) => selectTableRowSecretProvenance([params.data]), }, - url: (params: TableUpdateByFilterParams) => `/api/table/${params.tableId}/rows`, + url: (params: TableUpdateByFilterParams) => internalRoute`/api/table/${params.tableId}/rows`, method: 'PUT', headers: () => ({ 'Content-Type': 'application/json', diff --git a/apps/sim/tools/table/upsert_row.ts b/apps/sim/tools/table/upsert_row.ts index 70afc179872..fc2f2ded343 100644 --- a/apps/sim/tools/table/upsert_row.ts +++ b/apps/sim/tools/table/upsert_row.ts @@ -1,3 +1,4 @@ +import { internalRoute } from '@/lib/core/utils/internal-route' import { selectTableRowSecretProvenance } from '@/lib/table/secret-provenance-selection' import { enrichTableToolSchema } from '@/tools/schema-enrichers' import type { TableRowInsertParams, TableUpsertResponse } from '@/tools/table/types' @@ -43,7 +44,7 @@ export const tableUpsertRowTool: ToolConfig selectTableRowSecretProvenance([params.data]), response: { incomplete: 'propagate' }, }, - url: (params: TableRowInsertParams) => `/api/table/${params.tableId}/rows/upsert`, + url: (params: TableRowInsertParams) => internalRoute`/api/table/${params.tableId}/rows/upsert`, method: 'POST', headers: () => ({ 'Content-Type': 'application/json', diff --git a/apps/sim/tools/tiktok/upload_video_draft.ts b/apps/sim/tools/tiktok/upload_video_draft.ts index ca478dd32dc..7a5ce910730 100644 --- a/apps/sim/tools/tiktok/upload_video_draft.ts +++ b/apps/sim/tools/tiktok/upload_video_draft.ts @@ -37,7 +37,7 @@ export const tiktokUploadVideoDraftTool: ToolConfig< }, request: { - url: () => '/api/tools/tiktok/upload-video-draft', + url: '/api/tools/tiktok/upload-video-draft', method: 'POST', headers: () => ({ 'Content-Type': 'application/json', diff --git a/apps/sim/tools/types.ts b/apps/sim/tools/types.ts index c9b15ee356c..5e7b9a62a36 100644 --- a/apps/sim/tools/types.ts +++ b/apps/sim/tools/types.ts @@ -1,5 +1,6 @@ import type { MothershipResource } from '@/lib/copilot/resources/types' import type { HostedKeyRateLimitConfig } from '@/lib/core/rate-limiter' +import type { InternalRoute } from '@/lib/core/utils/internal-route' import type { PrivateSecretProvenanceSelection } from '@/lib/execution/model-input-provenance' import type { OAuthService } from '@/lib/oauth' import type { ResolvedSecretInputPath } from '@/executor/utils/resolved-secret-trace-registry' @@ -174,7 +175,12 @@ export interface ToolConfig

{ // Request configuration request: { - url: string | ((params: P) => string) + /** + * The request target. A static string may be a relative `/api/` route on Sim's own API; a + * builder must return an absolute URL, or an `internalRoute` template to target Sim's API — + * a bare `/api/` string from a builder is treated as external, because params can produce one. + */ + url: string | ((params: P) => string | InternalRoute) method: HttpMethod | ((params: P) => HttpMethod) headers: (params: P) => Record body?: (params: P) => Record | string | FormData | undefined diff --git a/apps/sim/tools/wordpress/upload_media.ts b/apps/sim/tools/wordpress/upload_media.ts index 7115346aaa0..e2fd0d3d924 100644 --- a/apps/sim/tools/wordpress/upload_media.ts +++ b/apps/sim/tools/wordpress/upload_media.ts @@ -63,7 +63,7 @@ export const uploadMediaTool: ToolConfig '/api/tools/wordpress/upload', + url: '/api/tools/wordpress/upload', method: 'POST', headers: () => ({ 'Content-Type': 'application/json', diff --git a/apps/sim/tools/workflow/executor.test.ts b/apps/sim/tools/workflow/executor.test.ts index e31ac6ebbfe..6ac01e22a9a 100644 --- a/apps/sim/tools/workflow/executor.test.ts +++ b/apps/sim/tools/workflow/executor.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest' +import type { InternalRoute } from '@/lib/core/utils/internal-route' import { workflowExecutorTool } from '@/tools/workflow/executor' describe('workflowExecutorTool', () => { @@ -311,10 +312,10 @@ describe('workflowExecutorTool', () => { describe('request.url', () => { it.concurrent('should build correct URL with workflowId', () => { - const url = workflowExecutorTool.request.url as (params: any) => string + const route = workflowExecutorTool.request.url as (params: any) => InternalRoute - expect(url({ workflowId: 'abc-123' })).toBe('/api/workflows/abc-123/execute') - expect(url({ workflowId: 'my-workflow' })).toBe('/api/workflows/my-workflow/execute') + expect(route({ workflowId: 'abc-123' }).path).toBe('/api/workflows/abc-123/execute') + expect(route({ workflowId: 'my-workflow' }).path).toBe('/api/workflows/my-workflow/execute') }) }) diff --git a/apps/sim/tools/workflow/executor.ts b/apps/sim/tools/workflow/executor.ts index 875e47044f8..8106f3237f0 100644 --- a/apps/sim/tools/workflow/executor.ts +++ b/apps/sim/tools/workflow/executor.ts @@ -1,3 +1,4 @@ +import { internalRoute } from '@/lib/core/utils/internal-route' import { normalizeWorkflowExecutorInput, WORKFLOW_EXECUTOR_INPUT_PROVENANCE_KEY, @@ -34,7 +35,8 @@ export const workflowExecutorTool: ToolConfig< }, }, request: { - url: (params: WorkflowExecutorParams) => `/api/workflows/${params.workflowId}/execute`, + url: (params: WorkflowExecutorParams) => + internalRoute`/api/workflows/${params.workflowId}/execute`, method: 'POST', headers: () => ({ 'Content-Type': 'application/json' }), secretProvenance: { diff --git a/package.json b/package.json index f0b2d645a5e..318b4aa6a5c 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "check:api-validation:strict": "bun run scripts/check-api-validation-contracts.ts --check --enforce-boundary-baseline", "check:realtime-prune": "bun run scripts/check-realtime-prune-graph.ts", "check:tool-request-boundary": "bun run scripts/check-tool-request-boundary.ts", + "check:internal-routes": "bun run scripts/check-internal-route-declarations.ts", "check:tool-registry-boundary": "bun run scripts/check-tool-registry-boundary.ts", "check:trigger-block-cycle": "bun run scripts/check-trigger-block-cycle.ts", "check:import-specifiers": "bun run scripts/check-import-specifiers.ts", diff --git a/packages/testing/src/mocks/executor.mock.ts b/packages/testing/src/mocks/executor.mock.ts index 8698c258b7a..ef8afe6d662 100644 --- a/packages/testing/src/mocks/executor.mock.ts +++ b/packages/testing/src/mocks/executor.mock.ts @@ -73,7 +73,9 @@ vi.mock('@/executor/resolver', () => ({ })) vi.mock('@/executor/utils/http', () => ({ buildAuthHeaders: vi.fn().mockResolvedValue({ 'Content-Type': 'application/json' }), - buildAPIUrl: vi.fn((path: string) => new URL(path, 'http://localhost:3000')), + buildInternalApiUrl: vi.fn( + (route: { path: string }) => new URL(route.path, 'http://localhost:3000') + ), extractAPIErrorMessage: vi.fn(async (response: Response) => { const defaultMessage = `API request failed with status ${response.status}` try { diff --git a/scripts/check-internal-route-declarations.ts b/scripts/check-internal-route-declarations.ts new file mode 100644 index 00000000000..b68c4241cca --- /dev/null +++ b/scripts/check-internal-route-declarations.ts @@ -0,0 +1,105 @@ +#!/usr/bin/env bun +/** + * Fails when a tool's URL builder returns a bare `/api/...` string. + * + * The transport signs internal requests with an internal token for the executing user, so a tool + * targeting Sim's own API must declare that in its source: a static `request.url` string, or an + * `internalRoute` template. A builder returning a plain `/api/...` string carries no provenance — + * a `user-or-llm` param produces exactly the same value — so the transport treats it as external + * and the tool silently loses its internal routing. This check catches that at authoring time + * rather than at runtime. + */ +import { readdirSync, readFileSync } from 'node:fs' +import { dirname, extname, join, relative, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)) +const ROOT = resolve(SCRIPT_DIR, '..') +const TOOLS = join(ROOT, 'apps/sim/tools') +const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx']) + +/** + * A `/api/...` string or template literal returned by a builder rather than branded. + * + * Matched against the builder with newlines collapsed, because the formatter wraps a long body + * onto the line after `=>` or `return` — and an arrow body may also be parenthesized. + */ +const RETURNED_INTERNAL_PATH = /(?:return|=>)\s*\(?\s*(['"`])(\/api\/)/ + +interface Violation { + file: string + line: number + text: string +} + +function collectSources(dir: string, found: string[] = []): string[] { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const path = join(dir, entry.name) + if (entry.isDirectory()) { + if (entry.name !== 'generated') collectSources(path, found) + } else if ( + SOURCE_EXTENSIONS.has(extname(path)) && + !path.endsWith('.test.ts') && + !path.endsWith('.d.ts') + ) { + found.push(path) + } + } + return found +} + +const violations: Violation[] = [] + +for (const file of collectSources(TOOLS)) { + const lines = readFileSync(file, 'utf8').split('\n') + let builderStart = -1 + let builderIndent = 0 + let builderText = '' + + const flush = () => { + if (builderStart !== -1 && RETURNED_INTERNAL_PATH.test(builderText)) { + violations.push({ + file: relative(ROOT, file), + line: builderStart + 1, + text: builderText.trim().slice(0, 160), + }) + } + builderStart = -1 + builderText = '' + } + + for (const [index, line] of lines.entries()) { + const indent = line.match(/^\s*/)![0].length + const startsBuilder = /^\s*url:\s*(\(|async\s*\()/.test(line) + + if (builderStart !== -1) { + const closesBuilder = + !startsBuilder && line.trim() !== '' && indent <= builderIndent && /^\s*\w+:/.test(line) + if (closesBuilder) flush() + } + + if (startsBuilder) { + flush() + builderStart = index + builderIndent = indent + } + // Collapse the builder onto one line so a wrapped `=>` / `return` body still matches. + if (builderStart !== -1) builderText += ` ${line.trim()}` + } + flush() +} + +if (violations.length > 0) { + console.error( + `\n✗ ${violations.length} tool URL builder(s) return a bare internal path.\n\n` + + " Use internalRoute from '@/lib/core/utils/internal-route' so the route is declared by the tool's\n" + + ' source, or make the URL a static config string:\n\n' + + ' url: (params) => internalRoute`/api/table/${params.tableId}/rows`\n' + ) + for (const violation of violations) { + console.error(` ${violation.file}:${violation.line}\n ${violation.text}`) + } + process.exit(1) +} + +console.log('✓ tool URL builders declare their internal routes')