Compare commits

..

1 Commits

Author SHA1 Message Date
Cursor Agent
9f30287eb9 fix(mcp): tighten resilience pipeline behavior 2026-03-10 00:17:49 +00:00
182 changed files with 1336 additions and 6419 deletions

View File

@@ -1979,24 +1979,6 @@ export function ElevenLabsIcon(props: SVGProps<SVGSVGElement>) {
)
}
export function FathomIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1000 1000' fill='none'>
<path
d='M0,668.7v205.78c0,53.97,34.24,102.88,85.8,119.08,87.48,27.49,167.88-36.99,167.88-120.22v-77.45L0,668.7Z'
fill='#007299'
/>
<path
d='M873.72,626.07c-19.05,0-38.38-4.3-56.58-13.38L72.78,241.43C11.15,210.69-17.51,136.6,11.18,74.05,41.2,8.59,119.26-18.53,183.23,13.38l744.25,371.21c62.45,31.15,91,109.08,59.79,171.43-22.22,44.38-67.02,70.05-113.55,70.05Z'
fill='#00beff'
/>
<path
d='M500.09,813.66c-19.05,0-38.38-4.3-56.58-13.38l-370.72-184.9c-61.63-30.74-90.29-104.82-61.61-167.37,30.02-65.46,108.08-92.59,172.06-60.68l370.62,184.85c62.45,31.15,91,109.08,59.79,171.43-22.22,44.38-67.02,70.05-113.55,70.05Z'
fill='#00beff'
/>
</svg>
)
}
export function LinkupIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 154 107' fill='none'>
@@ -3572,27 +3554,6 @@ export const ResendIcon = (props: SVGProps<SVGSVGElement>) => (
</svg>
)
export const GoogleAdsIcon = (props: SVGProps<SVGSVGElement>) => (
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'>
<g transform='matrix(.257748 0 0 .257745 -.361416 2.515516)'>
<path
d='M85.9 28.6c2.4-6.3 5.7-12.1 10.6-16.8 19.6-19.1 52-14.3 65.3 9.7 10 18.2 20.6 36 30.9 54l51.6 89.8c14.3 25.1-1.2 56.8-29.6 61.1-17.4 2.6-33.7-5.4-42.7-21l-45.4-78.8c-.3-.6-.7-1.1-1.1-1.6-1.6-1.3-2.3-3.2-3.3-4.9L88.8 62.2c-3.9-6.8-5.7-14.2-5.5-22 .3-4 .8-8 2.6-11.6'
fill='#3c8bd9'
/>
<path
d='M85.9 28.6c-.9 3.6-1.7 7.2-1.9 11-.3 8.4 1.8 16.2 6 23.5l32.9 56.9c1 1.7 1.8 3.4 2.8 5l-18.1 31.1-25.3 43.6c-.4 0-.5-.2-.6-.5-.1-.8.2-1.5.4-2.3 4.1-15 .7-28.3-9.6-39.7-6.3-6.9-14.3-10.8-23.5-12.1-12-1.7-22.6 1.4-32.1 8.9-1.7 1.3-2.8 3.2-4.8 4.2-.4 0-.6-.2-.7-.5l14.3-24.9L85.2 29.7c.2-.4.5-.7.7-1.1'
fill='#fabc04'
/>
<path
d='M11.8 158l5.7-5.1c24.3-19.2 60.8-5.3 66.1 25.1 1.3 7.3.6 14.3-1.6 21.3-.1.6-.2 1.1-.4 1.7-.9 1.6-1.7 3.3-2.7 4.9-8.9 14.7-22 22-39.2 20.9C20 225.4 4.5 210.6 1.8 191c-1.3-9.5.6-18.4 5.5-26.6 1-1.8 2.2-3.4 3.3-5.2.5-.4.3-1.2 1.2-1.2'
fill='#34a852'
/>
<path d='M11.8 158c-.4.4-.4 1.1-1.1 1.2-.1-.7.3-1.1.7-1.6l.4.4' fill='#fabc04' />
<path d='M81.6 201c-.4-.7 0-1.2.4-1.7l.4.4-.8 1.3' fill='#e1c025' />
</g>
</svg>
)
export const GoogleBigQueryIcon = (props: SVGProps<SVGSVGElement>) => (
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'>
<path

View File

@@ -43,7 +43,6 @@ import {
EvernoteIcon,
ExaAIIcon,
EyeIcon,
FathomIcon,
FirecrawlIcon,
FirefliesIcon,
GammaIcon,
@@ -51,7 +50,6 @@ import {
GitLabIcon,
GmailIcon,
GongIcon,
GoogleAdsIcon,
GoogleBigQueryIcon,
GoogleBooksIcon,
GoogleCalendarIcon,
@@ -208,7 +206,6 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
enrich: EnrichSoIcon,
evernote: EvernoteIcon,
exa: ExaAIIcon,
fathom: FathomIcon,
file_v3: DocumentIcon,
firecrawl: FirecrawlIcon,
fireflies_v2: FirefliesIcon,
@@ -217,7 +214,6 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
gitlab: GitLabIcon,
gmail_v2: GmailIcon,
gong: GongIcon,
google_ads: GoogleAdsIcon,
google_bigquery: GoogleBigQueryIcon,
google_books: GoogleBooksIcon,
google_calendar_v2: GoogleCalendarIcon,

View File

@@ -22,8 +22,6 @@ With Ashby, you can:
- **List and view jobs**: Browse all open, closed, and archived job postings with location and department info
- **List applications**: View all applications across your organization with candidate and job details, status tracking, and pagination
The Ashby block also supports **webhook triggers** that automatically start workflows in response to Ashby events. Available triggers include Application Submitted, Candidate Stage Change, Candidate Hired, Candidate Deleted, Job Created, and Offer Created. Webhooks are fully managed — Sim automatically creates the webhook in Ashby when you save the trigger and deletes it when you remove it, so there's no manual webhook configuration needed. Just provide your Ashby API key (with `apiKeysWrite` permission) and select the event type.
In Sim, the Ashby integration enables your agents to programmatically manage your recruiting pipeline. Agents can search for candidates, create new candidate records, add notes after interviews, and monitor applications across jobs. This allows you to automate recruiting workflows like candidate intake, interview follow-ups, pipeline reporting, and cross-referencing candidates across roles.
{/* MANUAL-CONTENT-END */}

View File

@@ -10,21 +10,6 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
color="#E0E0E0"
/>
{/* MANUAL-CONTENT-START:intro */}
[Evernote](https://evernote.com/) is a note-taking and organization platform that helps individuals and teams capture ideas, manage projects, and store information across devices. With notebooks, tags, and powerful search, Evernote serves as a central hub for knowledge management.
With the Sim Evernote integration, you can:
- **Create and update notes**: Programmatically create new notes with content and tags, or update existing notes in any notebook.
- **Search and retrieve notes**: Use Evernote's search grammar to find notes by keyword, tag, notebook, or other criteria, and retrieve full note content.
- **Organize with notebooks and tags**: Create notebooks and tags, list existing ones, and move or copy notes between notebooks.
- **Delete and manage notes**: Move notes to trash or copy them to different notebooks as part of automated workflows.
**How it works in Sim:**
Add an Evernote block to your workflow and select an operation (e.g., create note, search notes, list notebooks). Provide your Evernote developer token and any required parameters. The block calls the Evernote API and returns structured data you can pass to downstream blocks — for example, searching for meeting notes and sending summaries to Slack, or creating notes from AI-generated content.
{/* MANUAL-CONTENT-END */}
## Usage Instructions
Integrate with Evernote to manage notes, notebooks, and tags. Create, read, update, copy, search, and delete notes. Create and list notebooks and tags.

View File

@@ -1,150 +0,0 @@
---
title: Fathom
description: Access meeting recordings, transcripts, and summaries
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="fathom"
color="#181C1E"
/>
{/* MANUAL-CONTENT-START:intro */}
[Fathom](https://fathom.video/) is an AI meeting assistant that automatically records, transcribes, and summarizes your video calls. It works across platforms like Zoom, Google Meet, and Microsoft Teams, generating highlights and action items so your team can stay focused during meetings and catch up quickly afterward.
With the Sim Fathom integration, you can:
- **List and filter meetings**: Retrieve recent meetings recorded by you or shared with your team, with optional filters by date range, recorder, or team.
- **Get meeting summaries**: Pull structured, markdown-formatted summaries for any recorded meeting to quickly review key discussion points.
- **Access full transcripts**: Retrieve complete transcripts with speaker attribution and timestamps for detailed review or downstream processing.
- **Manage teams and members**: List teams in your Fathom organization and view team member details to coordinate meeting workflows.
**How it works in Sim:**
Add a Fathom block to your workflow and select an operation. Provide your Fathom API key and any required parameters (such as a recording ID for summaries and transcripts). The block calls the Fathom API and returns structured data you can pass to downstream blocks — for example, sending a summary to Slack or extracting action items with an AI agent.
{/* MANUAL-CONTENT-END */}
## Usage Instructions
Integrate Fathom AI Notetaker into your workflow. List meetings, get transcripts and summaries, and manage team members and teams. Can also trigger workflows when new meeting content is ready.
## Tools
### `fathom_list_meetings`
List recent meetings recorded by the user or shared to their team.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Fathom API Key |
| `includeSummary` | string | No | Include meeting summary \(true/false\) |
| `includeTranscript` | string | No | Include meeting transcript \(true/false\) |
| `includeActionItems` | string | No | Include action items \(true/false\) |
| `includeCrmMatches` | string | No | Include linked CRM matches \(true/false\) |
| `createdAfter` | string | No | Filter meetings created after this ISO 8601 timestamp |
| `createdBefore` | string | No | Filter meetings created before this ISO 8601 timestamp |
| `recordedBy` | string | No | Filter by recorder email address |
| `teams` | string | No | Filter by team name |
| `cursor` | string | No | Pagination cursor from a previous response |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `meetings` | array | List of meetings |
| ↳ `title` | string | Meeting title |
| ↳ `recording_id` | number | Unique recording ID |
| ↳ `url` | string | URL to view the meeting |
| ↳ `share_url` | string | Shareable URL |
| ↳ `created_at` | string | Creation timestamp |
| ↳ `transcript_language` | string | Transcript language |
| `next_cursor` | string | Pagination cursor for next page |
### `fathom_get_summary`
Get the call summary for a specific meeting recording.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Fathom API Key |
| `recordingId` | string | Yes | The recording ID of the meeting |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `template_name` | string | Name of the summary template used |
| `markdown_formatted` | string | Markdown-formatted summary text |
### `fathom_get_transcript`
Get the full transcript for a specific meeting recording.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Fathom API Key |
| `recordingId` | string | Yes | The recording ID of the meeting |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `transcript` | array | Array of transcript entries with speaker, text, and timestamp |
| ↳ `speaker` | object | Speaker information |
| ↳ `display_name` | string | Speaker display name |
| ↳ `matched_calendar_invitee_email` | string | Matched calendar invitee email |
| ↳ `text` | string | Transcript text |
| ↳ `timestamp` | string | Timestamp \(HH:MM:SS\) |
### `fathom_list_team_members`
List team members in your Fathom organization.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Fathom API Key |
| `teams` | string | No | Team name to filter by |
| `cursor` | string | No | Pagination cursor from a previous response |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `members` | array | List of team members |
| ↳ `name` | string | Team member name |
| ↳ `email` | string | Team member email |
| ↳ `created_at` | string | Date the member was added |
| `next_cursor` | string | Pagination cursor for next page |
### `fathom_list_teams`
List teams in your Fathom organization.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Fathom API Key |
| `cursor` | string | No | Pagination cursor from a previous response |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `teams` | array | List of teams |
| ↳ `name` | string | Team name |
| ↳ `created_at` | string | Date the team was created |
| `next_cursor` | string | Pagination cursor for next page |

View File

@@ -1,192 +0,0 @@
---
title: Google Ads
description: Query campaigns, ad groups, and performance metrics
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="google_ads"
color="#E0E0E0"
/>
{/* MANUAL-CONTENT-START:intro */}
[Google Ads](https://ads.google.com) is Google's online advertising platform that lets businesses create ads to reach customers across Google Search, YouTube, Gmail, and millions of partner websites. It supports campaign types including Search, Display, Video, Shopping, and Performance Max, with detailed targeting, bidding strategies, and performance analytics.
In Sim, the Google Ads integration enables your agents to query campaign data, monitor ad group performance, and pull detailed metrics using the Google Ads Query Language (GAQL). This supports use cases such as automated performance reporting, budget monitoring, campaign health checks, and data-driven optimization workflows. By connecting Sim with Google Ads, your agents can retrieve real-time advertising data and act on insights without manual dashboard navigation.
{/* MANUAL-CONTENT-END */}
## Usage Instructions
Connect to Google Ads to list accessible accounts, list campaigns, view ad group details, get performance metrics, and run custom GAQL queries.
## Tools
### `google_ads_list_customers`
List all Google Ads customer accounts accessible by the authenticated user
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `developerToken` | string | Yes | Google Ads API developer token |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `customerIds` | array | List of accessible customer IDs |
| `totalCount` | number | Total number of accessible customer accounts |
### `google_ads_search`
Run a custom Google Ads Query Language (GAQL) query
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `customerId` | string | Yes | Google Ads customer ID \(numeric, no dashes\) |
| `developerToken` | string | Yes | Google Ads API developer token |
| `managerCustomerId` | string | No | Manager account customer ID \(if accessing via manager account\) |
| `query` | string | Yes | GAQL query to execute |
| `pageToken` | string | No | Page token for pagination |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `results` | json | Array of result objects from the GAQL query |
| `totalResultsCount` | number | Total number of matching results |
| `nextPageToken` | string | Token for the next page of results |
### `google_ads_list_campaigns`
List campaigns in a Google Ads account with optional status filtering
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `customerId` | string | Yes | Google Ads customer ID \(numeric, no dashes\) |
| `developerToken` | string | Yes | Google Ads API developer token |
| `managerCustomerId` | string | No | Manager account customer ID \(if accessing via manager account\) |
| `status` | string | No | Filter by campaign status \(ENABLED, PAUSED, REMOVED\) |
| `limit` | number | No | Maximum number of campaigns to return |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `campaigns` | array | List of campaigns in the account |
| ↳ `id` | string | Campaign ID |
| ↳ `name` | string | Campaign name |
| ↳ `status` | string | Campaign status \(ENABLED, PAUSED, REMOVED\) |
| ↳ `channelType` | string | Advertising channel type \(SEARCH, DISPLAY, SHOPPING, VIDEO, PERFORMANCE_MAX\) |
| ↳ `startDate` | string | Campaign start date \(YYYY-MM-DD\) |
| ↳ `endDate` | string | Campaign end date \(YYYY-MM-DD\) |
| ↳ `budgetAmountMicros` | string | Daily budget in micros \(divide by 1,000,000 for currency value\) |
| `totalCount` | number | Total number of campaigns returned |
### `google_ads_campaign_performance`
Get performance metrics for Google Ads campaigns over a date range
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `customerId` | string | Yes | Google Ads customer ID \(numeric, no dashes\) |
| `developerToken` | string | Yes | Google Ads API developer token |
| `managerCustomerId` | string | No | Manager account customer ID \(if accessing via manager account\) |
| `campaignId` | string | No | Filter by specific campaign ID |
| `dateRange` | string | No | Predefined date range \(LAST_7_DAYS, LAST_30_DAYS, THIS_MONTH, LAST_MONTH, TODAY, YESTERDAY\) |
| `startDate` | string | No | Custom start date in YYYY-MM-DD format |
| `endDate` | string | No | Custom end date in YYYY-MM-DD format |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `campaigns` | array | Campaign performance data broken down by date |
| ↳ `id` | string | Campaign ID |
| ↳ `name` | string | Campaign name |
| ↳ `status` | string | Campaign status |
| ↳ `impressions` | string | Number of impressions |
| ↳ `clicks` | string | Number of clicks |
| ↳ `costMicros` | string | Cost in micros \(divide by 1,000,000 for currency value\) |
| ↳ `ctr` | number | Click-through rate \(0.0 to 1.0\) |
| ↳ `conversions` | number | Number of conversions |
| ↳ `date` | string | Date for this row \(YYYY-MM-DD\) |
| `totalCount` | number | Total number of result rows |
### `google_ads_list_ad_groups`
List ad groups in a Google Ads campaign
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `customerId` | string | Yes | Google Ads customer ID \(numeric, no dashes\) |
| `developerToken` | string | Yes | Google Ads API developer token |
| `managerCustomerId` | string | No | Manager account customer ID \(if accessing via manager account\) |
| `campaignId` | string | Yes | Campaign ID to list ad groups for |
| `status` | string | No | Filter by ad group status \(ENABLED, PAUSED, REMOVED\) |
| `limit` | number | No | Maximum number of ad groups to return |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `adGroups` | array | List of ad groups in the campaign |
| ↳ `id` | string | Ad group ID |
| ↳ `name` | string | Ad group name |
| ↳ `status` | string | Ad group status \(ENABLED, PAUSED, REMOVED\) |
| ↳ `type` | string | Ad group type \(SEARCH_STANDARD, DISPLAY_STANDARD, SHOPPING_PRODUCT_ADS\) |
| ↳ `campaignId` | string | Parent campaign ID |
| ↳ `campaignName` | string | Parent campaign name |
| `totalCount` | number | Total number of ad groups returned |
### `google_ads_ad_performance`
Get performance metrics for individual ads over a date range
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `customerId` | string | Yes | Google Ads customer ID \(numeric, no dashes\) |
| `developerToken` | string | Yes | Google Ads API developer token |
| `managerCustomerId` | string | No | Manager account customer ID \(if accessing via manager account\) |
| `campaignId` | string | No | Filter by campaign ID |
| `adGroupId` | string | No | Filter by ad group ID |
| `dateRange` | string | No | Predefined date range \(LAST_7_DAYS, LAST_30_DAYS, THIS_MONTH, LAST_MONTH, TODAY, YESTERDAY\) |
| `startDate` | string | No | Custom start date in YYYY-MM-DD format |
| `endDate` | string | No | Custom end date in YYYY-MM-DD format |
| `limit` | number | No | Maximum number of results to return |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `ads` | array | Ad performance data broken down by date |
| ↳ `adId` | string | Ad ID |
| ↳ `adGroupId` | string | Parent ad group ID |
| ↳ `adGroupName` | string | Parent ad group name |
| ↳ `campaignId` | string | Parent campaign ID |
| ↳ `campaignName` | string | Parent campaign name |
| ↳ `adType` | string | Ad type \(RESPONSIVE_SEARCH_AD, EXPANDED_TEXT_AD, etc.\) |
| ↳ `impressions` | string | Number of impressions |
| ↳ `clicks` | string | Number of clicks |
| ↳ `costMicros` | string | Cost in micros \(divide by 1,000,000 for currency value\) |
| ↳ `ctr` | number | Click-through rate \(0.0 to 1.0\) |
| ↳ `conversions` | number | Number of conversions |
| ↳ `date` | string | Date for this row \(YYYY-MM-DD\) |
| `totalCount` | number | Total number of result rows |

View File

@@ -37,7 +37,6 @@
"enrich",
"evernote",
"exa",
"fathom",
"file",
"firecrawl",
"fireflies",
@@ -46,7 +45,6 @@
"gitlab",
"gmail",
"gong",
"google_ads",
"google_bigquery",
"google_books",
"google_calendar",

View File

@@ -10,22 +10,6 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
color="#0F0F0F"
/>
{/* MANUAL-CONTENT-START:intro */}
[Obsidian](https://obsidian.md/) is a powerful knowledge base and note-taking application that works on top of a local folder of plain-text Markdown files. With features like bidirectional linking, graph views, and a rich plugin ecosystem, Obsidian is widely used for personal knowledge management, research, and documentation.
With the Sim Obsidian integration, you can:
- **Read and create notes**: Retrieve note content from your vault or create new notes programmatically as part of automated workflows.
- **Update and patch notes**: Modify existing notes in full or patch content at specific locations within a note.
- **Search your vault**: Find notes by keyword or content across your entire Obsidian vault.
- **Manage periodic notes**: Access and create daily or other periodic notes for journaling and task tracking.
- **Execute commands**: Trigger Obsidian commands remotely to automate vault operations.
**How it works in Sim:**
Add an Obsidian block to your workflow and select an operation. This integration requires the [Obsidian Local REST API](https://github.com/coddingtonbear/obsidian-local-rest-api) plugin to be installed and running in your vault. Provide your API key and vault URL, along with any required parameters. The block communicates with your local Obsidian instance and returns structured data you can pass to downstream blocks — for example, searching your vault for research notes and feeding them into an AI agent for summarization.
{/* MANUAL-CONTENT-END */}
## Usage Instructions
Read, create, update, search, and delete notes in your Obsidian vault. Manage periodic notes, execute commands, and patch content at specific locations. Requires the Obsidian Local REST API plugin.

View File

@@ -44,24 +44,20 @@ Search the web using Parallel AI. Provides comprehensive search results with int
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `objective` | string | Yes | The search objective or question to answer |
| `search_queries` | string | No | Comma-separated list of search queries to execute |
| `mode` | string | No | Search mode: one-shot, agentic, or fast \(default: one-shot\) |
| `max_results` | number | No | Maximum number of results to return \(default: 10\) |
| `max_chars_per_result` | number | No | Maximum characters per result excerpt \(minimum: 1000\) |
| `include_domains` | string | No | Comma-separated list of domains to restrict search results to |
| `exclude_domains` | string | No | Comma-separated list of domains to exclude from search results |
| `search_queries` | string | No | Optional comma-separated list of search queries to execute |
| `processor` | string | No | Processing method: base or pro \(default: base\) |
| `max_results` | number | No | Maximum number of results to return \(default: 5\) |
| `max_chars_per_result` | number | No | Maximum characters per result \(default: 1500\) |
| `apiKey` | string | Yes | Parallel AI API Key |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `search_id` | string | Unique identifier for this search request |
| `results` | array | Search results with excerpts from relevant pages |
| ↳ `url` | string | The URL of the search result |
| ↳ `title` | string | The title of the search result |
| ↳ `publish_date` | string | Publication date of the page \(YYYY-MM-DD\) |
| ↳ `excerpts` | array | LLM-optimized excerpts from the page |
| ↳ `excerpts` | array | Text excerpts from the page |
### `parallel_extract`
@@ -72,33 +68,31 @@ Extract targeted information from specific URLs using Parallel AI. Processes pro
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `urls` | string | Yes | Comma-separated list of URLs to extract information from |
| `objective` | string | No | What information to extract from the provided URLs |
| `excerpts` | boolean | No | Include relevant excerpts from the content \(default: true\) |
| `full_content` | boolean | No | Include full page content as markdown \(default: false\) |
| `objective` | string | Yes | What information to extract from the provided URLs |
| `excerpts` | boolean | Yes | Include relevant excerpts from the content |
| `full_content` | boolean | Yes | Include full page content |
| `apiKey` | string | Yes | Parallel AI API Key |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `extract_id` | string | Unique identifier for this extraction request |
| `results` | array | Extracted information from the provided URLs |
| ↳ `url` | string | The source URL |
| ↳ `title` | string | The title of the page |
| ↳ `publish_date` | string | Publication date \(YYYY-MM-DD\) |
| ↳ `excerpts` | array | Relevant text excerpts in markdown |
| ↳ `full_content` | string | Full page content as markdown |
| ↳ `content` | string | Extracted content |
| ↳ `excerpts` | array | Relevant text excerpts |
### `parallel_deep_research`
Conduct comprehensive deep research across the web using Parallel AI. Synthesizes information from multiple sources with citations. Can take up to 45 minutes to complete.
Conduct comprehensive deep research across the web using Parallel AI. Synthesizes information from multiple sources with citations. Can take up to 15 minutes to complete.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `input` | string | Yes | Research query or question \(up to 15,000 characters\) |
| `processor` | string | No | Processing tier: pro, ultra, pro-fast, ultra-fast \(default: pro\) |
| `processor` | string | No | Compute level: base, lite, pro, ultra, ultra2x, ultra4x, ultra8x \(default: base\) |
| `include_domains` | string | No | Comma-separated list of domains to restrict research to \(source policy\) |
| `exclude_domains` | string | No | Comma-separated list of domains to exclude from research \(source policy\) |
| `apiKey` | string | Yes | Parallel AI API Key |
@@ -107,17 +101,17 @@ Conduct comprehensive deep research across the web using Parallel AI. Synthesize
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `status` | string | Task status \(completed, failed, running\) |
| `status` | string | Task status \(completed, failed\) |
| `run_id` | string | Unique ID for this research task |
| `message` | string | Status message |
| `content` | object | Research results \(structured based on output_schema\) |
| `basis` | array | Citations and sources with reasoning and confidence levels |
| ↳ `field` | string | Output field dot-notation path |
| ↳ `field` | string | Output field name |
| ↳ `reasoning` | string | Explanation for the result |
| ↳ `citations` | array | Array of sources |
| ↳ `url` | string | Source URL |
| ↳ `title` | string | Source title |
| ↳ `excerpts` | array | Relevant excerpts from the source |
| ↳ `confidence` | string | Confidence level \(high, medium\) |
| ↳ `confidence` | string | Confidence level indicator |

View File

@@ -590,7 +590,6 @@ List all users in a Slack workspace. Returns user profiles with names and avatar
| ↳ `name` | string | Username \(handle\) |
| ↳ `real_name` | string | Full real name |
| ↳ `display_name` | string | Display name shown in Slack |
| ↳ `email` | string | Email address \(requires users:read.email scope\) |
| ↳ `is_bot` | boolean | Whether the user is a bot |
| ↳ `is_admin` | boolean | Whether the user is a workspace admin |
| ↳ `is_owner` | boolean | Whether the user is the workspace owner |
@@ -630,7 +629,6 @@ Get detailed information about a specific Slack user by their user ID.
| ↳ `title` | string | Job title |
| ↳ `phone` | string | Phone number |
| ↳ `skype` | string | Skype handle |
| ↳ `email` | string | Email address \(requires users:read.email scope\) |
| ↳ `is_bot` | boolean | Whether the user is a bot |
| ↳ `is_admin` | boolean | Whether the user is a workspace admin |
| ↳ `is_owner` | boolean | Whether the user is the workspace owner |

View File

@@ -13,7 +13,7 @@ import {
isTerminalState,
parseWorkflowSSEChunk,
} from '@/lib/a2a/utils'
import { type AuthResult, AuthType, checkHybridAuth } from '@/lib/auth/hybrid'
import { type AuthResult, checkHybridAuth } from '@/lib/auth/hybrid'
import { acquireLock, getRedisClient, releaseLock } from '@/lib/core/config/redis'
import { validateUrlWithDNS } from '@/lib/core/security/input-validation.server'
import { SSE_HEADERS } from '@/lib/core/utils/sse'
@@ -242,9 +242,9 @@ export async function POST(request: NextRequest, { params }: { params: Promise<R
const { id, method, params: rpcParams } = body
const requestApiKey = request.headers.get('X-API-Key')
const apiKey = authenticatedAuthType === AuthType.API_KEY ? requestApiKey : null
const apiKey = authenticatedAuthType === 'api_key' ? requestApiKey : null
const isPersonalApiKeyCaller =
authenticatedAuthType === AuthType.API_KEY && authenticatedApiKeyType === 'personal'
authenticatedAuthType === 'api_key' && authenticatedApiKeyType === 'personal'
const billedUserId = await getWorkspaceBilledAccountUserId(agent.workspaceId)
if (!billedUserId) {
logger.error('Unable to resolve workspace billed account for A2A execution', {

View File

@@ -24,7 +24,6 @@ const { mockCheckSessionOrInternalAuth, mockLogger } = vi.hoisted(() => {
})
vi.mock('@/lib/auth/hybrid', () => ({
AuthType: { SESSION: 'session', API_KEY: 'api_key', INTERNAL_JWT: 'internal_jwt' },
checkSessionOrInternalAuth: mockCheckSessionOrInternalAuth,
}))

View File

@@ -51,7 +51,6 @@ vi.mock('@/lib/auth/credential-access', () => ({
}))
vi.mock('@/lib/auth/hybrid', () => ({
AuthType: { SESSION: 'session', API_KEY: 'api_key', INTERNAL_JWT: 'internal_jwt' },
checkHybridAuth: vi.fn(),
checkSessionOrInternalAuth: mockCheckSessionOrInternalAuth,
checkInternalAuth: vi.fn(),

View File

@@ -2,7 +2,7 @@ import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { AuthType, checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { getCredential, getOAuthToken, refreshTokenIfNeeded } from '@/app/api/auth/oauth/utils'
@@ -72,7 +72,7 @@ export async function POST(request: NextRequest) {
})
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
if (!auth.success || auth.authType !== AuthType.SESSION || !auth.userId) {
if (!auth.success || auth.authType !== 'session' || !auth.userId) {
logger.warn(`[${requestId}] Unauthorized request for credentialAccountUserId path`, {
success: auth.success,
authType: auth.authType,
@@ -202,7 +202,7 @@ export async function GET(request: NextRequest) {
credentialId,
requireWorkflowIdForInternal: false,
})
if (!authz.ok || authz.authType !== AuthType.SESSION || !authz.credentialOwnerUserId) {
if (!authz.ok || authz.authType !== 'session' || !authz.credentialOwnerUserId) {
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
}

View File

@@ -91,7 +91,6 @@ vi.mock('@/lib/auth', () => ({
}))
vi.mock('@/lib/auth/hybrid', () => ({
AuthType: { SESSION: 'session', API_KEY: 'api_key', INTERNAL_JWT: 'internal_jwt' },
checkHybridAuth: mocks.mockCheckHybridAuth,
checkSessionOrInternalAuth: mocks.mockCheckSessionOrInternalAuth,
checkInternalAuth: mocks.mockCheckInternalAuth,

View File

@@ -106,7 +106,6 @@ vi.mock('@/lib/auth', () => ({
}))
vi.mock('@/lib/auth/hybrid', () => ({
AuthType: { SESSION: 'session', API_KEY: 'api_key', INTERNAL_JWT: 'internal_jwt' },
checkInternalAuth: mockCheckInternalAuth,
checkHybridAuth: mockCheckHybridAuth,
checkSessionOrInternalAuth: mockCheckSessionOrInternalAuth,

View File

@@ -49,7 +49,6 @@ vi.mock('fs/promises', () => ({
}))
vi.mock('@/lib/auth/hybrid', () => ({
AuthType: { SESSION: 'session', API_KEY: 'api_key', INTERNAL_JWT: 'internal_jwt' },
checkSessionOrInternalAuth: mockCheckSessionOrInternalAuth,
}))

View File

@@ -100,7 +100,6 @@ vi.mock('@/lib/auth', () => ({
}))
vi.mock('@/lib/auth/hybrid', () => ({
AuthType: { SESSION: 'session', API_KEY: 'api_key', INTERNAL_JWT: 'internal_jwt' },
checkHybridAuth: mocks.mockCheckHybridAuth,
checkSessionOrInternalAuth: mocks.mockCheckSessionOrInternalAuth,
checkInternalAuth: mocks.mockCheckInternalAuth,

View File

@@ -18,7 +18,6 @@ vi.mock('@/lib/execution/isolated-vm', () => ({
}))
vi.mock('@/lib/auth/hybrid', () => ({
AuthType: { SESSION: 'session', API_KEY: 'api_key', INTERNAL_JWT: 'internal_jwt' },
checkInternalAuth: mockCheckInternalAuth,
}))

View File

@@ -2,7 +2,7 @@ import { randomUUID } from 'crypto'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { AuthType, checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { SUPPORTED_FIELD_TYPES } from '@/lib/knowledge/constants'
import { createTagDefinition, getTagDefinitions } from '@/lib/knowledge/tags/service'
import { checkKnowledgeBaseAccess } from '@/app/api/knowledge/utils'
@@ -25,7 +25,7 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ id:
}
// For session auth, verify KB access. Internal JWT is trusted.
if (auth.authType === AuthType.SESSION && auth.userId) {
if (auth.authType === 'session' && auth.userId) {
const accessCheck = await checkKnowledgeBaseAccess(knowledgeBaseId, auth.userId)
if (!accessCheck.hasAccess) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
@@ -62,7 +62,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
}
// For session auth, verify KB access. Internal JWT is trusted.
if (auth.authType === AuthType.SESSION && auth.userId) {
if (auth.authType === 'session' && auth.userId) {
const accessCheck = await checkKnowledgeBaseAccess(knowledgeBaseId, auth.userId)
if (!accessCheck.hasAccess) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })

View File

@@ -68,7 +68,6 @@ vi.mock('@sim/db', () => ({
}))
vi.mock('@/lib/auth/hybrid', () => ({
AuthType: { SESSION: 'session', API_KEY: 'api_key', INTERNAL_JWT: 'internal_jwt' },
checkSessionOrInternalAuth: mockCheckSessionOrInternalAuth,
}))

View File

@@ -59,7 +59,6 @@ vi.mock('@sim/db/schema', () => ({
}))
vi.mock('@/lib/auth/hybrid', () => ({
AuthType: { SESSION: 'session', API_KEY: 'api_key', INTERNAL_JWT: 'internal_jwt' },
checkHybridAuth: mockCheckHybridAuth,
checkSessionOrInternalAuth: vi.fn(),
checkInternalAuth: vi.fn(),

View File

@@ -19,7 +19,7 @@ import { workflow, workflowMcpServer, workflowMcpTool } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { type AuthResult, AuthType, checkHybridAuth } from '@/lib/auth/hybrid'
import { type AuthResult, checkHybridAuth } from '@/lib/auth/hybrid'
import { generateInternalToken } from '@/lib/auth/internal'
import { getMaxExecutionTimeout } from '@/lib/core/execution-limits'
import { getInternalApiBaseUrl } from '@/lib/core/utils/urls'
@@ -137,7 +137,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<R
executeAuthContext = {
authType: auth.authType,
userId: auth.userId,
apiKey: auth.authType === AuthType.API_KEY ? request.headers.get('X-API-Key') : null,
apiKey: auth.authType === 'api_key' ? request.headers.get('X-API-Key') : null,
}
}
@@ -295,7 +295,7 @@ async function handleToolsCall(
const internalToken = await generateInternalToken(publicServerOwnerId)
headers.Authorization = `Bearer ${internalToken}`
} else if (executeAuthContext) {
if (executeAuthContext.authType === AuthType.API_KEY && executeAuthContext.apiKey) {
if (executeAuthContext.authType === 'api_key' && executeAuthContext.apiKey) {
headers['X-API-Key'] = executeAuthContext.apiKey
} else {
const internalToken = await generateInternalToken(executeAuthContext.userId)

View File

@@ -192,8 +192,7 @@ export const POST = withMcpAuth<{ id: string }>('read')(
)
} catch (error) {
connectionStatus = 'error'
lastError =
error instanceof Error ? error.message.split('\n')[0].slice(0, 200) : 'Connection failed'
lastError = error instanceof Error ? error.message : 'Connection test failed'
logger.warn(`[${requestId}] Failed to connect to server ${serverId}:`, error)
}

View File

@@ -41,20 +41,6 @@ interface TestConnectionResult {
warnings?: string[]
}
/**
* Extracts a user-friendly error message from connection errors.
* Keeps diagnostic info (timeout, DNS, HTTP status) but strips
* verbose internals (Zod details, full response bodies, stack traces).
*/
function sanitizeConnectionError(error: unknown): string {
if (!(error instanceof Error)) {
return 'Unknown connection error'
}
const firstLine = error.message.split('\n')[0]
return firstLine.length > 200 ? `${firstLine.slice(0, 200)}...` : firstLine
}
/**
* POST - Test connection to an MCP server before registering it
*/
@@ -151,7 +137,8 @@ export const POST = withMcpAuth('write')(
} catch (toolError) {
logger.warn(`[${requestId}] Connection established but could not list tools:`, toolError)
result.success = false
result.error = 'Connection established but could not list tools'
const errorMessage = toolError instanceof Error ? toolError.message : 'Unknown error'
result.error = `Connection established but could not list tools: ${errorMessage}`
result.warnings = result.warnings || []
result.warnings.push(
'Server connected but tool listing failed - connection may be incomplete'
@@ -176,7 +163,11 @@ export const POST = withMcpAuth('write')(
logger.warn(`[${requestId}] MCP server test failed:`, error)
result.success = false
result.error = sanitizeConnectionError(error)
if (error instanceof Error) {
result.error = error.message
} else {
result.error = 'Unknown connection error'
}
} finally {
if (client) {
try {

View File

@@ -89,12 +89,11 @@ export const POST = withMcpAuth('read')(
tool = tools.find((t) => t.name === toolName) ?? null
if (!tool) {
logger.warn(`[${requestId}] Tool ${toolName} not found on server ${serverId}`, {
availableTools: tools.map((t) => t.name),
})
return createMcpErrorResponse(
new Error('Tool not found'),
'Tool not found on the specified server',
new Error(
`Tool ${toolName} not found on server ${serverId}. Available tools: ${tools.map((t) => t.name).join(', ')}`
),
'Tool not found',
404
)
}

View File

@@ -1,7 +1,6 @@
import { randomUUID } from 'crypto'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { AuthType } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { preprocessExecution } from '@/lib/execution/preprocessing'
import { PauseResumeManager } from '@/lib/workflows/executor/human-in-the-loop-manager'
@@ -40,7 +39,7 @@ export async function POST(
const resumeInput = payload?.input ?? payload ?? {}
const isPersonalApiKeyCaller =
access.auth?.authType === AuthType.API_KEY && access.auth?.apiKeyType === 'personal'
access.auth?.authType === 'api_key' && access.auth?.apiKeyType === 'personal'
let userId: string
if (isPersonalApiKeyCaller && access.auth?.userId) {

View File

@@ -76,7 +76,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json(
{
success: false,
error: 'Failed to cancel task',
error: error instanceof Error ? error.message : 'Failed to cancel task',
},
{ status: 500 }
)

View File

@@ -86,7 +86,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json(
{
success: false,
error: 'Failed to delete push notification',
error: error instanceof Error ? error.message : 'Failed to delete push notification',
},
{ status: 500 }
)

View File

@@ -84,7 +84,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json(
{
success: false,
error: 'Failed to fetch Agent Card',
error: error instanceof Error ? error.message : 'Failed to fetch Agent Card',
},
{ status: 500 }
)

View File

@@ -107,7 +107,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json(
{
success: false,
error: 'Failed to get push notification',
error: error instanceof Error ? error.message : 'Failed to get push notification',
},
{ status: 500 }
)

View File

@@ -87,7 +87,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json(
{
success: false,
error: 'Failed to get task',
error: error instanceof Error ? error.message : 'Failed to get task',
},
{ status: 500 }
)

View File

@@ -111,7 +111,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json(
{
success: false,
error: 'Failed to resubscribe',
error: error instanceof Error ? error.message : 'Failed to resubscribe',
},
{ status: 500 }
)

View File

@@ -70,7 +70,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json(
{
success: false,
error: 'Failed to connect to agent',
error: `Failed to connect to agent: ${clientError instanceof Error ? clientError.message : 'Unknown error'}`,
},
{ status: 502 }
)
@@ -158,7 +158,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json(
{
success: false,
error: 'Failed to send message to agent',
error: `Failed to send message: ${sendError instanceof Error ? sendError.message : 'Unknown error'}`,
},
{ status: 502 }
)
@@ -218,7 +218,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json(
{
success: false,
error: 'Internal server error',
error: error instanceof Error ? error.message : 'Internal server error',
},
{ status: 500 }
)

View File

@@ -98,7 +98,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json(
{
success: false,
error: 'Failed to set push notification',
error: error instanceof Error ? error.message : 'Failed to set push notification',
},
{ status: 500 }
)

View File

@@ -182,7 +182,6 @@ vi.mock('@/lib/auth', () => ({
}))
vi.mock('@/lib/auth/hybrid', () => ({
AuthType: { SESSION: 'session', API_KEY: 'api_key', INTERNAL_JWT: 'internal_jwt' },
checkSessionOrInternalAuth: (...args: unknown[]) => mockCheckSessionOrInternalAuth(...args),
}))

View File

@@ -1,13 +1,7 @@
import { MongoClient } from 'mongodb'
import { validateDatabaseHost } from '@/lib/core/security/input-validation.server'
import type { MongoDBCollectionInfo, MongoDBConnectionConfig } from '@/tools/mongodb/types'
export async function createMongoDBConnection(config: MongoDBConnectionConfig) {
const hostValidation = await validateDatabaseHost(config.host, 'host')
if (!hostValidation.isValid) {
throw new Error(hostValidation.error)
}
const credentials =
config.username && config.password
? `${encodeURIComponent(config.username)}:${encodeURIComponent(config.password)}@`

View File

@@ -1,5 +1,4 @@
import mysql from 'mysql2/promise'
import { validateDatabaseHost } from '@/lib/core/security/input-validation.server'
export interface MySQLConnectionConfig {
host: string
@@ -11,11 +10,6 @@ export interface MySQLConnectionConfig {
}
export async function createMySQLConnection(config: MySQLConnectionConfig) {
const hostValidation = await validateDatabaseHost(config.host, 'host')
if (!hostValidation.isValid) {
throw new Error(hostValidation.error)
}
const connectionConfig: mysql.ConnectionOptions = {
host: config.host,
port: config.port,

View File

@@ -1,13 +1,7 @@
import neo4j from 'neo4j-driver'
import { validateDatabaseHost } from '@/lib/core/security/input-validation.server'
import type { Neo4jConnectionConfig } from '@/tools/neo4j/types'
export async function createNeo4jDriver(config: Neo4jConnectionConfig) {
const hostValidation = await validateDatabaseHost(config.host, 'host')
if (!hostValidation.isValid) {
throw new Error(hostValidation.error)
}
const isAuraHost =
config.host === 'databases.neo4j.io' || config.host.endsWith('.databases.neo4j.io')

View File

@@ -35,7 +35,7 @@ export async function POST(request: NextRequest) {
`[${requestId}] Deleting data from ${params.table} on ${params.host}:${params.port}/${params.database}`
)
const sql = await createPostgresConnection({
const sql = createPostgresConnection({
host: params.host,
port: params.port,
database: params.database,

View File

@@ -47,7 +47,7 @@ export async function POST(request: NextRequest) {
)
}
const sql = await createPostgresConnection({
const sql = createPostgresConnection({
host: params.host,
port: params.port,
database: params.database,

View File

@@ -57,7 +57,7 @@ export async function POST(request: NextRequest) {
`[${requestId}] Inserting data into ${params.table} on ${params.host}:${params.port}/${params.database}`
)
const sql = await createPostgresConnection({
const sql = createPostgresConnection({
host: params.host,
port: params.port,
database: params.database,

View File

@@ -34,7 +34,7 @@ export async function POST(request: NextRequest) {
`[${requestId}] Introspecting PostgreSQL schema on ${params.host}:${params.port}/${params.database}`
)
const sql = await createPostgresConnection({
const sql = createPostgresConnection({
host: params.host,
port: params.port,
database: params.database,

View File

@@ -34,7 +34,7 @@ export async function POST(request: NextRequest) {
`[${requestId}] Executing PostgreSQL query on ${params.host}:${params.port}/${params.database}`
)
const sql = await createPostgresConnection({
const sql = createPostgresConnection({
host: params.host,
port: params.port,
database: params.database,

View File

@@ -54,7 +54,7 @@ export async function POST(request: NextRequest) {
`[${requestId}] Updating data in ${params.table} on ${params.host}:${params.port}/${params.database}`
)
const sql = await createPostgresConnection({
const sql = createPostgresConnection({
host: params.host,
port: params.port,
database: params.database,

View File

@@ -1,13 +1,7 @@
import postgres from 'postgres'
import { validateDatabaseHost } from '@/lib/core/security/input-validation.server'
import type { PostgresConnectionConfig } from '@/tools/postgresql/types'
export async function createPostgresConnection(config: PostgresConnectionConfig) {
const hostValidation = await validateDatabaseHost(config.host, 'host')
if (!hostValidation.isValid) {
throw new Error(hostValidation.error)
}
export function createPostgresConnection(config: PostgresConnectionConfig) {
const sslConfig =
config.ssl === 'disabled'
? false

View File

@@ -3,7 +3,6 @@ import Redis from 'ioredis'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { validateDatabaseHost } from '@/lib/core/security/input-validation.server'
const logger = createLogger('RedisAPI')
@@ -25,16 +24,6 @@ export async function POST(request: NextRequest) {
const body = await request.json()
const { url, command, args } = RequestSchema.parse(body)
const parsedUrl = new URL(url)
const hostname =
parsedUrl.hostname.startsWith('[') && parsedUrl.hostname.endsWith(']')
? parsedUrl.hostname.slice(1, -1)
: parsedUrl.hostname
const hostValidation = await validateDatabaseHost(hostname, 'host')
if (!hostValidation.isValid) {
return NextResponse.json({ error: hostValidation.error }, { status: 400 })
}
client = new Redis(url, {
connectTimeout: 10000,
commandTimeout: 10000,

View File

@@ -1,6 +1,6 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { AuthType, checkHybridAuth } from '@/lib/auth/hybrid'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { checkServerSideUsageLimits } from '@/lib/billing'
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
import { getEffectiveCurrentPeriodCost } from '@/lib/billing/core/usage'
@@ -20,7 +20,7 @@ export async function GET(request: NextRequest) {
const userSubscription = await getHighestPrioritySubscription(authenticatedUserId)
const rateLimiter = new RateLimiter()
const triggerType = auth.authType === AuthType.API_KEY ? 'api' : 'manual'
const triggerType = auth.authType === 'api_key' ? 'api' : 'manual'
const [syncStatus, asyncStatus] = await Promise.all([
rateLimiter.getRateLimitStatusWithSubscription(
authenticatedUserId,

View File

@@ -367,7 +367,9 @@ export async function POST(request: NextRequest) {
)
}
const needsConfiguration = provider === 'gmail' || provider === 'outlook'
// Configure each new webhook (for providers that need configuration)
const pollingProviders = ['gmail', 'outlook']
const needsConfiguration = pollingProviders.includes(provider)
if (needsConfiguration) {
const configureFunc =

View File

@@ -268,32 +268,6 @@ vi.mock('@/lib/webhooks/processor', () => ({
}
}),
handleProviderChallenges: vi.fn().mockResolvedValue(null),
handlePreLookupWebhookVerification: vi
.fn()
.mockImplementation(
async (
method: string,
body: Record<string, unknown> | undefined,
_requestId: string,
path: string
) => {
if (path !== 'pending-verification-path') {
return null
}
const isVerificationProbe =
method === 'GET' ||
method === 'HEAD' ||
(method === 'POST' && (!body || Object.keys(body).length === 0 || !body.type))
if (!isVerificationProbe) {
return null
}
const { NextResponse } = require('next/server')
return NextResponse.json({ status: 'ok', message: 'Webhook endpoint verified' })
}
),
handleProviderReachabilityTest: vi.fn().mockReturnValue(null),
verifyProviderAuth: vi
.fn()
@@ -350,9 +324,7 @@ vi.mock('@/lib/webhooks/processor', () => ({
return null
}
),
checkWebhookPreprocessing: vi
.fn()
.mockResolvedValue({ error: null, actorUserId: 'test-user-id' }),
checkWebhookPreprocessing: vi.fn().mockResolvedValue(null),
formatProviderErrorResponse: vi.fn().mockImplementation((_webhook, error, status) => {
const { NextResponse } = require('next/server')
return NextResponse.json({ error }, { status })
@@ -379,7 +351,7 @@ vi.mock('@/lib/core/utils/request', () => requestUtilsMock)
process.env.DATABASE_URL = 'postgresql://test:test@localhost:5432/test'
import { GET, POST } from '@/app/api/webhooks/trigger/[path]/route'
import { POST } from '@/app/api/webhooks/trigger/[path]/route'
describe('Webhook Trigger API Route', () => {
beforeEach(() => {
@@ -415,77 +387,11 @@ describe('Webhook Trigger API Route', () => {
})
it('should handle 404 for non-existent webhooks', async () => {
const req = createMockRequest('POST', { type: 'event.test' })
const params = Promise.resolve({ path: 'non-existent-path' })
const response = await POST(req, { params })
expect(response.status).toBe(404)
const text = await response.text()
expect(text).toMatch(/not found/i)
})
it('should return 405 for GET requests on unknown webhook paths', async () => {
const req = createMockRequest(
'GET',
undefined,
{},
'http://localhost:3000/api/webhooks/trigger/non-existent-path'
)
const params = Promise.resolve({ path: 'non-existent-path' })
const response = await GET(req as any, { params })
expect(response.status).toBe(405)
})
it('should return 200 for GET verification probes on registered pending paths', async () => {
const req = createMockRequest(
'GET',
undefined,
{},
'http://localhost:3000/api/webhooks/trigger/pending-verification-path'
)
const params = Promise.resolve({ path: 'pending-verification-path' })
const response = await GET(req as any, { params })
expect(response.status).toBe(200)
await expect(response.json()).resolves.toMatchObject({
status: 'ok',
message: 'Webhook endpoint verified',
})
})
it('should return 200 for empty POST verification probes on registered pending paths', async () => {
const req = createMockRequest(
'POST',
undefined,
{},
'http://localhost:3000/api/webhooks/trigger/pending-verification-path'
)
const params = Promise.resolve({ path: 'pending-verification-path' })
const response = await POST(req as any, { params })
expect(response.status).toBe(200)
await expect(response.json()).resolves.toMatchObject({
status: 'ok',
message: 'Webhook endpoint verified',
})
})
it('should return 404 for POST requests without type on unknown webhook paths', async () => {
const req = createMockRequest('POST', { event: 'test' })
const params = Promise.resolve({ path: 'non-existent-path' })
const response = await POST(req as any, { params })
const response = await POST(req, { params })
expect(response.status).toBe(404)

View File

@@ -4,8 +4,8 @@ import { generateRequestId } from '@/lib/core/utils/request'
import {
checkWebhookPreprocessing,
findAllWebhooksForPath,
formatProviderErrorResponse,
handlePreDeploymentVerification,
handlePreLookupWebhookVerification,
handleProviderChallenges,
handleProviderReachabilityTest,
parseWebhookBody,
@@ -31,10 +31,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
return challengeResponse
}
return (
(await handlePreLookupWebhookVerification(request.method, undefined, requestId, path)) ||
new NextResponse('Method not allowed', { status: 405 })
)
return new NextResponse('Method not allowed', { status: 405 })
}
export async function POST(
@@ -68,16 +65,6 @@ export async function POST(
const webhooksForPath = await findAllWebhooksForPath({ requestId, path })
if (webhooksForPath.length === 0) {
const verificationResponse = await handlePreLookupWebhookVerification(
request.method,
body,
requestId,
path
)
if (verificationResponse) {
return verificationResponse
}
logger.warn(`[${requestId}] Webhook or workflow not found for path: ${path}`)
return new NextResponse('Not Found', { status: 404 })
}
@@ -95,6 +82,7 @@ export async function POST(
requestId
)
if (authError) {
// For multi-webhook, log and continue to next webhook
if (webhooksForPath.length > 1) {
logger.warn(`[${requestId}] Auth failed for webhook ${foundWebhook.id}, continuing to next`)
continue
@@ -104,18 +92,39 @@ export async function POST(
const reachabilityResponse = handleProviderReachabilityTest(foundWebhook, body, requestId)
if (reachabilityResponse) {
// Reachability test should return immediately for the first webhook
return reachabilityResponse
}
const preprocessResult = await checkWebhookPreprocessing(foundWorkflow, foundWebhook, requestId)
if (preprocessResult.error) {
let preprocessError: NextResponse | null = null
try {
preprocessError = await checkWebhookPreprocessing(foundWorkflow, foundWebhook, requestId)
if (preprocessError) {
if (webhooksForPath.length > 1) {
logger.warn(
`[${requestId}] Preprocessing failed for webhook ${foundWebhook.id}, continuing to next`
)
continue
}
return preprocessError
}
} catch (error) {
logger.error(`[${requestId}] Unexpected error during webhook preprocessing`, {
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
webhookId: foundWebhook.id,
workflowId: foundWorkflow.id,
})
if (webhooksForPath.length > 1) {
logger.warn(
`[${requestId}] Preprocessing failed for webhook ${foundWebhook.id}, continuing to next`
)
continue
}
return preprocessResult.error
return formatProviderErrorResponse(
foundWebhook,
'An unexpected error occurred during preprocessing',
500
)
}
if (foundWebhook.blockId) {
@@ -143,7 +152,6 @@ export async function POST(
const response = await queueWebhookExecution(foundWebhook, foundWorkflow, body, request, {
requestId,
path,
actorUserId: preprocessResult.actorUserId,
})
responses.push(response)
}

View File

@@ -49,7 +49,6 @@ vi.mock('@sim/db/schema', () => ({
}))
vi.mock('@/lib/auth/hybrid', () => ({
AuthType: { SESSION: 'session', API_KEY: 'api_key', INTERNAL_JWT: 'internal_jwt' },
checkSessionOrInternalAuth: mockCheckSessionOrInternalAuth,
}))

View File

@@ -1,115 +0,0 @@
/**
* Tests that internal JWT callers receive the standard response format
* even when the child workflow has a Response block.
*
* @vitest-environment node
*/
import { beforeEach, describe, expect, it } from 'vitest'
import { AuthType } from '@/lib/auth/hybrid'
import type { ExecutionResult } from '@/lib/workflows/types'
import { createHttpResponseFromBlock, workflowHasResponseBlock } from '@/lib/workflows/utils'
function buildExecutionResult(overrides: Partial<ExecutionResult> = {}): ExecutionResult {
return {
success: true,
output: { data: { issues: [] }, status: 200, headers: {} },
logs: [
{
blockId: 'response-1',
blockType: 'response',
blockName: 'Response',
success: true,
output: { data: { issues: [] }, status: 200, headers: {} },
startedAt: '2026-01-01T00:00:00Z',
endedAt: '2026-01-01T00:00:01Z',
},
],
metadata: {
duration: 500,
startTime: '2026-01-01T00:00:00Z',
endTime: '2026-01-01T00:00:01Z',
},
...overrides,
}
}
describe('Response block gating by auth type', () => {
let resultWithResponseBlock: ExecutionResult
beforeEach(() => {
resultWithResponseBlock = buildExecutionResult()
})
it('should detect a Response block in execution result', () => {
expect(workflowHasResponseBlock(resultWithResponseBlock)).toBe(true)
})
it('should not detect a Response block when none exists', () => {
const resultWithoutResponseBlock = buildExecutionResult({
output: { result: 'hello' },
logs: [
{
blockId: 'agent-1',
blockType: 'agent',
blockName: 'Agent',
success: true,
output: { result: 'hello' },
startedAt: '2026-01-01T00:00:00Z',
endedAt: '2026-01-01T00:00:01Z',
},
],
})
expect(workflowHasResponseBlock(resultWithoutResponseBlock)).toBe(false)
})
it('should skip Response block formatting for internal JWT callers', () => {
const authType = AuthType.INTERNAL_JWT
const hasResponseBlock = workflowHasResponseBlock(resultWithResponseBlock)
expect(hasResponseBlock).toBe(true)
// This mirrors the route.ts condition:
// if (auth.authType !== AuthType.INTERNAL_JWT && workflowHasResponseBlock(...))
const shouldFormatAsResponseBlock = authType !== AuthType.INTERNAL_JWT && hasResponseBlock
expect(shouldFormatAsResponseBlock).toBe(false)
})
it('should apply Response block formatting for API key callers', () => {
const authType = AuthType.API_KEY
const hasResponseBlock = workflowHasResponseBlock(resultWithResponseBlock)
const shouldFormatAsResponseBlock = authType !== AuthType.INTERNAL_JWT && hasResponseBlock
expect(shouldFormatAsResponseBlock).toBe(true)
const response = createHttpResponseFromBlock(resultWithResponseBlock)
expect(response.status).toBe(200)
})
it('should apply Response block formatting for session callers', () => {
const authType = AuthType.SESSION
const hasResponseBlock = workflowHasResponseBlock(resultWithResponseBlock)
const shouldFormatAsResponseBlock = authType !== AuthType.INTERNAL_JWT && hasResponseBlock
expect(shouldFormatAsResponseBlock).toBe(true)
})
it('should return raw user data via createHttpResponseFromBlock', async () => {
const response = createHttpResponseFromBlock(resultWithResponseBlock)
const body = await response.json()
// Response block returns the user-defined data directly (no success/executionId wrapper)
expect(body).toEqual({ issues: [] })
expect(body.success).toBeUndefined()
expect(body.executionId).toBeUndefined()
})
it('should respect custom status codes from Response block', () => {
const result = buildExecutionResult({
output: { data: { error: 'Not found' }, status: 404, headers: {} },
})
const response = createHttpResponseFromBlock(result)
expect(response.status).toBe(404)
})
})

View File

@@ -2,7 +2,7 @@ import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { validate as uuidValidate, v4 as uuidv4 } from 'uuid'
import { z } from 'zod'
import { AuthType, checkHybridAuth } from '@/lib/auth/hybrid'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { getJobQueue, shouldExecuteInline } from '@/lib/core/async-jobs'
import {
createTimeoutAbortController,
@@ -322,8 +322,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
)
}
const defaultTriggerType =
isPublicApiAccess || auth.authType === AuthType.API_KEY ? 'api' : 'manual'
const defaultTriggerType = isPublicApiAccess || auth.authType === 'api_key' ? 'api' : 'manual'
const {
selectedOutputs,
@@ -382,9 +381,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
// For API key and internal JWT auth, the entire body is the input (except for our control fields)
// For session auth, the input is explicitly provided in the input field
const input =
isPublicApiAccess ||
auth.authType === AuthType.API_KEY ||
auth.authType === AuthType.INTERNAL_JWT
isPublicApiAccess || auth.authType === 'api_key' || auth.authType === 'internal_jwt'
? (() => {
const {
selectedOutputs,
@@ -410,7 +407,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
// Public API callers always execute the deployed state, never the draft.
const shouldUseDraftState = isPublicApiAccess
? false
: (useDraftState ?? auth.authType === AuthType.SESSION)
: (useDraftState ?? auth.authType === 'session')
const streamHeader = req.headers.get('X-Stream-Response') === 'true'
const enableSSE = streamHeader || streamParam === true
const executionModeHeader = req.headers.get('X-Execution-Mode')
@@ -443,7 +440,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
// Client-side sessions and personal API keys bill/permission-check the
// authenticated user, not the workspace billed account.
const useAuthenticatedUserAsActor =
isClientSession || (auth.authType === AuthType.API_KEY && auth.apiKeyType === 'personal')
isClientSession || (auth.authType === 'api_key' && auth.apiKeyType === 'personal')
// Authorization fetches the full workflow record and checks workspace permissions.
// Run it first so we can pass the record to preprocessing (eliminates a duplicate DB query).
@@ -673,7 +670,8 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
const resultWithBase64 = { ...result, output: outputWithBase64 }
if (auth.authType !== AuthType.INTERNAL_JWT && workflowHasResponseBlock(resultWithBase64)) {
const hasResponseBlock = workflowHasResponseBlock(resultWithBase64)
if (hasResponseBlock) {
return createHttpResponseFromBlock(resultWithBase64)
}

View File

@@ -44,7 +44,6 @@ vi.mock('@sim/db/schema', () => ({
}))
vi.mock('@/lib/auth/hybrid', () => ({
AuthType: { SESSION: 'session', API_KEY: 'api_key', INTERNAL_JWT: 'internal_jwt' },
checkSessionOrInternalAuth: mockCheckSessionOrInternalAuth,
}))

View File

@@ -43,7 +43,6 @@ vi.mock('@/lib/auth', () => ({
}))
vi.mock('@/lib/auth/hybrid', () => ({
AuthType: { SESSION: 'session', API_KEY: 'api_key', INTERNAL_JWT: 'internal_jwt' },
checkHybridAuth: (...args: unknown[]) => mockCheckHybridAuth(...args),
checkSessionOrInternalAuth: (...args: unknown[]) => mockCheckSessionOrInternalAuth(...args),
}))

View File

@@ -5,7 +5,7 @@ import { and, eq, isNull, ne } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { AuthType, checkHybridAuth, checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { checkHybridAuth, checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { env } from '@/lib/core/config/env'
import { PlatformEvents } from '@/lib/core/telemetry'
import { generateRequestId } from '@/lib/core/utils/request'
@@ -39,7 +39,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const isInternalCall = auth.authType === AuthType.INTERNAL_JWT
const isInternalCall = auth.authType === 'internal_jwt'
const userId = auth.userId || null
let workflowData = await getWorkflowById(workflowId)

View File

@@ -18,7 +18,6 @@ const { mockCheckSessionOrInternalAuth, mockAuthorizeWorkflowByWorkspacePermissi
vi.mock('@/lib/audit/log', () => auditMock)
vi.mock('@/lib/auth/hybrid', () => ({
AuthType: { SESSION: 'session', API_KEY: 'api_key', INTERNAL_JWT: 'internal_jwt' },
checkSessionOrInternalAuth: mockCheckSessionOrInternalAuth,
}))

View File

@@ -64,7 +64,6 @@ vi.mock('@/lib/audit/log', () => ({
}))
vi.mock('@/lib/auth/hybrid', () => ({
AuthType: { SESSION: 'session', API_KEY: 'api_key', INTERNAL_JWT: 'internal_jwt' },
checkHybridAuth: vi.fn(),
checkSessionOrInternalAuth: mockCheckSessionOrInternalAuth,
checkInternalAuth: vi.fn(),

View File

@@ -12,7 +12,6 @@ import {
} from '@/components/emails'
import { getSession } from '@/lib/auth'
import { decryptSecret } from '@/lib/core/security/encryption'
import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { sendEmail } from '@/lib/messaging/email/mailer'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
@@ -136,18 +135,18 @@ async function testWebhook(subscription: typeof workspaceNotificationSubscriptio
headers['sim-signature'] = `t=${timestamp},v1=${signature}`
}
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 10000)
try {
const response = await secureFetchWithValidation(
webhookConfig.url,
{
method: 'POST',
headers,
body,
timeout: 10000,
allowHttp: true,
},
'webhookUrl'
)
const response = await fetch(webhookConfig.url, {
method: 'POST',
headers,
body,
signal: controller.signal,
})
clearTimeout(timeoutId)
const responseBody = await response.text().catch(() => '')
return {
@@ -158,10 +157,12 @@ async function testWebhook(subscription: typeof workspaceNotificationSubscriptio
timestamp: new Date().toISOString(),
}
} catch (error: unknown) {
logger.warn('Webhook test failed', {
error: error instanceof Error ? error.message : String(error),
})
return { success: false, error: 'Failed to deliver webhook' }
clearTimeout(timeoutId)
const err = error as Error & { name?: string }
if (err.name === 'AbortError') {
return { success: false, error: 'Request timeout after 10 seconds' }
}
return { success: false, error: err.message }
}
}
@@ -267,15 +268,13 @@ async function testSlack(
return {
success: result.ok,
error: result.ok ? undefined : `Slack error: ${result.error || 'unknown'}`,
error: result.error,
channel: result.channel,
timestamp: new Date().toISOString(),
}
} catch (error: unknown) {
logger.warn('Slack test notification failed', {
error: error instanceof Error ? error.message : String(error),
})
return { success: false, error: 'Failed to send Slack notification' }
const err = error as Error
return { success: false, error: err.message }
}
}

View File

@@ -12,7 +12,7 @@ interface UseShiftSelectionLockResult {
/** Computed ReactFlow props based on current selection state */
selectionProps: {
selectionOnDrag: boolean
panOnDrag: number[]
panOnDrag: [number, number] | false
selectionKeyCode: string | null
}
}
@@ -55,7 +55,7 @@ export function useShiftSelectionLock({
const selectionProps = {
selectionOnDrag: !isHandMode || isShiftSelecting,
panOnDrag: isHandMode && !isShiftSelecting ? [0, 1] : [1],
panOnDrag: (isHandMode && !isShiftSelecting ? [0, 1] : false) as [number, number] | false,
selectionKeyCode: isShiftSelecting ? null : 'Shift',
}

View File

@@ -1,13 +1,18 @@
import { db } from '@sim/db'
import { account, webhook } from '@sim/db/schema'
import { webhook, workflow as workflowTable } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { task } from '@trigger.dev/sdk'
import { eq } from 'drizzle-orm'
import { v4 as uuidv4 } from 'uuid'
import { createTimeoutAbortController, getTimeoutErrorMessage } from '@/lib/core/execution-limits'
import { getHighestPrioritySubscription } from '@/lib/billing'
import {
createTimeoutAbortController,
getExecutionTimeout,
getTimeoutErrorMessage,
} from '@/lib/core/execution-limits'
import { IdempotencyService, webhookIdempotency } from '@/lib/core/idempotency'
import type { SubscriptionPlan } from '@/lib/core/rate-limiter/types'
import { processExecutionFiles } from '@/lib/execution/files'
import { preprocessExecution } from '@/lib/execution/preprocessing'
import { LoggingSession } from '@/lib/logs/execution/logging-session'
import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans'
import { WebhookAttachmentProcessor } from '@/lib/webhooks/attachment-processor'
@@ -15,7 +20,7 @@ import { fetchAndProcessAirtablePayloads, formatWebhookInput } from '@/lib/webho
import { executeWorkflowCore } from '@/lib/workflows/executor/execution-core'
import { PauseResumeManager } from '@/lib/workflows/executor/human-in-the-loop-manager'
import { loadDeployedWorkflowState } from '@/lib/workflows/persistence/utils'
import { resolveOAuthAccountId } from '@/app/api/auth/oauth/utils'
import { getWorkflowById } from '@/lib/workflows/utils'
import { getBlock } from '@/blocks'
import { ExecutionSnapshot } from '@/executor/execution/snapshot'
import type { ExecutionMetadata } from '@/executor/execution/types'
@@ -104,8 +109,8 @@ export type WebhookExecutionPayload = {
headers: Record<string, string>
path: string
blockId?: string
workspaceId?: string
credentialId?: string
credentialAccountUserId?: string
}
export async function executeWebhookJob(payload: WebhookExecutionPayload) {
@@ -138,22 +143,6 @@ export async function executeWebhookJob(payload: WebhookExecutionPayload) {
)
}
/**
* Resolve the account userId for a credential
*/
async function resolveCredentialAccountUserId(credentialId: string): Promise<string | undefined> {
const resolved = await resolveOAuthAccountId(credentialId)
if (!resolved) {
return undefined
}
const [credentialRecord] = await db
.select({ userId: account.userId })
.from(account)
.where(eq(account.id, resolved.accountId))
.limit(1)
return credentialRecord?.userId
}
async function executeWebhookJobInternal(
payload: WebhookExecutionPayload,
executionId: string,
@@ -166,56 +155,17 @@ async function executeWebhookJobInternal(
requestId
)
// Resolve workflow record, billing actor, subscription, and timeout
const preprocessResult = await preprocessExecution({
workflowId: payload.workflowId,
userId: payload.userId,
triggerType: 'webhook',
executionId,
requestId,
checkRateLimit: false,
checkDeployment: false,
skipUsageLimits: true,
workspaceId: payload.workspaceId,
loggingSession,
})
if (!preprocessResult.success) {
throw new Error(preprocessResult.error?.message || 'Preprocessing failed in background job')
}
const { workflowRecord, executionTimeout } = preprocessResult
if (!workflowRecord) {
throw new Error(`Workflow ${payload.workflowId} not found during preprocessing`)
}
const workspaceId = workflowRecord.workspaceId
if (!workspaceId) {
throw new Error(`Workflow ${payload.workflowId} has no associated workspace`)
}
const workflowVariables = (workflowRecord.variables as Record<string, any>) || {}
const asyncTimeout = executionTimeout?.async ?? 120_000
const userSubscription = await getHighestPrioritySubscription(payload.userId)
const asyncTimeout = getExecutionTimeout(
userSubscription?.plan as SubscriptionPlan | undefined,
'async'
)
const timeoutController = createTimeoutAbortController(asyncTimeout)
let deploymentVersionId: string | undefined
try {
// Parallelize workflow state, webhook record, and credential resolution
const [workflowData, webhookRows, resolvedCredentialUserId] = await Promise.all([
loadDeployedWorkflowState(payload.workflowId, workspaceId),
db.select().from(webhook).where(eq(webhook.id, payload.webhookId)).limit(1),
payload.credentialId
? resolveCredentialAccountUserId(payload.credentialId)
: Promise.resolve(undefined),
])
const credentialAccountUserId = resolvedCredentialUserId
if (payload.credentialId && !credentialAccountUserId) {
logger.warn(
`[${requestId}] Failed to resolve credential account for credential ${payload.credentialId}`
)
}
const workflowData = await loadDeployedWorkflowState(payload.workflowId)
if (!workflowData) {
throw new Error(
'Workflow state not found. The workflow may not be deployed or the deployment data may be corrupted.'
@@ -228,11 +178,28 @@ async function executeWebhookJobInternal(
? (workflowData.deploymentVersionId as string)
: undefined
const wfRows = await db
.select({ workspaceId: workflowTable.workspaceId, variables: workflowTable.variables })
.from(workflowTable)
.where(eq(workflowTable.id, payload.workflowId))
.limit(1)
const workspaceId = wfRows[0]?.workspaceId
if (!workspaceId) {
throw new Error(`Workflow ${payload.workflowId} has no associated workspace`)
}
const workflowVariables = (wfRows[0]?.variables as Record<string, any>) || {}
// Handle special Airtable case
if (payload.provider === 'airtable') {
logger.info(`[${requestId}] Processing Airtable webhook via fetchAndProcessAirtablePayloads`)
const webhookRecord = webhookRows[0]
// Load the actual webhook record from database to get providerConfig
const [webhookRecord] = await db
.select()
.from(webhook)
.where(eq(webhook.id, payload.webhookId))
.limit(1)
if (!webhookRecord) {
throw new Error(`Webhook record not found: ${payload.webhookId}`)
}
@@ -243,20 +210,29 @@ async function executeWebhookJobInternal(
providerConfig: webhookRecord.providerConfig,
}
// Create a mock workflow object for Airtable processing
const mockWorkflow = {
id: payload.workflowId,
userId: payload.userId,
}
// Get the processed Airtable input
const airtableInput = await fetchAndProcessAirtablePayloads(
webhookData,
mockWorkflow,
requestId
)
// If we got input (changes), execute the workflow like other providers
if (airtableInput) {
logger.info(`[${requestId}] Executing workflow with Airtable changes`)
// Get workflow for core execution
const workflow = await getWorkflowById(payload.workflowId)
if (!workflow) {
throw new Error(`Workflow ${payload.workflowId} not found`)
}
const metadata: ExecutionMetadata = {
requestId,
executionId,
@@ -264,13 +240,13 @@ async function executeWebhookJobInternal(
workspaceId,
userId: payload.userId,
sessionUserId: undefined,
workflowUserId: workflowRecord.userId,
workflowUserId: workflow.userId,
triggerType: payload.provider || 'webhook',
triggerBlockId: payload.blockId,
useDraftState: false,
startTime: new Date().toISOString(),
isClientSession: false,
credentialAccountUserId,
credentialAccountUserId: payload.credentialAccountUserId,
workflowStateOverride: {
blocks,
edges,
@@ -282,7 +258,7 @@ async function executeWebhookJobInternal(
const snapshot = new ExecutionSnapshot(
metadata,
workflowRecord,
workflow,
airtableInput,
workflowVariables,
[]
@@ -353,6 +329,7 @@ async function executeWebhookJobInternal(
// No changes to process
logger.info(`[${requestId}] No Airtable changes to process`)
// Start logging session so the complete call has a log entry to update
await loggingSession.safeStart({
userId: payload.userId,
workspaceId,
@@ -380,6 +357,13 @@ async function executeWebhookJobInternal(
}
// Format input for standard webhooks
// Load the actual webhook to get providerConfig (needed for Teams credentialId)
const webhookRows = await db
.select()
.from(webhook)
.where(eq(webhook.id, payload.webhookId))
.limit(1)
const actualWebhook =
webhookRows.length > 0
? webhookRows[0]
@@ -402,6 +386,7 @@ async function executeWebhookJobInternal(
if (!input && payload.provider === 'whatsapp') {
logger.info(`[${requestId}] No messages in WhatsApp payload, skipping execution`)
// Start logging session so the complete call has a log entry to update
await loggingSession.safeStart({
userId: payload.userId,
workspaceId,
@@ -467,6 +452,7 @@ async function executeWebhookJobInternal(
}
} catch (error) {
logger.error(`[${requestId}] Error processing trigger file outputs:`, error)
// Continue without processing attachments rather than failing execution
}
}
@@ -513,11 +499,18 @@ async function executeWebhookJobInternal(
}
} catch (error) {
logger.error(`[${requestId}] Error processing generic webhook files:`, error)
// Continue without processing files rather than failing execution
}
}
logger.info(`[${requestId}] Executing workflow for ${payload.provider} webhook`)
// Get workflow for core execution
const workflow = await getWorkflowById(payload.workflowId)
if (!workflow) {
throw new Error(`Workflow ${payload.workflowId} not found`)
}
const metadata: ExecutionMetadata = {
requestId,
executionId,
@@ -525,13 +518,13 @@ async function executeWebhookJobInternal(
workspaceId,
userId: payload.userId,
sessionUserId: undefined,
workflowUserId: workflowRecord.userId,
workflowUserId: workflow.userId,
triggerType: payload.provider || 'webhook',
triggerBlockId: payload.blockId,
useDraftState: false,
startTime: new Date().toISOString(),
isClientSession: false,
credentialAccountUserId,
credentialAccountUserId: payload.credentialAccountUserId,
workflowStateOverride: {
blocks,
edges,
@@ -543,13 +536,7 @@ async function executeWebhookJobInternal(
const triggerInput = input || {}
const snapshot = new ExecutionSnapshot(
metadata,
workflowRecord,
triggerInput,
workflowVariables,
[]
)
const snapshot = new ExecutionSnapshot(metadata, workflow, triggerInput, workflowVariables, [])
const executionResult = await executeWorkflowCore({
snapshot,
@@ -624,9 +611,23 @@ async function executeWebhookJobInternal(
})
try {
const wfRow = await db
.select({ workspaceId: workflowTable.workspaceId })
.from(workflowTable)
.where(eq(workflowTable.id, payload.workflowId))
.limit(1)
const errorWorkspaceId = wfRow[0]?.workspaceId
if (!errorWorkspaceId) {
logger.warn(
`[${requestId}] Cannot log error: workflow ${payload.workflowId} has no workspace`
)
throw error
}
await loggingSession.safeStart({
userId: payload.userId,
workspaceId,
workspaceId: errorWorkspaceId,
variables: {},
triggerData: {
isTest: false,

View File

@@ -19,7 +19,6 @@ import { checkUsageStatus } from '@/lib/billing/calculations/usage-monitor'
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
import { RateLimiter } from '@/lib/core/rate-limiter'
import { decryptSecret } from '@/lib/core/security/encryption'
import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server'
import { formatDuration } from '@/lib/core/utils/formatting'
import { getBaseUrl } from '@/lib/core/utils/urls'
import type { TraceSpan, WorkflowExecutionLog } from '@/lib/logs/types'
@@ -208,18 +207,18 @@ async function deliverWebhook(
headers['sim-signature'] = `t=${payload.timestamp},v1=${signature}`
}
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 30000)
try {
const response = await secureFetchWithValidation(
webhookConfig.url,
{
method: 'POST',
headers,
body,
timeout: 30000,
allowHttp: true,
},
'webhookUrl'
)
const response = await fetch(webhookConfig.url, {
method: 'POST',
headers,
body,
signal: controller.signal,
})
clearTimeout(timeoutId)
return {
success: response.ok,
@@ -227,13 +226,11 @@ async function deliverWebhook(
error: response.ok ? undefined : `HTTP ${response.status}`,
}
} catch (error: unknown) {
logger.warn('Webhook delivery failed', {
error: error instanceof Error ? error.message : String(error),
webhookUrl: webhookConfig.url,
})
clearTimeout(timeoutId)
const err = error as Error & { name?: string }
return {
success: false,
error: 'Failed to deliver webhook',
error: err.name === 'AbortError' ? 'Request timeout' : err.message,
}
}
}

View File

@@ -1,6 +1,5 @@
import { AshbyIcon } from '@/components/icons'
import { AuthMode, type BlockConfig } from '@/blocks/types'
import { getTrigger } from '@/triggers'
export const AshbyBlock: BlockConfig = {
type: 'ashby',
@@ -14,18 +13,6 @@ export const AshbyBlock: BlockConfig = {
icon: AshbyIcon,
authMode: AuthMode.ApiKey,
triggers: {
enabled: true,
available: [
'ashby_application_submit',
'ashby_candidate_stage_change',
'ashby_candidate_hire',
'ashby_candidate_delete',
'ashby_job_create',
'ashby_offer_create',
],
},
subBlocks: [
{
id: 'operation',
@@ -379,14 +366,6 @@ Output only the ISO 8601 timestamp string, nothing else.`,
},
mode: 'advanced',
},
// Trigger subBlocks
...getTrigger('ashby_application_submit').subBlocks,
...getTrigger('ashby_candidate_stage_change').subBlocks,
...getTrigger('ashby_candidate_hire').subBlocks,
...getTrigger('ashby_candidate_delete').subBlocks,
...getTrigger('ashby_job_create').subBlocks,
...getTrigger('ashby_offer_create').subBlocks,
],
tools: {

View File

@@ -1,211 +0,0 @@
import { FathomIcon } from '@/components/icons'
import { AuthMode, type BlockConfig } from '@/blocks/types'
import type { FathomResponse } from '@/tools/fathom/types'
import { getTrigger } from '@/triggers'
import { fathomTriggerOptions } from '@/triggers/fathom/utils'
export const FathomBlock: BlockConfig<FathomResponse> = {
type: 'fathom',
name: 'Fathom',
description: 'Access meeting recordings, transcripts, and summaries',
authMode: AuthMode.ApiKey,
triggerAllowed: true,
longDescription:
'Integrate Fathom AI Notetaker into your workflow. List meetings, get transcripts and summaries, and manage team members and teams. Can also trigger workflows when new meeting content is ready.',
docsLink: 'https://docs.sim.ai/tools/fathom',
category: 'tools',
bgColor: '#181C1E',
icon: FathomIcon,
subBlocks: [
{
id: 'operation',
title: 'Operation',
type: 'dropdown',
options: [
{ label: 'List Meetings', id: 'fathom_list_meetings' },
{ label: 'Get Summary', id: 'fathom_get_summary' },
{ label: 'Get Transcript', id: 'fathom_get_transcript' },
{ label: 'List Team Members', id: 'fathom_list_team_members' },
{ label: 'List Teams', id: 'fathom_list_teams' },
],
value: () => 'fathom_list_meetings',
},
{
id: 'recordingId',
title: 'Recording ID',
type: 'short-input',
required: { field: 'operation', value: ['fathom_get_summary', 'fathom_get_transcript'] },
placeholder: 'Enter the recording ID',
condition: { field: 'operation', value: ['fathom_get_summary', 'fathom_get_transcript'] },
},
{
id: 'includeSummary',
title: 'Include Summary',
type: 'dropdown',
options: [
{ label: 'No', id: 'false' },
{ label: 'Yes', id: 'true' },
],
value: () => 'false',
condition: { field: 'operation', value: 'fathom_list_meetings' },
},
{
id: 'includeTranscript',
title: 'Include Transcript',
type: 'dropdown',
options: [
{ label: 'No', id: 'false' },
{ label: 'Yes', id: 'true' },
],
value: () => 'false',
condition: { field: 'operation', value: 'fathom_list_meetings' },
},
{
id: 'includeActionItems',
title: 'Include Action Items',
type: 'dropdown',
options: [
{ label: 'No', id: 'false' },
{ label: 'Yes', id: 'true' },
],
value: () => 'false',
condition: { field: 'operation', value: 'fathom_list_meetings' },
},
{
id: 'includeCrmMatches',
title: 'Include CRM Matches',
type: 'dropdown',
options: [
{ label: 'No', id: 'false' },
{ label: 'Yes', id: 'true' },
],
value: () => 'false',
condition: { field: 'operation', value: 'fathom_list_meetings' },
},
{
id: 'createdAfter',
title: 'Created After',
type: 'short-input',
placeholder: 'ISO 8601 timestamp (e.g., 2025-01-01T00:00:00Z)',
condition: { field: 'operation', value: 'fathom_list_meetings' },
mode: 'advanced',
wandConfig: {
enabled: true,
prompt: 'Generate an ISO 8601 timestamp. Return ONLY the timestamp string.',
generationType: 'timestamp',
},
},
{
id: 'createdBefore',
title: 'Created Before',
type: 'short-input',
placeholder: 'ISO 8601 timestamp (e.g., 2025-12-31T23:59:59Z)',
condition: { field: 'operation', value: 'fathom_list_meetings' },
mode: 'advanced',
wandConfig: {
enabled: true,
prompt: 'Generate an ISO 8601 timestamp. Return ONLY the timestamp string.',
generationType: 'timestamp',
},
},
{
id: 'recordedBy',
title: 'Recorded By',
type: 'short-input',
placeholder: 'Filter by recorder email',
condition: { field: 'operation', value: 'fathom_list_meetings' },
mode: 'advanced',
},
{
id: 'teams',
title: 'Team',
type: 'short-input',
placeholder: 'Filter by team name',
condition: {
field: 'operation',
value: ['fathom_list_meetings', 'fathom_list_team_members'],
},
mode: 'advanced',
},
{
id: 'cursor',
title: 'Pagination Cursor',
type: 'short-input',
placeholder: 'Cursor from a previous response',
condition: {
field: 'operation',
value: ['fathom_list_meetings', 'fathom_list_team_members', 'fathom_list_teams'],
},
mode: 'advanced',
},
{
id: 'apiKey',
title: 'API Key',
type: 'short-input',
required: true,
placeholder: 'Enter your Fathom API key',
password: true,
},
{
id: 'selectedTriggerId',
title: 'Trigger Type',
type: 'dropdown',
mode: 'trigger',
options: fathomTriggerOptions,
value: () => 'fathom_new_meeting',
required: true,
},
...getTrigger('fathom_new_meeting').subBlocks,
...getTrigger('fathom_webhook').subBlocks,
],
tools: {
access: [
'fathom_list_meetings',
'fathom_get_summary',
'fathom_get_transcript',
'fathom_list_team_members',
'fathom_list_teams',
],
config: {
tool: (params) => {
return params.operation || 'fathom_list_meetings'
},
},
},
inputs: {
operation: { type: 'string', description: 'Operation to perform' },
apiKey: { type: 'string', description: 'Fathom API key' },
recordingId: { type: 'string', description: 'Recording ID for summary or transcript' },
includeSummary: { type: 'string', description: 'Include summary in meetings response' },
includeTranscript: { type: 'string', description: 'Include transcript in meetings response' },
includeActionItems: {
type: 'string',
description: 'Include action items in meetings response',
},
includeCrmMatches: {
type: 'string',
description: 'Include linked CRM matches in meetings response',
},
createdAfter: { type: 'string', description: 'Filter meetings created after this timestamp' },
createdBefore: {
type: 'string',
description: 'Filter meetings created before this timestamp',
},
recordedBy: { type: 'string', description: 'Filter by recorder email' },
teams: { type: 'string', description: 'Filter by team name' },
cursor: { type: 'string', description: 'Pagination cursor for next page' },
},
outputs: {
meetings: { type: 'json', description: 'List of meetings' },
template_name: { type: 'string', description: 'Summary template name' },
markdown_formatted: { type: 'string', description: 'Markdown-formatted summary' },
transcript: { type: 'json', description: 'Meeting transcript entries' },
members: { type: 'json', description: 'List of team members' },
teams: { type: 'json', description: 'List of teams' },
next_cursor: { type: 'string', description: 'Pagination cursor' },
},
triggers: {
enabled: true,
available: ['fathom_new_meeting', 'fathom_webhook'],
},
}

View File

@@ -18,7 +18,6 @@ export const GenericWebhookBlock: BlockConfig = {
bestPractices: `
- You can test the webhook by sending a request to the webhook URL. E.g. depending on authorization: curl -X POST http://localhost:3000/api/webhooks/trigger/d8abcf0d-1ee5-4b77-bb07-b1e8142ea4e9 -H "Content-Type: application/json" -H "X-Sim-Secret: 1234" -d '{"message": "Test webhook trigger", "data": {"key": "v"}}'
- Continuing example above, the body can be accessed in downstream block using dot notation. E.g. <webhook1.message> and <webhook1.data.key>
- To deduplicate incoming events, set the Deduplication Field to a dot-notation path of a unique field in the payload (e.g. "event.id"). Duplicate values within 7 days will be skipped.
- Only use when there's no existing integration for the service with triggerAllowed flag set to true.
`,
subBlocks: [...getTrigger('generic_webhook').subBlocks],

View File

@@ -1,294 +0,0 @@
import { GoogleAdsIcon } from '@/components/icons'
import { getScopesForService } from '@/lib/oauth/utils'
import type { BlockConfig } from '@/blocks/types'
import { AuthMode } from '@/blocks/types'
export const GoogleAdsBlock: BlockConfig = {
type: 'google_ads',
name: 'Google Ads',
description: 'Query campaigns, ad groups, and performance metrics',
longDescription:
'Connect to Google Ads to list accessible accounts, list campaigns, view ad group details, get performance metrics, and run custom GAQL queries.',
docsLink: 'https://docs.sim.ai/tools/google_ads',
category: 'tools',
bgColor: '#E0E0E0',
icon: GoogleAdsIcon,
authMode: AuthMode.OAuth,
subBlocks: [
{
id: 'operation',
title: 'Operation',
type: 'dropdown',
options: [
{ label: 'List Customers', id: 'list_customers' },
{ label: 'List Campaigns', id: 'list_campaigns' },
{ label: 'Campaign Performance', id: 'campaign_performance' },
{ label: 'List Ad Groups', id: 'list_ad_groups' },
{ label: 'Ad Performance', id: 'ad_performance' },
{ label: 'Custom Query (GAQL)', id: 'search' },
],
value: () => 'list_campaigns',
},
{
id: 'credential',
title: 'Google Ads Account',
type: 'oauth-input',
canonicalParamId: 'oauthCredential',
mode: 'basic',
required: true,
serviceId: 'google-ads',
requiredScopes: getScopesForService('google-ads'),
placeholder: 'Select Google Ads account',
},
{
id: 'manualCredential',
title: 'Google Ads Account',
type: 'short-input',
canonicalParamId: 'oauthCredential',
mode: 'advanced',
placeholder: 'Enter credential ID',
required: true,
},
{
id: 'developerToken',
title: 'Developer Token',
type: 'short-input',
placeholder: 'Enter your Google Ads API developer token',
required: true,
password: true,
},
{
id: 'customerId',
title: 'Customer ID',
type: 'short-input',
placeholder: 'Google Ads customer ID (no dashes)',
condition: {
field: 'operation',
value: 'list_customers',
not: true,
},
required: {
field: 'operation',
value: 'list_customers',
not: true,
},
},
{
id: 'managerCustomerId',
title: 'Manager Customer ID',
type: 'short-input',
placeholder: 'Manager account ID (optional)',
mode: 'advanced',
condition: {
field: 'operation',
value: 'list_customers',
not: true,
},
},
{
id: 'query',
title: 'GAQL Query',
type: 'long-input',
placeholder:
"SELECT campaign.id, campaign.name, metrics.impressions FROM campaign WHERE campaign.status = 'ENABLED'",
condition: { field: 'operation', value: 'search' },
required: { field: 'operation', value: 'search' },
wandConfig: {
enabled: true,
prompt: `Generate a Google Ads Query Language (GAQL) query based on the user's description.
The query should:
- Use valid GAQL syntax
- Include relevant metrics when asking about performance
- Include segments.date with a date range when using metrics
- Be efficient and well-formatted
Common resources: campaign, ad_group, ad_group_ad, keyword_view, search_term_view
Common metrics: metrics.impressions, metrics.clicks, metrics.cost_micros, metrics.ctr, metrics.conversions
Date ranges: LAST_7_DAYS, LAST_30_DAYS, THIS_MONTH, YESTERDAY
Examples:
- "active campaigns" -> SELECT campaign.id, campaign.name, campaign.status FROM campaign WHERE campaign.status = 'ENABLED'
- "campaign spend last week" -> SELECT campaign.name, metrics.cost_micros, segments.date FROM campaign WHERE segments.date DURING LAST_7_DAYS AND campaign.status != 'REMOVED'
Return ONLY the GAQL query - no explanations, no quotes, no extra text.`,
placeholder: 'Describe the query you want to run...',
},
},
{
id: 'campaignId',
title: 'Campaign ID',
type: 'short-input',
placeholder: 'Campaign ID to filter by',
condition: {
field: 'operation',
value: ['campaign_performance', 'list_ad_groups', 'ad_performance'],
},
required: { field: 'operation', value: 'list_ad_groups' },
},
{
id: 'adGroupId',
title: 'Ad Group ID',
type: 'short-input',
placeholder: 'Ad group ID to filter by',
mode: 'advanced',
condition: { field: 'operation', value: 'ad_performance' },
},
{
id: 'status',
title: 'Status Filter',
type: 'dropdown',
options: [
{ label: 'All (except removed)', id: '' },
{ label: 'Enabled', id: 'ENABLED' },
{ label: 'Paused', id: 'PAUSED' },
],
mode: 'advanced',
condition: { field: 'operation', value: ['list_campaigns', 'list_ad_groups'] },
},
{
id: 'dateRange',
title: 'Date Range',
type: 'dropdown',
options: [
{ label: 'Last 30 Days', id: 'LAST_30_DAYS' },
{ label: 'Last 7 Days', id: 'LAST_7_DAYS' },
{ label: 'Today', id: 'TODAY' },
{ label: 'Yesterday', id: 'YESTERDAY' },
{ label: 'This Month', id: 'THIS_MONTH' },
{ label: 'Last Month', id: 'LAST_MONTH' },
{ label: 'Custom', id: 'CUSTOM' },
],
condition: { field: 'operation', value: ['campaign_performance', 'ad_performance'] },
value: () => 'LAST_30_DAYS',
},
{
id: 'startDate',
title: 'Start Date',
type: 'short-input',
placeholder: 'YYYY-MM-DD',
condition: { field: 'dateRange', value: 'CUSTOM' },
required: { field: 'dateRange', value: 'CUSTOM' },
},
{
id: 'endDate',
title: 'End Date',
type: 'short-input',
placeholder: 'YYYY-MM-DD',
condition: { field: 'dateRange', value: 'CUSTOM' },
required: { field: 'dateRange', value: 'CUSTOM' },
},
{
id: 'pageToken',
title: 'Page Token',
type: 'short-input',
placeholder: 'Pagination token',
mode: 'advanced',
condition: { field: 'operation', value: 'search' },
},
{
id: 'limit',
title: 'Limit',
type: 'short-input',
placeholder: 'Maximum results to return',
mode: 'advanced',
condition: {
field: 'operation',
value: ['list_campaigns', 'list_ad_groups', 'ad_performance'],
},
},
],
tools: {
access: [
'google_ads_list_customers',
'google_ads_search',
'google_ads_list_campaigns',
'google_ads_campaign_performance',
'google_ads_list_ad_groups',
'google_ads_ad_performance',
],
config: {
tool: (params) => `google_ads_${params.operation}`,
params: (params) => {
const { oauthCredential, dateRange, limit, ...rest } = params
const result: Record<string, unknown> = {
...rest,
oauthCredential,
}
if (dateRange && dateRange !== 'CUSTOM') {
result.dateRange = dateRange
}
if (limit !== undefined && limit !== '') {
result.limit = Number(limit)
}
return result
},
},
},
inputs: {
operation: { type: 'string', description: 'Operation to perform' },
oauthCredential: { type: 'string', description: 'Google Ads OAuth credential' },
developerToken: { type: 'string', description: 'Google Ads API developer token' },
customerId: { type: 'string', description: 'Google Ads customer ID (numeric, no dashes)' },
managerCustomerId: { type: 'string', description: 'Manager account customer ID' },
query: { type: 'string', description: 'GAQL query to execute' },
campaignId: { type: 'string', description: 'Campaign ID to filter by' },
adGroupId: { type: 'string', description: 'Ad group ID to filter by' },
status: { type: 'string', description: 'Status filter (ENABLED, PAUSED)' },
dateRange: { type: 'string', description: 'Date range for performance queries' },
startDate: { type: 'string', description: 'Custom start date (YYYY-MM-DD)' },
endDate: { type: 'string', description: 'Custom end date (YYYY-MM-DD)' },
pageToken: { type: 'string', description: 'Pagination token' },
limit: { type: 'number', description: 'Maximum results to return' },
},
outputs: {
customerIds: {
type: 'json',
description: 'List of accessible customer IDs (list_customers)',
},
results: {
type: 'json',
description: 'Query results (search)',
},
campaigns: {
type: 'json',
description: 'Campaign data (list_campaigns, campaign_performance)',
},
adGroups: {
type: 'json',
description: 'Ad group data (list_ad_groups)',
},
ads: {
type: 'json',
description: 'Ad performance data (ad_performance)',
},
totalCount: {
type: 'number',
description: 'Total number of results',
},
totalResultsCount: {
type: 'number',
description: 'Total results count (search)',
},
nextPageToken: {
type: 'string',
description: 'Token for next page of results',
},
},
}

View File

@@ -25,7 +25,6 @@ export const GrainBlock: BlockConfig = {
{ label: 'List Recordings', id: 'grain_list_recordings' },
{ label: 'Get Recording', id: 'grain_get_recording' },
{ label: 'Get Transcript', id: 'grain_get_transcript' },
{ label: 'List Views', id: 'grain_list_views' },
{ label: 'List Teams', id: 'grain_list_teams' },
{ label: 'List Meeting Types', id: 'grain_list_meeting_types' },
{ label: 'Create Webhook', id: 'grain_create_hook' },
@@ -73,7 +72,7 @@ export const GrainBlock: BlockConfig = {
placeholder: 'ISO8601 timestamp (e.g., 2024-01-01T00:00:00Z)',
condition: {
field: 'operation',
value: ['grain_list_recordings'],
value: ['grain_list_recordings', 'grain_create_hook'],
},
wandConfig: {
enabled: true,
@@ -97,7 +96,7 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
placeholder: 'ISO8601 timestamp (e.g., 2024-01-01T00:00:00Z)',
condition: {
field: 'operation',
value: ['grain_list_recordings'],
value: ['grain_list_recordings', 'grain_create_hook'],
},
wandConfig: {
enabled: true,
@@ -126,7 +125,7 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
value: () => '',
condition: {
field: 'operation',
value: ['grain_list_recordings'],
value: ['grain_list_recordings', 'grain_create_hook'],
},
},
// Title search
@@ -163,7 +162,7 @@ Return ONLY the search term - no explanations, no quotes, no extra text.`,
placeholder: 'Filter by team UUID (optional)',
condition: {
field: 'operation',
value: ['grain_list_recordings'],
value: ['grain_list_recordings', 'grain_create_hook'],
},
},
// Meeting type ID filter
@@ -174,7 +173,7 @@ Return ONLY the search term - no explanations, no quotes, no extra text.`,
placeholder: 'Filter by meeting type UUID (optional)',
condition: {
field: 'operation',
value: ['grain_list_recordings'],
value: ['grain_list_recordings', 'grain_create_hook'],
},
},
// Include highlights
@@ -184,7 +183,7 @@ Return ONLY the search term - no explanations, no quotes, no extra text.`,
type: 'switch',
condition: {
field: 'operation',
value: ['grain_list_recordings', 'grain_get_recording'],
value: ['grain_list_recordings', 'grain_get_recording', 'grain_create_hook'],
},
},
// Include participants
@@ -194,7 +193,7 @@ Return ONLY the search term - no explanations, no quotes, no extra text.`,
type: 'switch',
condition: {
field: 'operation',
value: ['grain_list_recordings', 'grain_get_recording'],
value: ['grain_list_recordings', 'grain_get_recording', 'grain_create_hook'],
},
},
// Include AI summary
@@ -204,18 +203,7 @@ Return ONLY the search term - no explanations, no quotes, no extra text.`,
type: 'switch',
condition: {
field: 'operation',
value: ['grain_list_recordings', 'grain_get_recording'],
},
},
{
id: 'viewId',
title: 'View ID',
type: 'short-input',
placeholder: 'Enter Grain view UUID',
required: true,
condition: {
field: 'operation',
value: ['grain_create_hook'],
value: ['grain_list_recordings', 'grain_get_recording', 'grain_create_hook'],
},
},
// Include calendar event (get_recording only)
@@ -283,7 +271,6 @@ Return ONLY the search term - no explanations, no quotes, no extra text.`,
'grain_list_recordings',
'grain_get_recording',
'grain_get_transcript',
'grain_list_views',
'grain_list_teams',
'grain_list_meeting_types',
'grain_create_hook',
@@ -340,7 +327,6 @@ Return ONLY the search term - no explanations, no quotes, no extra text.`,
case 'grain_list_teams':
case 'grain_list_meeting_types':
case 'grain_list_views':
case 'grain_list_hooks':
return baseParams
@@ -348,13 +334,17 @@ Return ONLY the search term - no explanations, no quotes, no extra text.`,
if (!params.hookUrl?.trim()) {
throw new Error('Webhook URL is required.')
}
if (!params.viewId?.trim()) {
throw new Error('View ID is required.')
}
return {
...baseParams,
hookUrl: params.hookUrl.trim(),
viewId: params.viewId.trim(),
filterBeforeDatetime: params.beforeDatetime || undefined,
filterAfterDatetime: params.afterDatetime || undefined,
filterParticipantScope: params.participantScope || undefined,
filterTeamId: params.teamId || undefined,
filterMeetingTypeId: params.meetingTypeId || undefined,
includeHighlights: params.includeHighlights || false,
includeParticipants: params.includeParticipants || false,
includeAiSummary: params.includeAiSummary || false,
}
case 'grain_delete_hook':
@@ -377,7 +367,6 @@ Return ONLY the search term - no explanations, no quotes, no extra text.`,
apiKey: { type: 'string', description: 'Grain API key (Personal Access Token)' },
recordingId: { type: 'string', description: 'Recording UUID' },
cursor: { type: 'string', description: 'Pagination cursor' },
viewId: { type: 'string', description: 'Grain view UUID for webhook subscriptions' },
beforeDatetime: {
type: 'string',
description: 'Filter recordings before this ISO8601 timestamp',
@@ -427,7 +416,6 @@ Return ONLY the search term - no explanations, no quotes, no extra text.`,
teamsList: { type: 'json', description: 'Array of team objects' },
// Meeting type outputs
meetingTypes: { type: 'json', description: 'Array of meeting type objects' },
views: { type: 'json', description: 'Array of Grain views' },
// Hook outputs
hooks: { type: 'json', description: 'Array of webhook objects' },
hook: { type: 'json', description: 'Created webhook data' },

View File

@@ -165,7 +165,7 @@ export const JiraBlock: BlockConfig<JiraResponse> = {
type: 'short-input',
canonicalParamId: 'issueKey',
placeholder: 'Enter Jira issue key',
dependsOn: ['credential', 'domain'],
dependsOn: ['credential', 'domain', 'projectId', 'manualProjectId'],
condition: {
field: 'operation',
value: [

View File

@@ -9,7 +9,7 @@ export const ParallelBlock: BlockConfig<ToolResponse> = {
authMode: AuthMode.ApiKey,
longDescription:
'Integrate Parallel AI into the workflow. Can search the web, extract information from URLs, and conduct deep research.',
docsLink: 'https://docs.sim.ai/tools/parallel-ai',
docsLink: 'https://docs.parallel.ai/',
category: 'tools',
bgColor: '#E0E0E0',
icon: ParallelIcon,
@@ -56,7 +56,7 @@ export const ParallelBlock: BlockConfig<ToolResponse> = {
title: 'Extract Objective',
type: 'long-input',
placeholder: 'What information to extract from the URLs?',
required: false,
required: true,
condition: { field: 'operation', value: 'extract' },
},
{
@@ -89,37 +89,6 @@ export const ParallelBlock: BlockConfig<ToolResponse> = {
required: true,
condition: { field: 'operation', value: 'deep_research' },
},
{
id: 'search_mode',
title: 'Search Mode',
type: 'dropdown',
options: [
{ label: 'One-Shot', id: 'one-shot' },
{ label: 'Agentic', id: 'agentic' },
{ label: 'Fast', id: 'fast' },
],
value: () => 'one-shot',
condition: { field: 'operation', value: 'search' },
mode: 'advanced',
},
{
id: 'search_include_domains',
title: 'Include Domains',
type: 'short-input',
placeholder: 'Comma-separated domains to include (e.g., .edu, example.com)',
required: false,
condition: { field: 'operation', value: 'search' },
mode: 'advanced',
},
{
id: 'search_exclude_domains',
title: 'Exclude Domains',
type: 'short-input',
placeholder: 'Comma-separated domains to exclude',
required: false,
condition: { field: 'operation', value: 'search' },
mode: 'advanced',
},
{
id: 'include_domains',
title: 'Include Domains',
@@ -127,7 +96,6 @@ export const ParallelBlock: BlockConfig<ToolResponse> = {
placeholder: 'Comma-separated domains to include',
required: false,
condition: { field: 'operation', value: 'deep_research' },
mode: 'advanced',
},
{
id: 'exclude_domains',
@@ -136,37 +104,37 @@ export const ParallelBlock: BlockConfig<ToolResponse> = {
placeholder: 'Comma-separated domains to exclude',
required: false,
condition: { field: 'operation', value: 'deep_research' },
mode: 'advanced',
},
{
id: 'processor',
title: 'Research Processor',
title: 'Processor',
type: 'dropdown',
options: [
{ label: 'Lite', id: 'lite' },
{ label: 'Base', id: 'base' },
{ label: 'Core', id: 'core' },
{ label: 'Core 2x', id: 'core2x' },
{ label: 'Pro', id: 'pro' },
{ label: 'Ultra', id: 'ultra' },
{ label: 'Pro Fast', id: 'pro-fast' },
{ label: 'Ultra Fast', id: 'ultra-fast' },
{ label: 'Ultra 2x', id: 'ultra2x' },
{ label: 'Ultra 4x', id: 'ultra4x' },
],
value: () => 'pro',
condition: { field: 'operation', value: 'deep_research' },
mode: 'advanced',
value: () => 'base',
condition: { field: 'operation', value: ['search', 'deep_research'] },
},
{
id: 'max_results',
title: 'Max Results',
type: 'short-input',
placeholder: '10',
placeholder: '5',
condition: { field: 'operation', value: 'search' },
mode: 'advanced',
},
{
id: 'max_chars_per_result',
title: 'Max Chars Per Result',
title: 'Max Chars',
type: 'short-input',
placeholder: '1500',
condition: { field: 'operation', value: 'search' },
mode: 'advanced',
},
{
id: 'apiKey',
@@ -181,6 +149,8 @@ export const ParallelBlock: BlockConfig<ToolResponse> = {
access: ['parallel_search', 'parallel_extract', 'parallel_deep_research'],
config: {
tool: (params) => {
if (params.extract_objective) params.objective = params.extract_objective
if (params.research_input) params.input = params.research_input
switch (params.operation) {
case 'search':
return 'parallel_search'
@@ -204,30 +174,21 @@ export const ParallelBlock: BlockConfig<ToolResponse> = {
.filter((query: string) => query.length > 0)
if (queries.length > 0) {
result.search_queries = queries
} else {
result.search_queries = undefined
}
}
if (params.search_mode && params.search_mode !== 'one-shot') {
result.mode = params.search_mode
}
if (params.max_results) result.max_results = Number(params.max_results)
if (params.max_chars_per_result) {
result.max_chars_per_result = Number(params.max_chars_per_result)
}
result.include_domains = params.search_include_domains || undefined
result.exclude_domains = params.search_exclude_domains || undefined
}
if (operation === 'extract') {
if (params.extract_objective) result.objective = params.extract_objective
result.excerpts = !(params.excerpts === 'false' || params.excerpts === false)
result.full_content = params.full_content === 'true' || params.full_content === true
}
if (operation === 'deep_research') {
if (params.research_input) result.input = params.research_input
if (params.processor) result.processor = params.processor
}
return result
},
},
@@ -241,34 +202,29 @@ export const ParallelBlock: BlockConfig<ToolResponse> = {
excerpts: { type: 'boolean', description: 'Include excerpts' },
full_content: { type: 'boolean', description: 'Include full content' },
research_input: { type: 'string', description: 'Deep research query' },
include_domains: { type: 'string', description: 'Domains to include (deep research)' },
exclude_domains: { type: 'string', description: 'Domains to exclude (deep research)' },
search_include_domains: { type: 'string', description: 'Domains to include (search)' },
search_exclude_domains: { type: 'string', description: 'Domains to exclude (search)' },
search_mode: { type: 'string', description: 'Search mode (one-shot, agentic, fast)' },
processor: { type: 'string', description: 'Research processing tier' },
include_domains: { type: 'string', description: 'Domains to include' },
exclude_domains: { type: 'string', description: 'Domains to exclude' },
processor: { type: 'string', description: 'Processing method' },
max_results: { type: 'number', description: 'Maximum number of results' },
max_chars_per_result: { type: 'number', description: 'Maximum characters per result' },
apiKey: { type: 'string', description: 'Parallel AI API key' },
},
outputs: {
results: {
type: 'json',
description: 'Search or extract results (array of url, title, excerpts)',
},
search_id: { type: 'string', description: 'Search request ID (for search)' },
extract_id: { type: 'string', description: 'Extract request ID (for extract)' },
results: { type: 'string', description: 'Search or extract results (JSON stringified)' },
status: { type: 'string', description: 'Task status (for deep research)' },
run_id: { type: 'string', description: 'Task run ID (for deep research)' },
message: { type: 'string', description: 'Status message (for deep research)' },
content: {
type: 'json',
description: 'Research content (for deep research, structured based on output_schema)',
type: 'string',
description: 'Research content (for deep research, JSON stringified)',
},
basis: {
type: 'json',
description:
'Citations and sources with field, reasoning, citations, confidence (for deep research)',
type: 'string',
description: 'Citations and sources (for deep research, JSON stringified)',
},
metadata: {
type: 'string',
description: 'Task metadata (for deep research, JSON stringified)',
},
},
}

View File

@@ -40,7 +40,6 @@ import { EnrichBlock } from '@/blocks/blocks/enrich'
import { EvaluatorBlock } from '@/blocks/blocks/evaluator'
import { EvernoteBlock } from '@/blocks/blocks/evernote'
import { ExaBlock } from '@/blocks/blocks/exa'
import { FathomBlock } from '@/blocks/blocks/fathom'
import { FileBlock, FileV2Block, FileV3Block } from '@/blocks/blocks/file'
import { FirecrawlBlock } from '@/blocks/blocks/firecrawl'
import { FirefliesBlock, FirefliesV2Block } from '@/blocks/blocks/fireflies'
@@ -52,7 +51,6 @@ import { GitLabBlock } from '@/blocks/blocks/gitlab'
import { GmailBlock, GmailV2Block } from '@/blocks/blocks/gmail'
import { GongBlock } from '@/blocks/blocks/gong'
import { GoogleSearchBlock } from '@/blocks/blocks/google'
import { GoogleAdsBlock } from '@/blocks/blocks/google_ads'
import { GoogleBigQueryBlock } from '@/blocks/blocks/google_bigquery'
import { GoogleBooksBlock } from '@/blocks/blocks/google_books'
import { GoogleCalendarBlock, GoogleCalendarV2Block } from '@/blocks/blocks/google_calendar'
@@ -237,7 +235,6 @@ export const registry: Record<string, BlockConfig> = {
dynamodb: DynamoDBBlock,
elasticsearch: ElasticsearchBlock,
elevenlabs: ElevenLabsBlock,
fathom: FathomBlock,
enrich: EnrichBlock,
evernote: EvernoteBlock,
evaluator: EvaluatorBlock,
@@ -258,7 +255,6 @@ export const registry: Record<string, BlockConfig> = {
gmail_v2: GmailV2Block,
google_calendar: GoogleCalendarBlock,
google_calendar_v2: GoogleCalendarV2Block,
google_ads: GoogleAdsBlock,
google_books: GoogleBooksBlock,
google_contacts: GoogleContactsBlock,
google_docs: GoogleDocsBlock,

View File

@@ -1979,24 +1979,6 @@ export function ElevenLabsIcon(props: SVGProps<SVGSVGElement>) {
)
}
export function FathomIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1000 1000' fill='none'>
<path
d='M0,668.7v205.78c0,53.97,34.24,102.88,85.8,119.08,87.48,27.49,167.88-36.99,167.88-120.22v-77.45L0,668.7Z'
fill='#007299'
/>
<path
d='M873.72,626.07c-19.05,0-38.38-4.3-56.58-13.38L72.78,241.43C11.15,210.69-17.51,136.6,11.18,74.05,41.2,8.59,119.26-18.53,183.23,13.38l744.25,371.21c62.45,31.15,91,109.08,59.79,171.43-22.22,44.38-67.02,70.05-113.55,70.05Z'
fill='#00beff'
/>
<path
d='M500.09,813.66c-19.05,0-38.38-4.3-56.58-13.38l-370.72-184.9c-61.63-30.74-90.29-104.82-61.61-167.37,30.02-65.46,108.08-92.59,172.06-60.68l370.62,184.85c62.45,31.15,91,109.08,59.79,171.43-22.22,44.38-67.02,70.05-113.55,70.05Z'
fill='#00beff'
/>
</svg>
)
}
export function LinkupIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 154 107' fill='none'>
@@ -3572,27 +3554,6 @@ export const ResendIcon = (props: SVGProps<SVGSVGElement>) => (
</svg>
)
export const GoogleAdsIcon = (props: SVGProps<SVGSVGElement>) => (
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'>
<g transform='matrix(.257748 0 0 .257745 -.361416 2.515516)'>
<path
d='M85.9 28.6c2.4-6.3 5.7-12.1 10.6-16.8 19.6-19.1 52-14.3 65.3 9.7 10 18.2 20.6 36 30.9 54l51.6 89.8c14.3 25.1-1.2 56.8-29.6 61.1-17.4 2.6-33.7-5.4-42.7-21l-45.4-78.8c-.3-.6-.7-1.1-1.1-1.6-1.6-1.3-2.3-3.2-3.3-4.9L88.8 62.2c-3.9-6.8-5.7-14.2-5.5-22 .3-4 .8-8 2.6-11.6'
fill='#3c8bd9'
/>
<path
d='M85.9 28.6c-.9 3.6-1.7 7.2-1.9 11-.3 8.4 1.8 16.2 6 23.5l32.9 56.9c1 1.7 1.8 3.4 2.8 5l-18.1 31.1-25.3 43.6c-.4 0-.5-.2-.6-.5-.1-.8.2-1.5.4-2.3 4.1-15 .7-28.3-9.6-39.7-6.3-6.9-14.3-10.8-23.5-12.1-12-1.7-22.6 1.4-32.1 8.9-1.7 1.3-2.8 3.2-4.8 4.2-.4 0-.6-.2-.7-.5l14.3-24.9L85.2 29.7c.2-.4.5-.7.7-1.1'
fill='#fabc04'
/>
<path
d='M11.8 158l5.7-5.1c24.3-19.2 60.8-5.3 66.1 25.1 1.3 7.3.6 14.3-1.6 21.3-.1.6-.2 1.1-.4 1.7-.9 1.6-1.7 3.3-2.7 4.9-8.9 14.7-22 22-39.2 20.9C20 225.4 4.5 210.6 1.8 191c-1.3-9.5.6-18.4 5.5-26.6 1-1.8 2.2-3.4 3.3-5.2.5-.4.3-1.2 1.2-1.2'
fill='#34a852'
/>
<path d='M11.8 158c-.4.4-.4 1.1-1.1 1.2-.1-.7.3-1.1.7-1.6l.4.4' fill='#fabc04' />
<path d='M81.6 201c-.4-.7 0-1.2.4-1.7l.4.4-.8 1.3' fill='#e1c025' />
</g>
</svg>
)
export const GoogleBigQueryIcon = (props: SVGProps<SVGSVGElement>) => (
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'>
<path

View File

@@ -166,8 +166,7 @@ export class ConditionBlockHandler implements BlockHandler {
if (!output || typeof output !== 'object') {
return output
}
const { _pauseMetadata, error, providerTiming, tokens, toolCalls, model, cost, ...rest } =
output
const { _pauseMetadata, error, ...rest } = output
return rest
}

View File

@@ -22,7 +22,7 @@ export class TriggerBlockHandler implements BlockHandler {
}
const existingState = ctx.blockStates.get(block.id)
if (existingState?.output) {
if (existingState?.output && Object.keys(existingState.output).length > 0) {
return existingState.output
}

View File

@@ -7,7 +7,6 @@ import {
ClientFactoryOptions,
} from '@a2a-js/sdk/client'
import { createLogger } from '@sim/logger'
import { validateUrlWithDNS } from '@/lib/core/security/input-validation.server'
import { isInternalFileUrl } from '@/lib/uploads/utils/file-utils'
import { A2A_TERMINAL_STATES } from './constants'
@@ -44,11 +43,6 @@ class ApiKeyInterceptor implements CallInterceptor {
* Tries standard path first, falls back to root URL for compatibility.
*/
export async function createA2AClient(agentUrl: string, apiKey?: string): Promise<Client> {
const validation = await validateUrlWithDNS(agentUrl, 'agentUrl')
if (!validation.isValid) {
throw new Error(validation.error || 'Agent URL validation failed')
}
const factoryOptions = apiKey
? ClientFactoryOptions.createFrom(ClientFactoryOptions.default, {
clientConfig: {

View File

@@ -8,7 +8,6 @@ import {
isLegacyApiKeyFormat,
} from '@/lib/api-key/crypto'
import { env } from '@/lib/core/config/env'
import { safeCompare } from '@/lib/core/security/encryption'
const logger = createLogger('ApiKeyAuth')
@@ -40,7 +39,7 @@ export async function authenticateApiKey(inputKey: string, storedKey: string): P
if (isEncryptedKey(storedKey)) {
try {
const { decrypted } = await decryptApiKey(storedKey)
return safeCompare(inputKey, decrypted)
return inputKey === decrypted
} catch (decryptError) {
logger.error('Failed to decrypt stored API key:', { error: decryptError })
return false
@@ -55,27 +54,27 @@ export async function authenticateApiKey(inputKey: string, storedKey: string): P
if (isEncryptedKey(storedKey)) {
try {
const { decrypted } = await decryptApiKey(storedKey)
return safeCompare(inputKey, decrypted)
return inputKey === decrypted
} catch (decryptError) {
logger.error('Failed to decrypt stored API key:', { error: decryptError })
// Fall through to plain text comparison if decryption fails
}
}
// Legacy format can match against plain text storage
return safeCompare(inputKey, storedKey)
return inputKey === storedKey
}
// If no recognized prefix, fall back to original behavior
if (isEncryptedKey(storedKey)) {
try {
const { decrypted } = await decryptApiKey(storedKey)
return safeCompare(inputKey, decrypted)
return inputKey === decrypted
} catch (decryptError) {
logger.error('Failed to decrypt stored API key:', { error: decryptError })
}
}
return safeCompare(inputKey, storedKey)
return inputKey === storedKey
} catch (error) {
logger.error('API key authentication error:', { error })
return false

View File

@@ -486,14 +486,13 @@ export const auth = betterAuth({
'google-docs',
'google-sheets',
'google-forms',
'google-ads',
'google-bigquery',
'google-vault',
'google-groups',
'google-meet',
'google-tasks',
'vertex-ai',
'github-repo',
'microsoft-dataverse',
'microsoft-teams',
'microsoft-excel',
@@ -755,6 +754,83 @@ export const auth = betterAuth({
}),
genericOAuth({
config: [
{
providerId: 'github-repo',
clientId: env.GITHUB_REPO_CLIENT_ID as string,
clientSecret: env.GITHUB_REPO_CLIENT_SECRET as string,
authorizationUrl: 'https://github.com/login/oauth/authorize',
accessType: 'offline',
prompt: 'consent',
tokenUrl: 'https://github.com/login/oauth/access_token',
userInfoUrl: 'https://api.github.com/user',
scopes: getCanonicalScopesForProvider('github-repo'),
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/github-repo`,
getUserInfo: async (tokens) => {
try {
const profileResponse = await fetch('https://api.github.com/user', {
headers: {
Authorization: `Bearer ${tokens.accessToken}`,
'User-Agent': 'sim-studio',
},
})
if (!profileResponse.ok) {
await profileResponse.text().catch(() => {})
logger.error('Failed to fetch GitHub profile', {
status: profileResponse.status,
statusText: profileResponse.statusText,
})
throw new Error(`Failed to fetch GitHub profile: ${profileResponse.statusText}`)
}
const profile = await profileResponse.json()
if (!profile.email) {
const emailsResponse = await fetch('https://api.github.com/user/emails', {
headers: {
Authorization: `Bearer ${tokens.accessToken}`,
'User-Agent': 'sim-studio',
},
})
if (emailsResponse.ok) {
const emails = await emailsResponse.json()
const primaryEmail =
emails.find(
(email: { primary: boolean; email: string; verified: boolean }) =>
email.primary
) || emails[0]
if (primaryEmail) {
profile.email = primaryEmail.email
profile.emailVerified = primaryEmail.verified || false
}
} else {
logger.warn('Failed to fetch GitHub emails', {
status: emailsResponse.status,
statusText: emailsResponse.statusText,
})
}
}
const now = new Date()
return {
id: `${profile.id.toString()}-${crypto.randomUUID()}`,
name: profile.name || profile.login,
email: profile.email,
image: profile.avatar_url,
emailVerified: profile.emailVerified || false,
createdAt: now,
updatedAt: now,
}
} catch (error) {
logger.error('Error in GitHub getUserInfo', { error })
throw error
}
},
},
// Google providers
{
providerId: 'google-email',
@@ -1009,41 +1085,6 @@ export const auth = betterAuth({
}
},
},
{
providerId: 'google-ads',
clientId: env.GOOGLE_CLIENT_ID as string,
clientSecret: env.GOOGLE_CLIENT_SECRET as string,
discoveryUrl: 'https://accounts.google.com/.well-known/openid-configuration',
accessType: 'offline',
scopes: getCanonicalScopesForProvider('google-ads'),
prompt: 'consent',
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/google-ads`,
getUserInfo: async (tokens) => {
try {
const response = await fetch('https://openidconnect.googleapis.com/v1/userinfo', {
headers: { Authorization: `Bearer ${tokens.accessToken}` },
})
if (!response.ok) {
logger.error('Failed to fetch Google user info', { status: response.status })
throw new Error(`Failed to fetch Google user info: ${response.statusText}`)
}
const profile = await response.json()
const now = new Date()
return {
id: `${profile.sub}-${crypto.randomUUID()}`,
name: profile.name || 'Google User',
email: profile.email,
image: profile.picture || undefined,
emailVerified: profile.email_verified || false,
createdAt: now,
updatedAt: now,
}
} catch (error) {
logger.error('Error in Google getUserInfo', { error })
throw error
}
},
},
{
providerId: 'google-bigquery',
clientId: env.GOOGLE_CLIENT_ID as string,

View File

@@ -2,13 +2,13 @@ import { db } from '@sim/db'
import { account, credential, credentialMember, workflow as workflowTable } from '@sim/db/schema'
import { and, eq } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { AuthType, checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
export interface CredentialAccessResult {
ok: boolean
error?: string
authType?: typeof AuthType.SESSION | typeof AuthType.INTERNAL_JWT
authType?: 'session' | 'internal_jwt'
requesterUserId?: string
credentialOwnerUserId?: string
workspaceId?: string
@@ -39,7 +39,7 @@ export async function authorizeCredentialUse(
return { ok: false, error: auth.error || 'Authentication required' }
}
const actingUserId = auth.authType === AuthType.INTERNAL_JWT ? callerUserId : auth.userId
const actingUserId = auth.authType === 'internal_jwt' ? callerUserId : auth.userId
const [workflowContext] = workflowId
? await db
@@ -217,7 +217,7 @@ export async function authorizeCredentialUse(
return { ok: false, error: 'Credential not found' }
}
if (auth.authType === AuthType.INTERNAL_JWT) {
if (auth.authType === 'internal_jwt') {
return { ok: false, error: 'workflowId is required' }
}

View File

@@ -6,20 +6,12 @@ import { verifyInternalToken } from '@/lib/auth/internal'
const logger = createLogger('HybridAuth')
export const AuthType = {
SESSION: 'session',
API_KEY: 'api_key',
INTERNAL_JWT: 'internal_jwt',
} as const
export type AuthTypeValue = (typeof AuthType)[keyof typeof AuthType]
export interface AuthResult {
success: boolean
userId?: string
userName?: string | null
userEmail?: string | null
authType?: AuthTypeValue
authType?: 'session' | 'api_key' | 'internal_jwt'
apiKeyType?: 'personal' | 'workspace'
error?: string
}
@@ -54,14 +46,14 @@ async function resolveUserFromJwt(
}
if (userId) {
return { success: true, userId, authType: AuthType.INTERNAL_JWT }
return { success: true, userId, authType: 'internal_jwt' }
}
if (options.requireWorkflowId !== false) {
return { success: false, error: 'userId required for internal JWT calls' }
}
return { success: true, authType: AuthType.INTERNAL_JWT }
return { success: true, authType: 'internal_jwt' }
}
/**
@@ -154,7 +146,7 @@ export async function checkSessionOrInternalAuth(
userId: session.user.id,
userName: session.user.name,
userEmail: session.user.email,
authType: AuthType.SESSION,
authType: 'session',
}
}
@@ -203,7 +195,7 @@ export async function checkHybridAuth(
userId: session.user.id,
userName: session.user.name,
userEmail: session.user.email,
authType: AuthType.SESSION,
authType: 'session',
}
}
@@ -216,7 +208,7 @@ export async function checkHybridAuth(
return {
success: true,
userId: result.userId!,
authType: AuthType.API_KEY,
authType: 'api_key',
apiKeyType: result.keyType,
}
}

View File

@@ -2,7 +2,6 @@ import { createLogger } from '@sim/logger'
import { jwtVerify, SignJWT } from 'jose'
import { type NextRequest, NextResponse } from 'next/server'
import { env } from '@/lib/core/config/env'
import { safeCompare } from '@/lib/core/security/encryption'
const logger = createLogger('CronAuth')
@@ -82,8 +81,7 @@ export function verifyCronAuth(request: NextRequest, context?: string): NextResp
const authHeader = request.headers.get('authorization')
const expectedAuth = `Bearer ${env.CRON_SECRET}`
const isValid = authHeader !== null && safeCompare(authHeader, expectedAuth)
if (!isValid) {
if (authHeader !== expectedAuth) {
const contextInfo = context ? ` for ${context}` : ''
logger.warn(`Unauthorized CRON access attempt${contextInfo}`, {
providedAuth: authHeader,

View File

@@ -1,6 +1,5 @@
import type { NextRequest } from 'next/server'
import { env } from '@/lib/core/config/env'
import { safeCompare } from '@/lib/core/security/encryption'
export function checkInternalApiKey(req: NextRequest) {
const apiKey = req.headers.get('x-api-key')
@@ -14,7 +13,7 @@ export function checkInternalApiKey(req: NextRequest) {
return { success: false, error: 'API key required' }
}
if (!safeCompare(apiKey, expectedApiKey)) {
if (apiKey !== expectedApiKey) {
return { success: false, error: 'Invalid API key' }
}

View File

@@ -7,7 +7,6 @@ const logger = createLogger('AsyncJobsConfig')
let cachedBackend: JobQueueBackend | null = null
let cachedBackendType: AsyncBackendType | null = null
let cachedInlineBackend: JobQueueBackend | null = null
/**
* Determines which async backend to use based on environment configuration.
@@ -72,31 +71,6 @@ export function getCurrentBackendType(): AsyncBackendType | null {
return cachedBackendType
}
/**
* Gets a job queue backend that bypasses Trigger.dev (Redis -> Database).
* Used for non-polling webhooks that should always execute inline.
*/
export async function getInlineJobQueue(): Promise<JobQueueBackend> {
if (cachedInlineBackend) {
return cachedInlineBackend
}
const redis = getRedisClient()
let type: string
if (redis) {
const { RedisJobQueue } = await import('@/lib/core/async-jobs/backends/redis')
cachedInlineBackend = new RedisJobQueue(redis)
type = 'redis'
} else {
const { DatabaseJobQueue } = await import('@/lib/core/async-jobs/backends/database')
cachedInlineBackend = new DatabaseJobQueue()
type = 'database'
}
logger.info(`Inline job backend initialized: ${type}`)
return cachedInlineBackend
}
/**
* Checks if jobs should be executed inline (fire-and-forget).
* For Redis/DB backends, we execute inline. Trigger.dev handles execution itself.
@@ -111,5 +85,4 @@ export function shouldExecuteInline(): boolean {
export function resetJobQueueCache(): void {
cachedBackend = null
cachedBackendType = null
cachedInlineBackend = null
}

View File

@@ -1,7 +1,6 @@
export {
getAsyncBackendType,
getCurrentBackendType,
getInlineJobQueue,
getJobQueue,
resetJobQueueCache,
shouldExecuteInline,

View File

@@ -230,7 +230,8 @@ export const env = createEnv({
GOOGLE_CLIENT_SECRET: z.string().optional(), // Google OAuth client secret
GITHUB_CLIENT_ID: z.string().optional(), // GitHub OAuth client ID for GitHub integration
GITHUB_CLIENT_SECRET: z.string().optional(), // GitHub OAuth client secret
GITHUB_REPO_CLIENT_ID: z.string().optional(), // GitHub OAuth client ID for repo access
GITHUB_REPO_CLIENT_SECRET: z.string().optional(), // GitHub OAuth client secret for repo access
X_CLIENT_ID: z.string().optional(), // X (Twitter) OAuth client ID
X_CLIENT_SECRET: z.string().optional(), // X (Twitter) OAuth client secret
CONFLUENCE_CLIENT_ID: z.string().optional(), // Atlassian Confluence OAuth client ID

View File

@@ -413,7 +413,6 @@ export class IdempotencyService {
: undefined
const webhookIdHeader =
normalizedHeaders?.['x-sim-idempotency-key'] ||
normalizedHeaders?.['webhook-id'] ||
normalizedHeaders?.['x-webhook-id'] ||
normalizedHeaders?.['x-shopify-webhook-id'] ||

View File

@@ -81,9 +81,7 @@ export function setDeploymentAuthCookie(
}
/**
* Adds CORS headers to allow cross-origin requests for embedded deployments.
* Embedded chat widgets and forms are designed to run on any customer domain,
* so we reflect the requesting origin rather than restricting to an allowlist.
* Adds CORS headers to allow cross-origin requests for embedded deployments
*/
export function addCorsHeaders(response: NextResponse, request: NextRequest): NextResponse {
const origin = request.headers.get('origin') || ''

View File

@@ -1,4 +1,4 @@
import { createCipheriv, createDecipheriv, createHmac, randomBytes, timingSafeEqual } from 'crypto'
import { createCipheriv, createDecipheriv, randomBytes, timingSafeEqual } from 'crypto'
import { createLogger } from '@sim/logger'
import { env } from '@/lib/core/config/env'
@@ -91,8 +91,8 @@ export function generatePassword(length = 24): string {
* @returns True if strings are equal, false otherwise
*/
export function safeCompare(a: string, b: string): boolean {
const key = 'safeCompare'
const ha = createHmac('sha256', key).update(a).digest()
const hb = createHmac('sha256', key).update(b).digest()
return timingSafeEqual(ha, hb)
if (a.length !== b.length) {
return false
}
return timingSafeEqual(Buffer.from(a), Buffer.from(b))
}

View File

@@ -54,10 +54,9 @@ function isPrivateOrReservedIP(ip: string): boolean {
*/
export async function validateUrlWithDNS(
url: string | null | undefined,
paramName = 'url',
options: { allowHttp?: boolean } = {}
paramName = 'url'
): Promise<AsyncValidationResult> {
const basicValidation = validateExternalUrl(url, paramName, options)
const basicValidation = validateExternalUrl(url, paramName)
if (!basicValidation.isValid) {
return basicValidation
}
@@ -89,10 +88,7 @@ export async function validateUrlWithDNS(
return ip === '127.0.0.1' || ip === '::1'
})()
if (
isPrivateOrReservedIP(address) &&
!(isLocalhost && resolvedIsLoopback && !options.allowHttp)
) {
if (isPrivateOrReservedIP(address) && !(isLocalhost && resolvedIsLoopback)) {
logger.warn('URL resolves to blocked IP address', {
paramName,
hostname,
@@ -122,70 +118,6 @@ export async function validateUrlWithDNS(
}
}
/**
* Validates a database hostname by resolving DNS and checking the resolved IP
* against private/reserved ranges to prevent SSRF via database connections.
*
* Unlike validateHostname (which enforces strict RFC hostname format), this
* function is permissive about hostname format to avoid breaking legitimate
* database hostnames (e.g. underscores in Docker/K8s service names). It only
* blocks localhost and private/reserved IPs.
*
* @param host - The database hostname to validate
* @param paramName - Name of the parameter for error messages
* @returns AsyncValidationResult with resolved IP
*/
export async function validateDatabaseHost(
host: string | null | undefined,
paramName = 'host'
): Promise<AsyncValidationResult> {
if (!host) {
return { isValid: false, error: `${paramName} is required` }
}
const lowerHost = host.toLowerCase()
if (lowerHost === 'localhost') {
return { isValid: false, error: `${paramName} cannot be localhost` }
}
if (ipaddr.isValid(lowerHost) && isPrivateOrReservedIP(lowerHost)) {
return { isValid: false, error: `${paramName} cannot be a private IP address` }
}
try {
const { address } = await dns.lookup(host, { verbatim: true })
if (isPrivateOrReservedIP(address)) {
logger.warn('Database host resolves to blocked IP address', {
paramName,
hostname: host,
resolvedIP: address,
})
return {
isValid: false,
error: `${paramName} resolves to a blocked IP address`,
}
}
return {
isValid: true,
resolvedIP: address,
originalHostname: host,
}
} catch (error) {
logger.warn('DNS lookup failed for database host', {
paramName,
hostname: host,
error: error instanceof Error ? error.message : String(error),
})
return {
isValid: false,
error: `${paramName} hostname could not be resolved`,
}
}
}
export interface SecureFetchOptions {
method?: string
headers?: Record<string, string>
@@ -251,7 +183,7 @@ function resolveRedirectUrl(baseUrl: string, location: string): string {
export async function secureFetchWithPinnedIP(
url: string,
resolvedIP: string,
options: SecureFetchOptions & { allowHttp?: boolean } = {},
options: SecureFetchOptions = {},
redirectCount = 0
): Promise<SecureFetchResponse> {
const maxRedirects = options.maxRedirects ?? DEFAULT_MAX_REDIRECTS
@@ -299,7 +231,7 @@ export async function secureFetchWithPinnedIP(
res.resume()
const redirectUrl = resolveRedirectUrl(url, location)
validateUrlWithDNS(redirectUrl, 'redirectUrl', { allowHttp: options.allowHttp })
validateUrlWithDNS(redirectUrl, 'redirectUrl')
.then((validation) => {
if (!validation.isValid) {
reject(new Error(`Redirect blocked: ${validation.error}`))
@@ -408,12 +340,10 @@ export async function secureFetchWithPinnedIP(
*/
export async function secureFetchWithValidation(
url: string,
options: SecureFetchOptions & { allowHttp?: boolean } = {},
options: SecureFetchOptions = {},
paramName = 'url'
): Promise<SecureFetchResponse> {
const validation = await validateUrlWithDNS(url, paramName, {
allowHttp: options.allowHttp,
})
const validation = await validateUrlWithDNS(url, paramName)
if (!validation.isValid) {
throw new Error(validation.error)
}

View File

@@ -676,8 +676,7 @@ export function validateJiraIssueKey(
*/
export function validateExternalUrl(
url: string | null | undefined,
paramName = 'url',
options: { allowHttp?: boolean } = {}
paramName = 'url'
): ValidationResult {
if (!url || typeof url !== 'string') {
return {
@@ -710,20 +709,7 @@ export function validateExternalUrl(
}
}
if (options.allowHttp) {
if (protocol !== 'https:' && protocol !== 'http:') {
return {
isValid: false,
error: `${paramName} must use http:// or https:// protocol`,
}
}
if (isLocalhost) {
return {
isValid: false,
error: `${paramName} cannot point to localhost`,
}
}
} else if (protocol !== 'https:' && !(protocol === 'http:' && isLocalhost)) {
if (protocol !== 'https:' && !(protocol === 'http:' && isLocalhost)) {
return {
isValid: false,
error: `${paramName} must use https:// protocol`,

View File

@@ -1,10 +1,6 @@
import { createLogger } from '@sim/logger'
import type { ToolCall, TraceSpan } from '@/lib/logs/types'
import {
isConditionBlockType,
isWorkflowBlockType,
stripCustomToolPrefix,
} from '@/executor/constants'
import { isWorkflowBlockType, stripCustomToolPrefix } from '@/executor/constants'
import type { ExecutionResult } from '@/executor/types'
import { stripCloneSuffixes } from '@/executor/utils/subflow-utils'
@@ -113,7 +109,6 @@ export function buildTraceSpans(result: ExecutionResult): {
if (!log.blockId || !log.blockType) return
const spanId = `${log.blockId}-${new Date(log.startedAt).getTime()}`
const isCondition = isConditionBlockType(log.blockType)
const duration = log.durationMs || 0
@@ -169,7 +164,7 @@ export function buildTraceSpans(result: ExecutionResult): {
...(log.parentIterations?.length && { parentIterations: log.parentIterations }),
}
if (!isCondition && log.output?.providerTiming) {
if (log.output?.providerTiming) {
const providerTiming = log.output.providerTiming as {
duration: number
startTime: string
@@ -191,7 +186,7 @@ export function buildTraceSpans(result: ExecutionResult): {
}
}
if (!isCondition && log.output?.cost) {
if (log.output?.cost) {
span.cost = log.output.cost as {
input?: number
output?: number
@@ -199,7 +194,7 @@ export function buildTraceSpans(result: ExecutionResult): {
}
}
if (!isCondition && log.output?.tokens) {
if (log.output?.tokens) {
const t = log.output.tokens as
| number
| {
@@ -229,13 +224,12 @@ export function buildTraceSpans(result: ExecutionResult): {
}
}
if (!isCondition && log.output?.model) {
if (log.output?.model) {
span.model = log.output.model as string
}
if (
!isWorkflowBlockType(log.blockType) &&
!isCondition &&
log.output?.providerTiming?.timeSegments &&
Array.isArray(log.output.providerTiming.timeSegments)
) {
@@ -323,7 +317,7 @@ export function buildTraceSpans(result: ExecutionResult): {
}
}
)
} else if (!isCondition) {
} else {
let toolCallsList = null
try {

View File

@@ -0,0 +1,143 @@
import { createLogger } from '@sim/logger'
import type { McpToolResult } from '@/lib/mcp/types'
import type { McpExecutionContext, McpMiddleware, McpMiddlewareNext } from './types'
// Configure standard cache size limit
const MAX_SERVER_STATES = 1000
export type CircuitState = 'CLOSED' | 'OPEN' | 'HALF-OPEN'
export interface CircuitBreakerConfig {
/** Number of failures before tripping to OPEN */
failureThreshold: number
/** How long to wait in OPEN before transitioning to HALF-OPEN (ms) */
resetTimeoutMs: number
}
interface ServerState {
state: CircuitState
failures: number
nextAttemptMs: number
isHalfOpenProbing: boolean
}
const logger = createLogger('mcp:resilience:circuit-breaker')
export class CircuitBreakerMiddleware implements McpMiddleware {
// Use a Map to maintain insertion order for standard LRU-like eviction if necessary.
// We constrain it to prevent memory leaks if thousands of ephemeral servers connect.
private registry = new Map<string, ServerState>()
private config: CircuitBreakerConfig
constructor(config: Partial<CircuitBreakerConfig> = {}) {
this.config = {
failureThreshold: config.failureThreshold ?? 5,
resetTimeoutMs: config.resetTimeoutMs ?? 30000,
}
}
private getState(serverId: string): ServerState {
let state = this.registry.get(serverId)
if (!state) {
state = {
state: 'CLOSED',
failures: 0,
nextAttemptMs: 0,
isHalfOpenProbing: false,
}
this.registry.set(serverId, state)
this.evictIfNecessary()
}
return state
}
private evictIfNecessary() {
if (this.registry.size > MAX_SERVER_STATES) {
// Evict the oldest entry (first inserted)
const firstKey = this.registry.keys().next().value
if (firstKey) {
this.registry.delete(firstKey)
}
}
}
async execute(context: McpExecutionContext, next: McpMiddlewareNext): Promise<McpToolResult> {
const { serverId, toolCall } = context
const serverState = this.getState(serverId)
// 1. Check current state and evaluate timeouts
if (serverState.state === 'OPEN') {
if (Date.now() > serverState.nextAttemptMs) {
// Time to try again, enter HALF-OPEN
logger.info(`Circuit breaker entering HALF-OPEN for server ${serverId}`)
serverState.state = 'HALF-OPEN'
serverState.isHalfOpenProbing = false
} else {
// Fast-fail
throw new Error(
`Circuit breaker is OPEN for server ${serverId}. Fast-failing request to ${toolCall.name}.`
)
}
}
if (serverState.state === 'HALF-OPEN') {
if (serverState.isHalfOpenProbing) {
// Another request is already probing. Fast-fail concurrent requests.
throw new Error(
`Circuit breaker is HALF-OPEN for server ${serverId}. A probe request is currently executing. Fast-failing concurrent request to ${toolCall.name}.`
)
}
// We are the chosen ones. Lock it down.
serverState.isHalfOpenProbing = true
}
try {
// 2. Invoke the next layer
const result = await next(context)
// 3. Handle result parsing (isError = true counts as failure for us)
if (result.isError) {
this.recordFailure(serverId, serverState)
} else {
this.recordSuccess(serverId, serverState)
}
return result
} catch (error) {
// Note: we record failure on ANY exception
this.recordFailure(serverId, serverState)
throw error // Re-throw to caller
}
}
private recordSuccess(serverId: string, state: ServerState) {
if (state.state !== 'CLOSED') {
logger.info(`Circuit breaker reset to CLOSED for server ${serverId}`)
}
state.state = 'CLOSED'
state.failures = 0
state.isHalfOpenProbing = false
}
private recordFailure(serverId: string, state: ServerState) {
if (state.state === 'HALF-OPEN') {
// The probe failed! Trip immediately back to OPEN.
logger.warn(`Circuit breaker probe failed. Tripping back to OPEN for server ${serverId}`)
this.tripToOpen(state)
} else if (state.state === 'CLOSED') {
state.failures++
if (state.failures >= this.config.failureThreshold) {
logger.error(
`Circuit breaker failure threshold reached (${state.failures}/${this.config.failureThreshold}). Tripping to OPEN for server ${serverId}`
)
this.tripToOpen(state)
}
}
}
private tripToOpen(state: ServerState) {
state.state = 'OPEN'
state.isHalfOpenProbing = false
state.nextAttemptMs = Date.now() + this.config.resetTimeoutMs
}
}

View File

@@ -0,0 +1,42 @@
import type { McpToolResult } from '@/lib/mcp/types'
import type { McpExecutionContext, McpMiddleware, McpMiddlewareNext } from './types'
export class ResiliencePipeline {
private middlewares: McpMiddleware[] = []
/**
* Add a middleware to the pipeline chain.
*/
use(middleware: McpMiddleware): this {
this.middlewares.push(middleware)
return this
}
/**
* Execute the pipeline, processing the context through all middlewares,
* and finally invoking the terminal handler.
*/
async execute(
context: McpExecutionContext,
finalHandler: McpMiddlewareNext
): Promise<McpToolResult> {
let index = -1
const dispatch = async (i: number, currentContext: McpExecutionContext): Promise<McpToolResult> => {
if (i <= index) {
throw new Error('next() called multiple times')
}
index = i
// If we reached the end of the middlewares, call the final handler
if (i === this.middlewares.length) {
return finalHandler(currentContext)
}
const middleware = this.middlewares[i]
return middleware.execute(currentContext, (nextContext) => dispatch(i + 1, nextContext))
}
return dispatch(0, context)
}
}

View File

@@ -0,0 +1,155 @@
import { createLogger } from '@sim/logger'
import { z } from 'zod'
import { createMcpToolId } from '@/lib/mcp/shared'
import type { McpTool, McpToolResult, McpToolSchema, McpToolSchemaProperty } from '@/lib/mcp/types'
import type { McpExecutionContext, McpMiddleware, McpMiddlewareNext } from './types'
const logger = createLogger('mcp:schema-validator')
export type ToolProvider = (
serverId: string,
toolName: string
) => McpTool | undefined | Promise<McpTool | undefined>
export class SchemaValidatorMiddleware implements McpMiddleware {
private schemaCache = new Map<string, z.ZodTypeAny>()
private toolProvider?: ToolProvider
constructor(options?: { toolProvider?: ToolProvider }) {
this.toolProvider = options?.toolProvider
}
/**
* Cache a tool's schema explicitly (e.g. during server discovery)
*/
cacheTool(tool: McpTool) {
const toolId = createMcpToolId(tool.serverId, tool.name)
const zodSchema = this.compileSchema(tool.inputSchema)
this.schemaCache.set(toolId, zodSchema)
}
/**
* Clear caches, either for a specific tool or globally.
*/
clearCache(toolId?: string) {
if (toolId) {
this.schemaCache.delete(toolId)
} else {
this.schemaCache.clear()
}
}
async execute(context: McpExecutionContext, next: McpMiddlewareNext): Promise<McpToolResult> {
const { toolCall } = context
const toolName = toolCall.name
const toolId = createMcpToolId(context.serverId, toolName)
let zodSchema = this.schemaCache.get(toolId)
if (!zodSchema && this.toolProvider) {
const tool = await this.toolProvider(context.serverId, toolName)
if (tool) {
zodSchema = this.compileSchema(tool.inputSchema)
this.schemaCache.set(toolId, zodSchema)
}
}
if (zodSchema) {
const parseResult = await zodSchema.safeParseAsync(toolCall.arguments)
if (!parseResult.success) {
// Return natively formatted error payload
const errorDetails = parseResult.error.errors
.map((e) => `${e.path.join('.') || 'root'}: ${e.message}`)
.join(', ')
logger.warn('Schema validation failed', { toolName, error: errorDetails })
return {
isError: true,
content: [
{
type: 'text',
text: `Schema validation failed: [${errorDetails}]`,
},
],
}
}
// Sync successfully parsed / defaulted arguments back to context
context.toolCall.arguments = parseResult.data
}
return next(context)
}
private compileSchema(schema: McpToolSchema): z.ZodObject<any> {
return this.compileObject(schema.properties || {}, schema.required || []) as z.ZodObject<any>
}
private compileObject(
properties: Record<string, McpToolSchemaProperty>,
required: string[]
): z.ZodTypeAny {
const shape: Record<string, z.ZodTypeAny> = {}
for (const [key, prop] of Object.entries(properties)) {
let zodType = this.compileProperty(prop)
if (!required.includes(key)) {
zodType = zodType.optional()
}
shape[key] = zodType
}
return z.object(shape)
}
private compileProperty(prop: McpToolSchemaProperty): z.ZodTypeAny {
let baseType: z.ZodTypeAny = z.any()
switch (prop.type) {
case 'string':
baseType = z.string()
break
case 'number':
case 'integer':
baseType = z.number()
break
case 'boolean':
baseType = z.boolean()
break
case 'array':
if (prop.items) {
baseType = z.array(this.compileProperty(prop.items))
} else {
baseType = z.array(z.any())
}
break
case 'object':
baseType = this.compileObject(prop.properties || {}, prop.required || [])
break
}
// Apply Enum mappings
if (prop.enum && prop.enum.length > 0) {
if (prop.enum.length === 1) {
baseType = z.literal(prop.enum[0])
} else {
// We use mapped literals injected into an array
const literals = prop.enum.map((e) => z.literal(e))
baseType = z.union(literals as any)
}
}
if (prop.description) {
baseType = baseType.describe(prop.description)
}
if (prop.default !== undefined) {
baseType = baseType.default(prop.default)
}
return baseType
}
}

View File

@@ -0,0 +1,53 @@
import { createLogger } from '@sim/logger'
import type { McpToolResult } from '@/lib/mcp/types'
import type { McpExecutionContext, McpMiddleware, McpMiddlewareNext } from './types'
const logger = createLogger('mcp:telemetry')
export class TelemetryMiddleware implements McpMiddleware {
async execute(context: McpExecutionContext, next: McpMiddlewareNext): Promise<McpToolResult> {
const startTime = performance.now()
try {
const result = await next(context)
const latency_ms = Math.round(performance.now() - startTime)
const isError = result.isError === true
logger.info('MCP Tool Execution Completed', {
toolName: context.toolCall.name,
serverId: context.serverId,
workspaceId: context.workspaceId,
latency_ms,
success: !isError,
...(isError && { failure_reason: 'TOOL_ERROR' }),
})
return result
} catch (error) {
const latency_ms = Math.round(performance.now() - startTime)
// Attempt to determine failure reason based on error
let failure_reason = 'API_500' // General failure fallback
if (error instanceof Error) {
const lowerMsg = error.message.toLowerCase()
if (error.name === 'TimeoutError' || lowerMsg.includes('timeout')) {
failure_reason = 'TIMEOUT'
} else if (lowerMsg.includes('validation') || error.name === 'ZodError') {
failure_reason = 'VALIDATION_ERROR'
}
}
logger.error('MCP Tool Execution Failed', {
toolName: context.toolCall.name,
serverId: context.serverId,
workspaceId: context.workspaceId,
latency_ms,
failure_reason,
err: error instanceof Error ? error.message : String(error),
})
throw error // Re-throw to allow upstream handling (e.g. circuit breaker)
}
}
}

View File

@@ -0,0 +1,32 @@
import type { McpToolCall, McpToolResult } from '@/lib/mcp/types'
/**
* Context passed through the Resilience Pipeline
*/
export interface McpExecutionContext {
toolCall: McpToolCall
serverId: string
userId: string
workspaceId: string
/**
* Additional parameters passed directly by the executeTool caller
*/
extraHeaders?: Record<string, string>
}
/**
* Standardized function signature for invoking the NEXT component in the pipeline
*/
export type McpMiddlewareNext = (context: McpExecutionContext) => Promise<McpToolResult>
/**
* Interface that all Resilience Middlewares must implement
*/
export interface McpMiddleware {
/**
* Execute the middleware logic
* @param context The current execution context
* @param next The next middleware/tool in the chain
*/
execute(context: McpExecutionContext, next: McpMiddlewareNext): Promise<McpToolResult>
}

View File

@@ -11,6 +11,10 @@ import { generateRequestId } from '@/lib/core/utils/request'
import { McpClient } from '@/lib/mcp/client'
import { mcpConnectionManager } from '@/lib/mcp/connection-manager'
import { isMcpDomainAllowed, validateMcpDomain } from '@/lib/mcp/domain-check'
import { CircuitBreakerMiddleware } from '@/lib/mcp/resilience/circuit-breaker'
import { ResiliencePipeline } from '@/lib/mcp/resilience/pipeline'
import { SchemaValidatorMiddleware } from '@/lib/mcp/resilience/schema-validator'
import { TelemetryMiddleware } from '@/lib/mcp/resilience/telemetry'
import { resolveMcpConfigEnvVars } from '@/lib/mcp/resolve-config'
import {
createMcpCacheAdapter,
@@ -35,10 +39,23 @@ class McpService {
private readonly cacheTimeout = MCP_CONSTANTS.CACHE_TIMEOUT
private unsubscribeConnectionManager?: () => void
private pipeline: ResiliencePipeline
private schemaValidator: SchemaValidatorMiddleware
private circuitBreaker: CircuitBreakerMiddleware
private telemetry: TelemetryMiddleware
constructor() {
this.cacheAdapter = createMcpCacheAdapter()
logger.info(`MCP Service initialized with ${getMcpCacheType()} cache`)
this.schemaValidator = new SchemaValidatorMiddleware()
this.circuitBreaker = new CircuitBreakerMiddleware()
this.telemetry = new TelemetryMiddleware()
this.pipeline = new ResiliencePipeline()
.use(this.telemetry)
.use(this.schemaValidator)
.use(this.circuitBreaker)
if (mcpConnectionManager) {
this.unsubscribeConnectionManager = mcpConnectionManager.subscribe((event) => {
this.clearCache(event.workspaceId)
@@ -191,15 +208,23 @@ class McpService {
if (extraHeaders && Object.keys(extraHeaders).length > 0) {
resolvedConfig.headers = { ...resolvedConfig.headers, ...extraHeaders }
}
const client = await this.createClient(resolvedConfig)
try {
const result = await client.callTool(toolCall)
logger.info(`[${requestId}] Successfully executed tool ${toolCall.name}`)
return result
} finally {
await client.disconnect()
const context = {
serverId,
workspaceId,
userId,
toolCall,
extraHeaders,
}
const result = await this.pipeline.execute(context, async (ctx) => {
const client = await this.createClient(resolvedConfig)
try {
return await client.callTool(ctx.toolCall)
} finally {
await client.disconnect()
}
})
logger.info(`[${requestId}] Successfully executed tool ${toolCall.name}`)
return result
} catch (error) {
if (this.isSessionError(error) && attempt < maxRetries - 1) {
logger.warn(
@@ -322,6 +347,7 @@ class McpService {
try {
const cached = await this.cacheAdapter.get(cacheKey)
if (cached) {
cached.tools.forEach((t: McpTool) => this.schemaValidator.cacheTool(t))
return cached.tools
}
} catch (error) {
@@ -414,6 +440,7 @@ class McpService {
logger.info(
`[${requestId}] Discovered ${allTools.length} tools from ${servers.length - failedCount}/${servers.length} servers`
)
allTools.forEach((t: McpTool) => this.schemaValidator.cacheTool(t))
return allTools
} catch (error) {
logger.error(`[${requestId}] Failed to discover MCP tools for user ${userId}:`, error)
@@ -450,6 +477,7 @@ class McpService {
try {
const tools = await client.listTools()
logger.info(`[${requestId}] Discovered ${tools.length} tools from server ${config.name}`)
tools.forEach((t: McpTool) => this.schemaValidator.cacheTool(t))
return tools
} finally {
await client.disconnect()
@@ -533,6 +561,7 @@ class McpService {
await this.cacheAdapter.clear()
logger.debug('Cleared all MCP tool cache')
}
this.schemaValidator.clearCache()
} catch (error) {
logger.warn('Failed to clear cache:', error)
}

View File

@@ -246,7 +246,7 @@ describe('categorizeError', () => {
const error = new Error('Server not accessible')
const result = categorizeError(error)
expect(result.status).toBe(404)
expect(result.message).toBe('Resource not found')
expect(result.message).toBe('Server not accessible')
})
it.concurrent('returns 401 for authentication errors', () => {
@@ -267,28 +267,28 @@ describe('categorizeError', () => {
const error = new Error('Invalid parameter provided')
const result = categorizeError(error)
expect(result.status).toBe(400)
expect(result.message).toBe('Invalid request parameters')
expect(result.message).toBe('Invalid parameter provided')
})
it.concurrent('returns 400 for missing required errors', () => {
const error = new Error('Missing required field: name')
const result = categorizeError(error)
expect(result.status).toBe(400)
expect(result.message).toBe('Invalid request parameters')
expect(result.message).toBe('Missing required field: name')
})
it.concurrent('returns 400 for validation errors', () => {
const error = new Error('Validation failed for input')
const result = categorizeError(error)
expect(result.status).toBe(400)
expect(result.message).toBe('Invalid request parameters')
expect(result.message).toBe('Validation failed for input')
})
it.concurrent('returns 500 for generic errors', () => {
const error = new Error('Something went wrong')
const result = categorizeError(error)
expect(result.status).toBe(500)
expect(result.message).toBe('Internal server error')
expect(result.message).toBe('Something went wrong')
})
it.concurrent('returns 500 for non-Error objects', () => {

View File

@@ -49,18 +49,18 @@ export const MCP_CLIENT_CONSTANTS = {
} as const
/**
* Create standardized MCP error response.
* Always returns the defaultMessage to clients to prevent leaking internal error details.
* Callers are responsible for logging the original error before calling this function.
* Create standardized MCP error response
*/
export function createMcpErrorResponse(
_error: unknown,
error: unknown,
defaultMessage: string,
status = 500
): NextResponse {
const errorMessage = error instanceof Error ? error.message : defaultMessage
const response: McpApiResponse = {
success: false,
error: defaultMessage,
error: errorMessage,
}
return NextResponse.json(response, { status })
@@ -115,33 +115,36 @@ export function validateRequiredFields(
}
/**
* Enhanced error categorization for more specific HTTP status codes.
* Returns safe, generic messages to prevent leaking internal details.
* Enhanced error categorization for more specific HTTP status codes
*/
export function categorizeError(error: unknown): { message: string; status: number } {
if (!(error instanceof Error)) {
return { message: 'Unknown error occurred', status: 500 }
}
const msg = error.message.toLowerCase()
const message = error.message.toLowerCase()
if (msg.includes('timeout')) {
if (message.includes('timeout')) {
return { message: 'Request timed out', status: 408 }
}
if (msg.includes('not found') || msg.includes('not accessible')) {
return { message: 'Resource not found', status: 404 }
if (message.includes('not found') || message.includes('not accessible')) {
return { message: error.message, status: 404 }
}
if (msg.includes('authentication') || msg.includes('unauthorized')) {
if (message.includes('authentication') || message.includes('unauthorized')) {
return { message: 'Authentication required', status: 401 }
}
if (msg.includes('invalid') || msg.includes('missing required') || msg.includes('validation')) {
return { message: 'Invalid request parameters', status: 400 }
if (
message.includes('invalid') ||
message.includes('missing required') ||
message.includes('validation')
) {
return { message: error.message, status: 400 }
}
return { message: 'Internal server error', status: 500 }
return { message: error.message, status: 500 }
}
/**

View File

@@ -170,6 +170,11 @@ describe('OAuth Token Refresh', () => {
describe('Body Credential Providers', () => {
const bodyCredentialProviders = [
{ name: 'Google', providerId: 'google', endpoint: 'https://oauth2.googleapis.com/token' },
{
name: 'GitHub',
providerId: 'github',
endpoint: 'https://github.com/login/oauth/access_token',
},
{
name: 'Microsoft',
providerId: 'microsoft',
@@ -274,6 +279,19 @@ describe('OAuth Token Refresh', () => {
)
})
it.concurrent('should include Accept header for GitHub requests', async () => {
const mockFetch = createMockFetch(defaultOAuthResponse)
const refreshToken = 'test_refresh_token'
await withMockFetch(mockFetch, () => refreshOAuthToken('github', refreshToken))
const [, requestOptions] = mockFetch.mock.calls[0] as [
string,
{ headers: Record<string, string>; body: string },
]
expect(requestOptions.headers.Accept).toBe('application/json')
})
it.concurrent('should include User-Agent header for Reddit requests', async () => {
const mockFetch = createMockFetch(defaultOAuthResponse)
const refreshToken = 'test_refresh_token'

View File

@@ -6,8 +6,8 @@ import {
CalComIcon,
ConfluenceIcon,
DropboxIcon,
GithubIcon,
GmailIcon,
GoogleAdsIcon,
GoogleBigQueryIcon,
GoogleCalendarIcon,
GoogleContactsIcon,
@@ -147,18 +147,6 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
'https://www.googleapis.com/auth/contacts',
],
},
'google-ads': {
name: 'Google Ads',
description: 'Query campaigns, ad groups, and performance metrics in Google Ads.',
providerId: 'google-ads',
icon: GoogleAdsIcon,
baseProviderIcon: GoogleIcon,
scopes: [
'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/userinfo.profile',
'https://www.googleapis.com/auth/adwords',
],
},
'google-bigquery': {
name: 'Google BigQuery',
description: 'Query, list, and insert data in Google BigQuery.',
@@ -352,6 +340,21 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
},
defaultService: 'outlook',
},
github: {
name: 'GitHub',
icon: GithubIcon,
services: {
github: {
name: 'GitHub',
description: 'Manage repositories, issues, and pull requests.',
providerId: 'github-repo',
icon: GithubIcon,
baseProviderIcon: GithubIcon,
scopes: ['repo', 'user:email', 'read:user', 'workflow'],
},
},
defaultService: 'github',
},
x: {
name: 'X',
icon: xIcon,
@@ -471,7 +474,6 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
'read:comment:jira',
'delete:comment:jira',
'read:attachment:jira',
'write:attachment:jira',
'delete:attachment:jira',
'write:issue-worklog:jira',
'read:issue-worklog:jira',
@@ -637,7 +639,6 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
'im:history',
'im:read',
'users:read',
// TODO: Add 'users:read.email' once Slack app review is approved
'files:write',
'files:read',
'canvases:write',
@@ -986,6 +987,19 @@ function getProviderAuthConfig(provider: string): ProviderAuthConfig {
useBasicAuth: false,
}
}
case 'github': {
const { clientId, clientSecret } = getCredentials(
env.GITHUB_CLIENT_ID,
env.GITHUB_CLIENT_SECRET
)
return {
tokenEndpoint: 'https://github.com/login/oauth/access_token',
clientId,
clientSecret,
useBasicAuth: false,
additionalHeaders: { Accept: 'application/json' },
}
}
case 'x': {
const { clientId, clientSecret } = getCredentials(env.X_CLIENT_ID, env.X_CLIENT_SECRET)
return {

Some files were not shown because too many files have changed in this diff Show More