diff --git a/apps/docs/components/ui/icon-mapping.ts b/apps/docs/components/ui/icon-mapping.ts index 39e82ed79c6..b4347637bd8 100644 --- a/apps/docs/components/ui/icon-mapping.ts +++ b/apps/docs/components/ui/icon-mapping.ts @@ -409,6 +409,7 @@ export const blockTypeToIconMap: Record = { credential: CredentialIcon, credential_group: GridOffset, crowdstrike: CrowdStrikeIcon, + crowdstrike_v2: CrowdStrikeIcon, crunchbase: CrunchbaseIcon, crunchbase_v2: CrunchbaseIcon, cursor: CursorIcon, @@ -418,6 +419,7 @@ export const blockTypeToIconMap: Record = { databricks: DatabricksIcon, databricks_v2: DatabricksIcon, datadog: DatadogIcon, + datadog_v2: DatadogIcon, datagma: DatagmaIcon, daytona: DaytonaIcon, daytona_v2: DaytonaIcon, @@ -487,6 +489,7 @@ export const blockTypeToIconMap: Record = { gmail: GmailIcon, gmail_v2: GmailIcon, gong: GongIcon, + gong_v2: GongIcon, google_ads: GoogleAdsIcon, google_appsheet: GoogleAppsheetIcon, google_bigquery: GoogleBigQueryIcon, @@ -658,6 +661,7 @@ export const blockTypeToIconMap: Record = { plane: PlaneIcon, plane_v2: PlaneIcon, planetscale: PlanetScaleIcon, + planetscale_v2: PlanetScaleIcon, polymarket: PolymarketIcon, postgresql: PostgresIcon, posthog: PosthogIcon, @@ -703,6 +707,7 @@ export const blockTypeToIconMap: Record = { s3: S3Icon, s3_v2: S3Icon, sailpoint: SailPointIcon, + sailpoint_v2: SailPointIcon, salesforce: SalesforceIcon, sap_concur: SapConcurIcon, sap_s4hana: SapS4HanaIcon, @@ -719,6 +724,7 @@ export const blockTypeToIconMap: Record = { serper: SerperIcon, servicenow: ServiceNowIcon, servicenow_v2: ServiceNowIcon, + servicenow_v3: ServiceNowIcon, ses: SESIcon, ses_v2: SESIcon, sftp: SftpIcon, @@ -823,12 +829,14 @@ export const blockTypeToIconMap: Record = { youtube: YouTubeIcon, youtube_v2: YouTubeIcon, zendesk: ZendeskIcon, + zendesk_v2: ZendeskIcon, zep: ZepIcon, zep_v2: ZepIcon, zerobounce: ZeroBounceIcon, zoho_desk: ZohoDeskIcon, zoom: ZoomIcon, zoominfo: ZoomInfoIcon, + zoominfo_v2: ZoomInfoIcon, } export const coreBlockTypes = new Set([ diff --git a/apps/docs/content/docs/cli/reference.mdx b/apps/docs/content/docs/cli/reference.mdx index 981fdcef182..adee7020308 100644 --- a/apps/docs/content/docs/cli/reference.mdx +++ b/apps/docs/content/docs/cli/reference.mdx @@ -5222,7 +5222,7 @@ sim selectors get [options] | Option | Required | Description | | --- | --- | --- | -| `--selector-key ` | Yes | Registered selector key from an import or sync preview, such as `gmail.labels`. Accepted values: `airtable.bases`, `airtable.tables`, `asana.workspaces`, `attio.lists`, `attio.objects`, `bigquery.datasets`, `bigquery.tables`, `bitbucket.workspaces`, `bitbucket.repositories`, `calcom.eventTypes`, `calcom.schedules`, `clickup.workspaces`, `clickup.spaces`, `clickup.folders`, `clickup.lists`, `coda.docs`, `coda.pages`, `coda.tables`, `coda.columns`, `coda.rows`, `coda.formulas`, `coda.controls`, `coda.folders`, `coda.permissions`, `confluence.spaces`, `confluence.spacesById`, `confluence.pages`, `google.tasks.lists`, `gauge.organizations`, `gauge.categories`, `gauge.topics`, `gauge.prompts`, `gauge.tags`, `gauge.answers`, `fivetran.groups`, `fivetran.connections`, `gmail.labels`, `google.calendar`, `google.drive`, `google.sheets`, `harmonic.savedSearches`, `jama.projects`, `jama.itemTypes`, `flow.projects`, `flow.categories`, `flow.branches`, `helpscout.mailboxes`, `helpscout.assignees`, `hubspot.lists`, `hubspot.owners`, `hubspot.pipelines`, `hubspot.pipelineStages`, `hubspot.properties`, `jsm.requestTypes`, `jsm.serviceDesks`, `microsoft.planner.plans`, `notion.databases`, `notion.pages`, `netsuite.recordTypes`, `netsuite.asyncTasks`, `pipedrive.pipelines`, `sharepoint.lists`, `trello.boards`, `zoho_desk.organizations`, `zoho_desk.departments`, `zoho_desk.agents`, `zoom.meetings`, `slack.channels`, `snowflake.databases`, `snowflake.schemas`, `snowflake.tables`, `snowflake.warehouses`, `snowflake.roles`, `snowflake.fileFormats`, `snowflake.procedures`, `slack.users`, `outlook.folders`, `outlook.calendars`, `microsoft.teams`, `microsoft.chats`, `microsoft.channels`, `microsoft.planner`, `onedrive.files`, `onedrive.folders`, `sharepoint.sites`, `microsoft.excel`, `microsoft.excel.drives`, `microsoft.excel.sheets`, `microsoft.word`, `wealthbox.contacts`, `jira.issues`, `jira.projects`, `jira.projectKeys`, `linear.projects`, `linear.teams`, `monday.boards`, `monday.groups`, `webflow.sites`, `webflow.collections`, `webflow.items`, `planetscale.databases`, `powerbi.workspaces`, `powerbi.datasets`, `powerbi.reports`, `planetscale.branches`, `planetscale.backups`, `planetscale.deployRequests`, `cloudwatch.logGroups`, `cloudwatch.logStreams`, `cloudwatch.savedLogGroups`, `cloudwatch.savedLogStreams`, `imap.mailboxes`, `mcp.tools`, `managedAgent.agents`, `managedAgent.environments`, `managedAgent.vaults`, `managedAgent.memoryStores`, `knowledge.documents`, `sim.workflows`, `table.columns`, `table.outputColumns`, `workspace.secretNames`, `workspace.sandboxes`, `providers.ollamaEmbeddingModels`, `providers.openrouterEmbeddingModels`. | +| `--selector-key ` | Yes | Registered selector key from an import or sync preview, such as `gmail.labels`. Accepted values: `airtable.bases`, `airtable.tables`, `asana.workspaces`, `attio.lists`, `attio.objects`, `bigquery.datasets`, `bigquery.tables`, `bitbucket.workspaces`, `bitbucket.repositories`, `calcom.eventTypes`, `calcom.schedules`, `clickup.workspaces`, `clickup.spaces`, `clickup.folders`, `clickup.lists`, `coda.docs`, `coda.pages`, `coda.tables`, `coda.columns`, `coda.rows`, `coda.formulas`, `coda.controls`, `coda.folders`, `coda.permissions`, `confluence.spaces`, `confluence.spacesById`, `confluence.pages`, `google.tasks.lists`, `gauge.organizations`, `gauge.categories`, `gauge.topics`, `gauge.prompts`, `gauge.tags`, `gauge.answers`, `fivetran.groups`, `fivetran.connections`, `gmail.labels`, `google.calendar`, `google.drive`, `google.sheets`, `harmonic.savedSearches`, `jama.projects`, `jama.itemTypes`, `flow.projects`, `flow.categories`, `flow.branches`, `helpscout.mailboxes`, `helpscout.assignees`, `hubspot.lists`, `hubspot.owners`, `hubspot.pipelines`, `hubspot.pipelineStages`, `hubspot.properties`, `jsm.requestTypes`, `jsm.serviceDesks`, `microsoft.planner.plans`, `notion.databases`, `notion.pages`, `netsuite.recordTypes`, `netsuite.asyncTasks`, `pipedrive.pipelines`, `sharepoint.lists`, `trello.boards`, `zoho_desk.organizations`, `zoho_desk.departments`, `zoho_desk.agents`, `zoom.meetings`, `slack.channels`, `snowflake.databases`, `snowflake.schemas`, `snowflake.tables`, `snowflake.warehouses`, `snowflake.roles`, `snowflake.fileFormats`, `snowflake.procedures`, `slack.users`, `outlook.folders`, `outlook.calendars`, `microsoft.teams`, `microsoft.chats`, `microsoft.channels`, `microsoft.planner`, `onedrive.files`, `onedrive.folders`, `sharepoint.sites`, `microsoft.excel`, `microsoft.excel.drives`, `microsoft.excel.sheets`, `microsoft.word`, `wealthbox.contacts`, `jira.issues`, `jira.projects`, `jira.projectKeys`, `linear.projects`, `linear.teams`, `monday.boards`, `monday.groups`, `webflow.sites`, `webflow.collections`, `webflow.items`, `planetscale.databases`, `powerbi.workspaces`, `powerbi.datasets`, `powerbi.reports`, `planetscale.branches`, `planetscale.backups`, `planetscale.deployRequests`, `planetscale.savedDatabases`, `planetscale.savedBranches`, `planetscale.savedBackups`, `planetscale.savedDeployRequests`, `cloudwatch.logGroups`, `cloudwatch.logStreams`, `cloudwatch.savedLogGroups`, `cloudwatch.savedLogStreams`, `imap.mailboxes`, `mcp.tools`, `managedAgent.agents`, `managedAgent.environments`, `managedAgent.vaults`, `managedAgent.memoryStores`, `knowledge.documents`, `sim.workflows`, `table.columns`, `table.outputColumns`, `workspace.secretNames`, `workspace.sandboxes`, `providers.ollamaEmbeddingModels`, `providers.openrouterEmbeddingModels`. | | `--context ` | No | Only the dependencies declared by the selector, such as oauthCredential and channelId. Missing OAuth connections require human authorization. Defaults to {}. (JSON, or @path / @- to read a file or stdin). | | `--id ` | Yes | Provider option ID to resolve with the same selector and declared context. | @@ -5244,7 +5244,7 @@ sim selectors list [options] | Option | Required | Description | | --- | --- | --- | -| `--selector-key ` | Yes | Registered selector key from an import or sync preview, such as `gmail.labels`. Accepted values: `airtable.bases`, `airtable.tables`, `asana.workspaces`, `attio.lists`, `attio.objects`, `bigquery.datasets`, `bigquery.tables`, `bitbucket.workspaces`, `bitbucket.repositories`, `calcom.eventTypes`, `calcom.schedules`, `clickup.workspaces`, `clickup.spaces`, `clickup.folders`, `clickup.lists`, `coda.docs`, `coda.pages`, `coda.tables`, `coda.columns`, `coda.rows`, `coda.formulas`, `coda.controls`, `coda.folders`, `coda.permissions`, `confluence.spaces`, `confluence.spacesById`, `confluence.pages`, `google.tasks.lists`, `gauge.organizations`, `gauge.categories`, `gauge.topics`, `gauge.prompts`, `gauge.tags`, `gauge.answers`, `fivetran.groups`, `fivetran.connections`, `gmail.labels`, `google.calendar`, `google.drive`, `google.sheets`, `harmonic.savedSearches`, `jama.projects`, `jama.itemTypes`, `flow.projects`, `flow.categories`, `flow.branches`, `helpscout.mailboxes`, `helpscout.assignees`, `hubspot.lists`, `hubspot.owners`, `hubspot.pipelines`, `hubspot.pipelineStages`, `hubspot.properties`, `jsm.requestTypes`, `jsm.serviceDesks`, `microsoft.planner.plans`, `notion.databases`, `notion.pages`, `netsuite.recordTypes`, `netsuite.asyncTasks`, `pipedrive.pipelines`, `sharepoint.lists`, `trello.boards`, `zoho_desk.organizations`, `zoho_desk.departments`, `zoho_desk.agents`, `zoom.meetings`, `slack.channels`, `snowflake.databases`, `snowflake.schemas`, `snowflake.tables`, `snowflake.warehouses`, `snowflake.roles`, `snowflake.fileFormats`, `snowflake.procedures`, `slack.users`, `outlook.folders`, `outlook.calendars`, `microsoft.teams`, `microsoft.chats`, `microsoft.channels`, `microsoft.planner`, `onedrive.files`, `onedrive.folders`, `sharepoint.sites`, `microsoft.excel`, `microsoft.excel.drives`, `microsoft.excel.sheets`, `microsoft.word`, `wealthbox.contacts`, `jira.issues`, `jira.projects`, `jira.projectKeys`, `linear.projects`, `linear.teams`, `monday.boards`, `monday.groups`, `webflow.sites`, `webflow.collections`, `webflow.items`, `planetscale.databases`, `powerbi.workspaces`, `powerbi.datasets`, `powerbi.reports`, `planetscale.branches`, `planetscale.backups`, `planetscale.deployRequests`, `cloudwatch.logGroups`, `cloudwatch.logStreams`, `cloudwatch.savedLogGroups`, `cloudwatch.savedLogStreams`, `imap.mailboxes`, `mcp.tools`, `managedAgent.agents`, `managedAgent.environments`, `managedAgent.vaults`, `managedAgent.memoryStores`, `knowledge.documents`, `sim.workflows`, `table.columns`, `table.outputColumns`, `workspace.secretNames`, `workspace.sandboxes`, `providers.ollamaEmbeddingModels`, `providers.openrouterEmbeddingModels`. | +| `--selector-key ` | Yes | Registered selector key from an import or sync preview, such as `gmail.labels`. Accepted values: `airtable.bases`, `airtable.tables`, `asana.workspaces`, `attio.lists`, `attio.objects`, `bigquery.datasets`, `bigquery.tables`, `bitbucket.workspaces`, `bitbucket.repositories`, `calcom.eventTypes`, `calcom.schedules`, `clickup.workspaces`, `clickup.spaces`, `clickup.folders`, `clickup.lists`, `coda.docs`, `coda.pages`, `coda.tables`, `coda.columns`, `coda.rows`, `coda.formulas`, `coda.controls`, `coda.folders`, `coda.permissions`, `confluence.spaces`, `confluence.spacesById`, `confluence.pages`, `google.tasks.lists`, `gauge.organizations`, `gauge.categories`, `gauge.topics`, `gauge.prompts`, `gauge.tags`, `gauge.answers`, `fivetran.groups`, `fivetran.connections`, `gmail.labels`, `google.calendar`, `google.drive`, `google.sheets`, `harmonic.savedSearches`, `jama.projects`, `jama.itemTypes`, `flow.projects`, `flow.categories`, `flow.branches`, `helpscout.mailboxes`, `helpscout.assignees`, `hubspot.lists`, `hubspot.owners`, `hubspot.pipelines`, `hubspot.pipelineStages`, `hubspot.properties`, `jsm.requestTypes`, `jsm.serviceDesks`, `microsoft.planner.plans`, `notion.databases`, `notion.pages`, `netsuite.recordTypes`, `netsuite.asyncTasks`, `pipedrive.pipelines`, `sharepoint.lists`, `trello.boards`, `zoho_desk.organizations`, `zoho_desk.departments`, `zoho_desk.agents`, `zoom.meetings`, `slack.channels`, `snowflake.databases`, `snowflake.schemas`, `snowflake.tables`, `snowflake.warehouses`, `snowflake.roles`, `snowflake.fileFormats`, `snowflake.procedures`, `slack.users`, `outlook.folders`, `outlook.calendars`, `microsoft.teams`, `microsoft.chats`, `microsoft.channels`, `microsoft.planner`, `onedrive.files`, `onedrive.folders`, `sharepoint.sites`, `microsoft.excel`, `microsoft.excel.drives`, `microsoft.excel.sheets`, `microsoft.word`, `wealthbox.contacts`, `jira.issues`, `jira.projects`, `jira.projectKeys`, `linear.projects`, `linear.teams`, `monday.boards`, `monday.groups`, `webflow.sites`, `webflow.collections`, `webflow.items`, `planetscale.databases`, `powerbi.workspaces`, `powerbi.datasets`, `powerbi.reports`, `planetscale.branches`, `planetscale.backups`, `planetscale.deployRequests`, `planetscale.savedDatabases`, `planetscale.savedBranches`, `planetscale.savedBackups`, `planetscale.savedDeployRequests`, `cloudwatch.logGroups`, `cloudwatch.logStreams`, `cloudwatch.savedLogGroups`, `cloudwatch.savedLogStreams`, `imap.mailboxes`, `mcp.tools`, `managedAgent.agents`, `managedAgent.environments`, `managedAgent.vaults`, `managedAgent.memoryStores`, `knowledge.documents`, `sim.workflows`, `table.columns`, `table.outputColumns`, `workspace.secretNames`, `workspace.sandboxes`, `providers.ollamaEmbeddingModels`, `providers.openrouterEmbeddingModels`. | | `--context ` | No | Only the dependencies declared by the selector, such as oauthCredential and channelId. Missing OAuth connections require human authorization. Defaults to {}. (JSON, or @path / @- to read a file or stdin). | | `--search ` | No | Provider option search text. | | `--cursor ` | No | Continue from nextCursor returned by a previous result. | diff --git a/apps/docs/content/docs/cli/selectors.mdx b/apps/docs/content/docs/cli/selectors.mdx index 368e237b9d9..0ffd129f4e6 100644 --- a/apps/docs/content/docs/cli/selectors.mdx +++ b/apps/docs/content/docs/cli/selectors.mdx @@ -25,7 +25,7 @@ sim selectors list [options] | Option | Required | Description | | --- | --- | --- | -| `--selector-key ` | Yes | Registered selector key from an import or sync preview, such as `gmail.labels`. Accepted values: `airtable.bases`, `airtable.tables`, `asana.workspaces`, `attio.lists`, `attio.objects`, `bigquery.datasets`, `bigquery.tables`, `bitbucket.workspaces`, `bitbucket.repositories`, `calcom.eventTypes`, `calcom.schedules`, `clickup.workspaces`, `clickup.spaces`, `clickup.folders`, `clickup.lists`, `coda.docs`, `coda.pages`, `coda.tables`, `coda.columns`, `coda.rows`, `coda.formulas`, `coda.controls`, `coda.folders`, `coda.permissions`, `confluence.spaces`, `confluence.spacesById`, `confluence.pages`, `google.tasks.lists`, `gauge.organizations`, `gauge.categories`, `gauge.topics`, `gauge.prompts`, `gauge.tags`, `gauge.answers`, `fivetran.groups`, `fivetran.connections`, `gmail.labels`, `google.calendar`, `google.drive`, `google.sheets`, `harmonic.savedSearches`, `jama.projects`, `jama.itemTypes`, `flow.projects`, `flow.categories`, `flow.branches`, `helpscout.mailboxes`, `helpscout.assignees`, `hubspot.lists`, `hubspot.owners`, `hubspot.pipelines`, `hubspot.pipelineStages`, `hubspot.properties`, `jsm.requestTypes`, `jsm.serviceDesks`, `microsoft.planner.plans`, `notion.databases`, `notion.pages`, `netsuite.recordTypes`, `netsuite.asyncTasks`, `pipedrive.pipelines`, `sharepoint.lists`, `trello.boards`, `zoho_desk.organizations`, `zoho_desk.departments`, `zoho_desk.agents`, `zoom.meetings`, `slack.channels`, `snowflake.databases`, `snowflake.schemas`, `snowflake.tables`, `snowflake.warehouses`, `snowflake.roles`, `snowflake.fileFormats`, `snowflake.procedures`, `slack.users`, `outlook.folders`, `outlook.calendars`, `microsoft.teams`, `microsoft.chats`, `microsoft.channels`, `microsoft.planner`, `onedrive.files`, `onedrive.folders`, `sharepoint.sites`, `microsoft.excel`, `microsoft.excel.drives`, `microsoft.excel.sheets`, `microsoft.word`, `wealthbox.contacts`, `jira.issues`, `jira.projects`, `jira.projectKeys`, `linear.projects`, `linear.teams`, `monday.boards`, `monday.groups`, `webflow.sites`, `webflow.collections`, `webflow.items`, `planetscale.databases`, `powerbi.workspaces`, `powerbi.datasets`, `powerbi.reports`, `planetscale.branches`, `planetscale.backups`, `planetscale.deployRequests`, `cloudwatch.logGroups`, `cloudwatch.logStreams`, `cloudwatch.savedLogGroups`, `cloudwatch.savedLogStreams`, `imap.mailboxes`, `mcp.tools`, `managedAgent.agents`, `managedAgent.environments`, `managedAgent.vaults`, `managedAgent.memoryStores`, `knowledge.documents`, `sim.workflows`, `table.columns`, `table.outputColumns`, `workspace.secretNames`, `workspace.sandboxes`, `providers.ollamaEmbeddingModels`, `providers.openrouterEmbeddingModels`. | +| `--selector-key ` | Yes | Registered selector key from an import or sync preview, such as `gmail.labels`. Accepted values: `airtable.bases`, `airtable.tables`, `asana.workspaces`, `attio.lists`, `attio.objects`, `bigquery.datasets`, `bigquery.tables`, `bitbucket.workspaces`, `bitbucket.repositories`, `calcom.eventTypes`, `calcom.schedules`, `clickup.workspaces`, `clickup.spaces`, `clickup.folders`, `clickup.lists`, `coda.docs`, `coda.pages`, `coda.tables`, `coda.columns`, `coda.rows`, `coda.formulas`, `coda.controls`, `coda.folders`, `coda.permissions`, `confluence.spaces`, `confluence.spacesById`, `confluence.pages`, `google.tasks.lists`, `gauge.organizations`, `gauge.categories`, `gauge.topics`, `gauge.prompts`, `gauge.tags`, `gauge.answers`, `fivetran.groups`, `fivetran.connections`, `gmail.labels`, `google.calendar`, `google.drive`, `google.sheets`, `harmonic.savedSearches`, `jama.projects`, `jama.itemTypes`, `flow.projects`, `flow.categories`, `flow.branches`, `helpscout.mailboxes`, `helpscout.assignees`, `hubspot.lists`, `hubspot.owners`, `hubspot.pipelines`, `hubspot.pipelineStages`, `hubspot.properties`, `jsm.requestTypes`, `jsm.serviceDesks`, `microsoft.planner.plans`, `notion.databases`, `notion.pages`, `netsuite.recordTypes`, `netsuite.asyncTasks`, `pipedrive.pipelines`, `sharepoint.lists`, `trello.boards`, `zoho_desk.organizations`, `zoho_desk.departments`, `zoho_desk.agents`, `zoom.meetings`, `slack.channels`, `snowflake.databases`, `snowflake.schemas`, `snowflake.tables`, `snowflake.warehouses`, `snowflake.roles`, `snowflake.fileFormats`, `snowflake.procedures`, `slack.users`, `outlook.folders`, `outlook.calendars`, `microsoft.teams`, `microsoft.chats`, `microsoft.channels`, `microsoft.planner`, `onedrive.files`, `onedrive.folders`, `sharepoint.sites`, `microsoft.excel`, `microsoft.excel.drives`, `microsoft.excel.sheets`, `microsoft.word`, `wealthbox.contacts`, `jira.issues`, `jira.projects`, `jira.projectKeys`, `linear.projects`, `linear.teams`, `monday.boards`, `monday.groups`, `webflow.sites`, `webflow.collections`, `webflow.items`, `planetscale.databases`, `powerbi.workspaces`, `powerbi.datasets`, `powerbi.reports`, `planetscale.branches`, `planetscale.backups`, `planetscale.deployRequests`, `planetscale.savedDatabases`, `planetscale.savedBranches`, `planetscale.savedBackups`, `planetscale.savedDeployRequests`, `cloudwatch.logGroups`, `cloudwatch.logStreams`, `cloudwatch.savedLogGroups`, `cloudwatch.savedLogStreams`, `imap.mailboxes`, `mcp.tools`, `managedAgent.agents`, `managedAgent.environments`, `managedAgent.vaults`, `managedAgent.memoryStores`, `knowledge.documents`, `sim.workflows`, `table.columns`, `table.outputColumns`, `workspace.secretNames`, `workspace.sandboxes`, `providers.ollamaEmbeddingModels`, `providers.openrouterEmbeddingModels`. | | `--context ` | No | Only the dependencies declared by the selector, such as oauthCredential and channelId. Missing OAuth connections require human authorization. Defaults to {}. (JSON, or @path / @- to read a file or stdin). | | `--search ` | No | Provider option search text. | | `--cursor ` | No | Continue from nextCursor returned by a previous result. | @@ -49,7 +49,7 @@ sim selectors get [options] | Option | Required | Description | | --- | --- | --- | -| `--selector-key ` | Yes | Registered selector key from an import or sync preview, such as `gmail.labels`. Accepted values: `airtable.bases`, `airtable.tables`, `asana.workspaces`, `attio.lists`, `attio.objects`, `bigquery.datasets`, `bigquery.tables`, `bitbucket.workspaces`, `bitbucket.repositories`, `calcom.eventTypes`, `calcom.schedules`, `clickup.workspaces`, `clickup.spaces`, `clickup.folders`, `clickup.lists`, `coda.docs`, `coda.pages`, `coda.tables`, `coda.columns`, `coda.rows`, `coda.formulas`, `coda.controls`, `coda.folders`, `coda.permissions`, `confluence.spaces`, `confluence.spacesById`, `confluence.pages`, `google.tasks.lists`, `gauge.organizations`, `gauge.categories`, `gauge.topics`, `gauge.prompts`, `gauge.tags`, `gauge.answers`, `fivetran.groups`, `fivetran.connections`, `gmail.labels`, `google.calendar`, `google.drive`, `google.sheets`, `harmonic.savedSearches`, `jama.projects`, `jama.itemTypes`, `flow.projects`, `flow.categories`, `flow.branches`, `helpscout.mailboxes`, `helpscout.assignees`, `hubspot.lists`, `hubspot.owners`, `hubspot.pipelines`, `hubspot.pipelineStages`, `hubspot.properties`, `jsm.requestTypes`, `jsm.serviceDesks`, `microsoft.planner.plans`, `notion.databases`, `notion.pages`, `netsuite.recordTypes`, `netsuite.asyncTasks`, `pipedrive.pipelines`, `sharepoint.lists`, `trello.boards`, `zoho_desk.organizations`, `zoho_desk.departments`, `zoho_desk.agents`, `zoom.meetings`, `slack.channels`, `snowflake.databases`, `snowflake.schemas`, `snowflake.tables`, `snowflake.warehouses`, `snowflake.roles`, `snowflake.fileFormats`, `snowflake.procedures`, `slack.users`, `outlook.folders`, `outlook.calendars`, `microsoft.teams`, `microsoft.chats`, `microsoft.channels`, `microsoft.planner`, `onedrive.files`, `onedrive.folders`, `sharepoint.sites`, `microsoft.excel`, `microsoft.excel.drives`, `microsoft.excel.sheets`, `microsoft.word`, `wealthbox.contacts`, `jira.issues`, `jira.projects`, `jira.projectKeys`, `linear.projects`, `linear.teams`, `monday.boards`, `monday.groups`, `webflow.sites`, `webflow.collections`, `webflow.items`, `planetscale.databases`, `powerbi.workspaces`, `powerbi.datasets`, `powerbi.reports`, `planetscale.branches`, `planetscale.backups`, `planetscale.deployRequests`, `cloudwatch.logGroups`, `cloudwatch.logStreams`, `cloudwatch.savedLogGroups`, `cloudwatch.savedLogStreams`, `imap.mailboxes`, `mcp.tools`, `managedAgent.agents`, `managedAgent.environments`, `managedAgent.vaults`, `managedAgent.memoryStores`, `knowledge.documents`, `sim.workflows`, `table.columns`, `table.outputColumns`, `workspace.secretNames`, `workspace.sandboxes`, `providers.ollamaEmbeddingModels`, `providers.openrouterEmbeddingModels`. | +| `--selector-key ` | Yes | Registered selector key from an import or sync preview, such as `gmail.labels`. Accepted values: `airtable.bases`, `airtable.tables`, `asana.workspaces`, `attio.lists`, `attio.objects`, `bigquery.datasets`, `bigquery.tables`, `bitbucket.workspaces`, `bitbucket.repositories`, `calcom.eventTypes`, `calcom.schedules`, `clickup.workspaces`, `clickup.spaces`, `clickup.folders`, `clickup.lists`, `coda.docs`, `coda.pages`, `coda.tables`, `coda.columns`, `coda.rows`, `coda.formulas`, `coda.controls`, `coda.folders`, `coda.permissions`, `confluence.spaces`, `confluence.spacesById`, `confluence.pages`, `google.tasks.lists`, `gauge.organizations`, `gauge.categories`, `gauge.topics`, `gauge.prompts`, `gauge.tags`, `gauge.answers`, `fivetran.groups`, `fivetran.connections`, `gmail.labels`, `google.calendar`, `google.drive`, `google.sheets`, `harmonic.savedSearches`, `jama.projects`, `jama.itemTypes`, `flow.projects`, `flow.categories`, `flow.branches`, `helpscout.mailboxes`, `helpscout.assignees`, `hubspot.lists`, `hubspot.owners`, `hubspot.pipelines`, `hubspot.pipelineStages`, `hubspot.properties`, `jsm.requestTypes`, `jsm.serviceDesks`, `microsoft.planner.plans`, `notion.databases`, `notion.pages`, `netsuite.recordTypes`, `netsuite.asyncTasks`, `pipedrive.pipelines`, `sharepoint.lists`, `trello.boards`, `zoho_desk.organizations`, `zoho_desk.departments`, `zoho_desk.agents`, `zoom.meetings`, `slack.channels`, `snowflake.databases`, `snowflake.schemas`, `snowflake.tables`, `snowflake.warehouses`, `snowflake.roles`, `snowflake.fileFormats`, `snowflake.procedures`, `slack.users`, `outlook.folders`, `outlook.calendars`, `microsoft.teams`, `microsoft.chats`, `microsoft.channels`, `microsoft.planner`, `onedrive.files`, `onedrive.folders`, `sharepoint.sites`, `microsoft.excel`, `microsoft.excel.drives`, `microsoft.excel.sheets`, `microsoft.word`, `wealthbox.contacts`, `jira.issues`, `jira.projects`, `jira.projectKeys`, `linear.projects`, `linear.teams`, `monday.boards`, `monday.groups`, `webflow.sites`, `webflow.collections`, `webflow.items`, `planetscale.databases`, `powerbi.workspaces`, `powerbi.datasets`, `powerbi.reports`, `planetscale.branches`, `planetscale.backups`, `planetscale.deployRequests`, `planetscale.savedDatabases`, `planetscale.savedBranches`, `planetscale.savedBackups`, `planetscale.savedDeployRequests`, `cloudwatch.logGroups`, `cloudwatch.logStreams`, `cloudwatch.savedLogGroups`, `cloudwatch.savedLogStreams`, `imap.mailboxes`, `mcp.tools`, `managedAgent.agents`, `managedAgent.environments`, `managedAgent.vaults`, `managedAgent.memoryStores`, `knowledge.documents`, `sim.workflows`, `table.columns`, `table.outputColumns`, `workspace.secretNames`, `workspace.sandboxes`, `providers.ollamaEmbeddingModels`, `providers.openrouterEmbeddingModels`. | | `--context ` | No | Only the dependencies declared by the selector, such as oauthCredential and channelId. Missing OAuth connections require human authorization. Defaults to {}. (JSON, or @path / @- to read a file or stdin). | | `--id ` | Yes | Provider option ID to resolve with the same selector and declared context. | diff --git a/apps/docs/content/docs/integrations/atlassian-service-account.mdx b/apps/docs/content/docs/integrations/atlassian-service-account.mdx index 76f7eea35de..ba1769ef676 100644 --- a/apps/docs/content/docs/integrations/atlassian-service-account.mdx +++ b/apps/docs/content/docs/integrations/atlassian-service-account.mdx @@ -97,12 +97,21 @@ Use the **App** and **Scope type** filters to find both classic and granular sco height={458} /> +## Use an OAuth 2.0 credential instead of a token [#oauth-2-0-credential] + +A service account can also have an [OAuth 2.0 credential](https://support.atlassian.com/user-management/docs/create-oauth-2-0-credential-for-service-accounts/): a client ID and secret with the scopes you select when you create it. Sim requests a 60-minute access token from `auth.atlassian.com` with the client credentials grant whenever a workflow needs one and calls `api.atlassian.com/ex/{jira|confluence}/{cloudId}` with it, so nothing expires in Sim the way a pasted token does. + +- Atlassian supports these credentials for the **Jira** and **Confluence** REST APIs. It does not list Jira Service Management, so use a scoped API token for Service Management operations. +- In **Directory → Service accounts**, select the service account, then **Create credentials → OAuth 2.0**, choose the scopes listed above, and copy the client ID and secret. Atlassian shows the secret only once. +- When you add the credential in Sim, set **Authentication** to **OAuth 2.0 credential (client credentials)** and paste the client ID and secret instead of an API token. +- Once the OAuth 2.0 credential is revoked in Atlassian, Sim can no longer request tokens with it. To rotate, create a new credential and **Reconnect** the Sim credential with its client ID and secret. + ## Add the credential to Sim 1. Open **Integrations** in your workspace sidebar, then **Jira**, **Jira Service Management**, or **Confluence**. 2. Select **Add to Sim → Add service account**. If only service accounts are available, select **Add service account** directly. -3. Paste the **API token** and enter **Site domain**, such as `your-team.atlassian.net`. Omit `https://` and any path. Optionally add a display name and description. -4. Select **Add service account**. Sim checks the token against the selected product's current-user endpoint; resolve any error before continuing. +3. Keep **Authentication** on **Scoped API token** and paste the **API token**, or choose [**OAuth 2.0 credential**](#oauth-2-0-credential) and paste its client ID and secret. Enter **Site domain**, such as `your-team.atlassian.net`. Omit `https://` and any path. Optionally add a display name and description. +4. Select **Add service account**. Sim checks the token (or a token minted from the OAuth 2.0 credential) against the selected product's current-user endpoint; resolve any error before continuing. 5. In your workflow's Jira, Jira Service Management, or Confluence block, select the credential and configure the operation. The credential appears on all three integration pages, but adding it only validates the selected product. Check account access and scopes before using another product. diff --git a/apps/docs/content/docs/integrations/aws-credentials.mdx b/apps/docs/content/docs/integrations/aws-credentials.mdx index 65308e11f36..ede81c9503d 100644 --- a/apps/docs/content/docs/integrations/aws-credentials.mdx +++ b/apps/docs/content/docs/integrations/aws-credentials.mdx @@ -65,7 +65,7 @@ To rotate a key, create the new key in AWS, reconnect the credential with it, co Keyless roles need two things on a self-hosted deployment: - `WORKLOAD_IDENTITY_SIGNING_KEY`: a PKCS#8 PEM RSA private key that signs Sim's tokens. -- An issuer AWS can reach over the public internet: an `https` URL on the default port, served by a certificate from a public CA. It defaults to `/oidc/workload`; set `WORKLOAD_IDENTITY_ISSUER` to pin it. Trust policies reference this URL, so never change it once roles trust it. Sim keeps keyless off when the issuer isn't `https` or names a port, query, or fragment, because AWS can't use it. +- An issuer AWS can reach over the public internet: an `https` URL on the default port, served by a certificate from a public CA. It defaults to `/oidc/workload`; set `WORKLOAD_IDENTITY_ISSUER` to pin it. A pinned issuer may use another host that reaches Sim. Its path should end in `/oidc/workload`, because Sim serves `/.well-known/openid-configuration` and `/jwks` under that path; any other path works only if a proxy routes it there, and Sim logs a warning for it. Trust policies reference this URL, so never change it once roles trust it. Sim keeps keyless off when the issuer isn't `https` or names a port, query, or fragment, because AWS can't use it. Generate a key with `openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048`. diff --git a/apps/docs/content/docs/integrations/bitbucket-service-account.mdx b/apps/docs/content/docs/integrations/bitbucket-service-account.mdx index afa099a3e2a..48afdbbf106 100644 --- a/apps/docs/content/docs/integrations/bitbucket-service-account.mdx +++ b/apps/docs/content/docs/integrations/bitbucket-service-account.mdx @@ -35,6 +35,10 @@ Admin access to the workspace, project, or repository the token is for. Workspac A repository access token can only reach its repository, and a project access token only the repositories in its project. Treat the token like a password; Sim encrypts it at rest. + +Sim also accepts an Atlassian API token created for Bitbucket (**Create API token with scopes**, then **Bitbucket**) with at least **Repositories: Read** (`read:repository:bitbucket`). That scope does not cover pull requests, so pull request operations also need **Pull requests: Read** (`read:pullrequest:bitbucket`), plus **Pull requests: Write** (`write:pullrequest:bitbucket`) to change them. An API token belongs to the person who created it and acts as them. Bitbucket app passwords no longer work: Atlassian stopped new app passwords on September 9, 2025 and set June 9, 2026 as the date existing ones stop working. See [Using API tokens](https://support.atlassian.com/bitbucket-cloud/docs/using-api-tokens/). + + ## Add the access token to Sim [#adding-the-access-token-to-sim] diff --git a/apps/docs/content/docs/integrations/crowdstrike.mdx b/apps/docs/content/docs/integrations/crowdstrike.mdx index d009fb81689..a459ba8f206 100644 --- a/apps/docs/content/docs/integrations/crowdstrike.mdx +++ b/apps/docs/content/docs/integrations/crowdstrike.mdx @@ -2,7 +2,7 @@ title: CrowdStrike description: Investigate and respond to CrowdStrike Falcon alerts, hosts, IOCs, and vulnerabilities integration: - type: "crowdstrike" + type: "crowdstrike_v2" color: "#E01F3D" --- @@ -10,7 +10,7 @@ import { ReferenceDetails } from "@/components/ui/reference-details" {/* MANUAL-CONTENT-START:intro */} -[CrowdStrike](https://www.crowdstrike.com/) is a cybersecurity platform providing endpoint protection, threat intelligence, and identity security through its Falcon suite. This integration authenticates with a Falcon API client ID and secret against a chosen cloud region and covers the Alerts, Hosts, Host Groups, IOC Management, Spotlight, Real Time Response, Case Management, and Identity Protection APIs. +[CrowdStrike](https://www.crowdstrike.com/) is a cybersecurity platform providing endpoint protection, threat intelligence, and identity security through its Falcon suite. This integration covers the Alerts, Hosts, Host Groups, IOC Management, Spotlight, Real Time Response, Case Management, and Identity Protection APIs. With this integration, you can: @@ -23,7 +23,9 @@ With this integration, you can: - **Read cases**: Search Case Management cases and read case details - **Query identity sensors**: Search Identity Protection sensors, fetch sensor details, and run aggregate queries -Each operation maps to a specific Falcon API scope — for example `Alerts: Read` and `Alerts: Write`, `Hosts: Write` for containment, `Host groups: Read`/`Write`, `IOC Management: Read`/`Write`, `Vulnerabilities: Read`, `Real time response: Read`, and `Cases: Read`. Containment and indicator deletion change live protection behavior, so scope the credential to only the operations your workflows need. +Connect a Falcon API client once as a CrowdStrike credential: create the API client in Falcon under **Support and resources > API clients and keys**, then add its client ID and secret and choose the cloud your console signs in to. Sim checks the credential by requesting a token when you save it, and requests a new one before each 30-minute Falcon token expires. An MSSP parent CID can enter a member CID so every call acts on that one child CID. Workflows built with the earlier CrowdStrike block keep the client ID, secret, and cloud they entered in the block. + +Each operation maps to a specific Falcon API scope — for example `Alerts: Read` and `Alerts: Write`, `Hosts: Write` for containment, `Host groups: Read`/`Write`, `IOC Management: Read`/`Write`, `Vulnerabilities: Read`, `Real time response: Read`, and `Cases: Read`. Containment and indicator deletion change live protection behavior, so scope the API client to only the operations your workflows need. Note that CrowdStrike decommissioned the legacy Detects API (September 30, 2025) and the CrowdScore Incidents API (March 9, 2026). This integration uses the current Alerts API and Case Management API in their place. {/* MANUAL-CONTENT-END */} @@ -44,9 +46,6 @@ Create custom CrowdStrike Falcon indicators of compromise (POST /iocs/entities/i | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | CrowdStrike Falcon API client ID | -| `clientSecret` | string | Yes | CrowdStrike Falcon API client secret | -| `cloud` | string | Yes | CrowdStrike Falcon cloud region | | `indicators` | json | Yes | JSON array of indicators to create. Each entry requires type, value, and applied_globally \(boolean\). type is one of sha256, md5, domain, ipv4, ipv6; action is one of no_action, allow, prevent, detect \(prevent_no_ui is widely reported and appears in the Falcon console, but CrowdStrike does not enumerate it in the IOC API docs - call GET /iocs/queries/actions/v1 to read the actions your tenant actually accepts\); severity is one of informational, low, medium, high, critical; platforms entries are windows, mac, or linux. Other documented fields: host_groups \(array\), description, source, tags \(array\), expiration \(ISO 8601\), mobile_action, metadata \(\{ filename \}\). Either applied_globally must be true or host_groups must be supplied. Tenants can extend these value sets, so treat them as the documented defaults rather than a closed list. | | `comment` | string | No | Audit comment explaining why these indicators were created | | `retrodetects` | boolean | No | Whether to generate retroactive detections for the new indicators | @@ -106,9 +105,6 @@ Permanently delete custom CrowdStrike Falcon indicators of compromise (DELETE /i | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | CrowdStrike Falcon API client ID | -| `clientSecret` | string | Yes | CrowdStrike Falcon API client secret | -| `cloud` | string | Yes | CrowdStrike Falcon cloud region | | `indicatorIds` | json | No | JSON array of CrowdStrike IOC IDs to delete. Cannot be combined with a filter. | | `filter` | string | No | Falcon Query Language filter selecting indicators to delete in bulk. Cannot be combined with an ID list. | | `comment` | string | No | Audit comment explaining why these indicators were deleted | @@ -136,9 +132,6 @@ Close an open CrowdStrike Falcon Real Time Response session (DELETE /real-time-r | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | CrowdStrike Falcon API client ID | -| `clientSecret` | string | Yes | CrowdStrike Falcon API client secret | -| `cloud` | string | Yes | CrowdStrike Falcon cloud region | | `sessionId` | string | Yes | RTR session ID to close | #### Output @@ -164,9 +157,6 @@ Run a read-only Real Time Response command in an open CrowdStrike Falcon session | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | CrowdStrike Falcon API client ID | -| `clientSecret` | string | Yes | CrowdStrike Falcon API client secret | -| `cloud` | string | Yes | CrowdStrike Falcon cloud region | | `sessionId` | string | Yes | RTR session ID returned by Init RTR Session | | `baseCommand` | string | Yes | Read-only RTR base command family, one of: cat, cd, clear, csrutil, env, eventlog, filehash, getsid, help, history, ifconfig, ipconfig, ls, mount, netstat, ps, reg, users. Subcommands belong in commandString, not here — and only reg query is read-tier, since reg set and reg delete are Active Responder commands. | | `commandString` | string | Yes | Full command line to run, such as "ls C:\Windows" or "reg query HKLM\Software" | @@ -195,9 +185,6 @@ Get full CrowdStrike Falcon alert records for one or more composite alert IDs (P | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | CrowdStrike Falcon API client ID | -| `clientSecret` | string | Yes | CrowdStrike Falcon API client secret | -| `cloud` | string | Yes | CrowdStrike Falcon cloud region | | `compositeIds` | json | Yes | JSON array of CrowdStrike composite alert IDs | | `includeHidden` | boolean | No | Include previously hidden alerts \(CrowdStrike defaults this to true\) | @@ -277,9 +264,6 @@ Get CrowdStrike Falcon Case Management case records for one or more case IDs (PO | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | CrowdStrike Falcon API client ID | -| `clientSecret` | string | Yes | CrowdStrike Falcon API client secret | -| `cloud` | string | Yes | CrowdStrike Falcon cloud region | | `caseIds` | json | Yes | JSON array of CrowdStrike case IDs | #### Output @@ -337,9 +321,6 @@ Get CrowdStrike Falcon host group records for one or more group IDs (GET /device | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | CrowdStrike Falcon API client ID | -| `clientSecret` | string | Yes | CrowdStrike Falcon API client secret | -| `cloud` | string | Yes | CrowdStrike Falcon cloud region | | `hostGroupIds` | json | Yes | JSON array of CrowdStrike host group IDs | #### Output @@ -374,9 +355,6 @@ Get custom CrowdStrike Falcon indicator of compromise (IOC) records for one or m | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | CrowdStrike Falcon API client ID | -| `clientSecret` | string | Yes | CrowdStrike Falcon API client secret | -| `cloud` | string | Yes | CrowdStrike Falcon cloud region | | `indicatorIds` | json | Yes | JSON array of CrowdStrike IOC IDs | #### Output @@ -433,9 +411,6 @@ Get the status and output of a Real Time Response command by cloud request ID (G | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | CrowdStrike Falcon API client ID | -| `clientSecret` | string | Yes | CrowdStrike Falcon API client secret | -| `cloud` | string | Yes | CrowdStrike Falcon cloud region | | `cloudRequestId` | string | Yes | Cloud request ID returned by Execute RTR Command | | `sequenceId` | number | No | Output chunk to retrieve, starting at 0 | @@ -467,9 +442,6 @@ Aggregate CrowdStrike Identity Protection sensors from a JSON aggregate query bo | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | CrowdStrike Falcon API client ID | -| `clientSecret` | string | Yes | CrowdStrike Falcon API client secret | -| `cloud` | string | Yes | CrowdStrike Falcon cloud region | | `aggregateQuery` | json | Yes | JSON aggregate query body documented by CrowdStrike for sensor aggregates | #### Output @@ -509,9 +481,6 @@ Get CrowdStrike Identity Protection sensor details for one or more device IDs (P | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | CrowdStrike Falcon API client ID | -| `clientSecret` | string | Yes | CrowdStrike Falcon API client secret | -| `cloud` | string | Yes | CrowdStrike Falcon cloud region | | `ids` | json | Yes | JSON array of CrowdStrike sensor device IDs | #### Output @@ -560,9 +529,6 @@ Get CrowdStrike Falcon Spotlight vulnerability records for one or more vulnerabi | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | CrowdStrike Falcon API client ID | -| `clientSecret` | string | Yes | CrowdStrike Falcon API client secret | -| `cloud` | string | Yes | CrowdStrike Falcon cloud region | | `vulnerabilityIds` | json | Yes | JSON array of Spotlight vulnerability IDs \(maximum 400 per request\) | #### Output @@ -639,9 +605,6 @@ Open a CrowdStrike Falcon Real Time Response session against a host so read-only | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | CrowdStrike Falcon API client ID | -| `clientSecret` | string | Yes | CrowdStrike Falcon API client secret | -| `cloud` | string | Yes | CrowdStrike Falcon cloud region | | `deviceId` | string | Yes | CrowdStrike host agent ID \(AID\) to open the session against | | `queueOffline` | boolean | No | Queue the session so it runs when an offline host comes back online | | `origin` | string | No | Optional session origin string recorded by CrowdStrike | @@ -674,9 +637,6 @@ Act on CrowdStrike Falcon hosts (POST /devices/entities/devices-actions/v2). Act | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | CrowdStrike Falcon API client ID | -| `clientSecret` | string | Yes | CrowdStrike Falcon API client secret | -| `cloud` | string | Yes | CrowdStrike Falcon cloud region | | `actionName` | string | Yes | Action to take: contain, lift_containment, hide_host, unhide_host, detection_suppress, or detection_unsuppress. "contain" network-isolates the host; "hide_host" removes it from the Falcon console. | | `deviceIds` | json | Yes | JSON array of up to 100 CrowdStrike host agent IDs \(AIDs\) to act on | @@ -705,9 +665,6 @@ Add hosts to or remove hosts from a CrowdStrike Falcon static host group (POST / | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | CrowdStrike Falcon API client ID | -| `clientSecret` | string | Yes | CrowdStrike Falcon API client secret | -| `cloud` | string | Yes | CrowdStrike Falcon cloud region | | `actionName` | string | Yes | Action to take: add-hosts or remove-hosts | | `hostGroupId` | string | Yes | CrowdStrike host group ID to modify \(static groups only\) | | `deviceIds` | json | Yes | JSON array of CrowdStrike host agent IDs \(AIDs\) to add to or remove from the group | @@ -744,9 +701,6 @@ Search CrowdStrike Falcon alerts with a Falcon Query Language filter and return | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | CrowdStrike Falcon API client ID | -| `clientSecret` | string | Yes | CrowdStrike Falcon API client secret | -| `cloud` | string | Yes | CrowdStrike Falcon cloud region | | `filter` | string | No | Falcon Query Language filter over alert fields | | `q` | string | No | Free-text search across all alert metadata | | `limit` | number | No | Maximum number of alert IDs to return \(max 10000\) | @@ -777,9 +731,6 @@ Search CrowdStrike Falcon Case Management cases with a Falcon Query Language fil | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | CrowdStrike Falcon API client ID | -| `clientSecret` | string | Yes | CrowdStrike Falcon API client secret | -| `cloud` | string | Yes | CrowdStrike Falcon cloud region | | `filter` | string | No | Falcon Query Language filter. Exact-match fields include cid and id; wildcard fields include assigned_to_name and assigned_to_uuid; range fields include created_timestamp and updated_timestamp. | | `q` | string | No | Free-text search across all case metadata | | `limit` | number | No | Maximum number of case IDs to return \(max 10000, default 100\) | @@ -809,9 +760,6 @@ Search CrowdStrike Falcon host groups with a Falcon Query Language filter and re | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | CrowdStrike Falcon API client ID | -| `clientSecret` | string | Yes | CrowdStrike Falcon API client secret | -| `cloud` | string | Yes | CrowdStrike Falcon cloud region | | `filter` | string | No | Falcon Query Language filter over host group fields | | `limit` | number | No | Maximum number of host group IDs to return \(1-5000\) | | `offset` | number | No | Pagination offset for the host group query | @@ -840,9 +788,6 @@ Search custom CrowdStrike Falcon indicators of compromise (IOCs) with a Falcon Q | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | CrowdStrike Falcon API client ID | -| `clientSecret` | string | Yes | CrowdStrike Falcon API client secret | -| `cloud` | string | Yes | CrowdStrike Falcon cloud region | | `filter` | string | No | Falcon Query Language filter over IOC fields | | `limit` | number | No | Maximum number of IOC IDs to return \(default 100\). CrowdStrike publishes no maximum for this endpoint; Sim caps it at 500 to keep a single request bounded | | `offset` | number | No | Pagination offset. Mutually exclusive with the after cursor; use after beyond 10,000 IOCs. | @@ -873,9 +818,6 @@ Search CrowdStrike Identity Protection sensors -- the domain controllers Falcon | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | CrowdStrike Falcon API client ID | -| `clientSecret` | string | Yes | CrowdStrike Falcon API client secret | -| `cloud` | string | Yes | CrowdStrike Falcon cloud region | | `filter` | string | No | Falcon Query Language filter for identity sensor search | | `limit` | number | No | Maximum number of sensor records to return | | `offset` | number | No | Pagination offset for the identity sensor query | @@ -927,9 +869,6 @@ Search CrowdStrike Falcon Spotlight vulnerabilities with a required Falcon Query | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | CrowdStrike Falcon API client ID | -| `clientSecret` | string | Yes | CrowdStrike Falcon API client secret | -| `cloud` | string | Yes | CrowdStrike Falcon cloud region | | `filter` | string | Yes | Falcon Query Language filter \(required by Spotlight\). Filterable fields include status, aid, cid, last_seen_within, cve.id, cve.severity, cve.exprt_rating, cve.is_cisa_kev, cve.base_score, host_info.platform_name, host_info.groups, host_info.tags, host_info.internet_exposure, and suppression_info.is_suppressed. | | `limit` | number | No | Maximum number of vulnerability IDs to return \(1-400, default 100\) | | `after` | string | No | Pagination cursor from a previous response. Spotlight does not support offset. | @@ -958,9 +897,6 @@ Update CrowdStrike Falcon alerts by composite ID: change status, assign or unass | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | CrowdStrike Falcon API client ID | -| `clientSecret` | string | Yes | CrowdStrike Falcon API client secret | -| `cloud` | string | Yes | CrowdStrike Falcon cloud region | | `compositeIds` | json | Yes | JSON array of CrowdStrike composite alert IDs to update | | `updateStatus` | string | No | New alert status: new, in_progress, reopened, or closed | | `assignToUuid` | string | No | Assign the alert to this Falcon user UUID | @@ -998,9 +934,6 @@ Update custom CrowdStrike Falcon indicators of compromise by ID (PATCH /iocs/ent | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | CrowdStrike Falcon API client ID | -| `clientSecret` | string | Yes | CrowdStrike Falcon API client secret | -| `cloud` | string | Yes | CrowdStrike Falcon cloud region | | `indicators` | json | Yes | JSON array of indicators to update. Each entry requires id, and should also repeat every field it wants to keep: an updatable field the entry omits may be cleared. Updatable fields: action, severity, description, source, tags \(array\), platforms \(array\), applied_globally \(boolean\), host_groups \(array\), expiration \(ISO 8601\), mobile_action, metadata \(\{ filename \}\). type and value cannot be changed. | | `comment` | string | No | Audit comment explaining why these indicators were updated | | `retrodetects` | boolean | No | Whether to generate retroactive detections for the updated indicators | diff --git a/apps/docs/content/docs/integrations/datadog.mdx b/apps/docs/content/docs/integrations/datadog.mdx index 93b13968ef8..b143fadca99 100644 --- a/apps/docs/content/docs/integrations/datadog.mdx +++ b/apps/docs/content/docs/integrations/datadog.mdx @@ -2,7 +2,7 @@ title: Datadog description: Monitor infrastructure, applications, and logs with Datadog integration: - type: "datadog" + type: "datadog_v2" color: "#632CA6" --- @@ -11,6 +11,8 @@ import { ReferenceDetails } from "@/components/ui/reference-details" {/* MANUAL-CONTENT-START:intro */} Use [Datadog](https://datadoghq.com/) to send or query telemetry, manage monitors and incidents, and inspect observability data from a workflow. The actions below also cover dashboards, SLOs, synthetic tests, and security signals. + +Connect Datadog once as a Datadog credential: choose your site, then add an API key and an application key from **Organization Settings > API Keys and Application Keys**. Sim checks the API key when you save the credential. The application key is checked by the first action that needs it, so a key scoped to only the permissions your workflows use works. Workflows built with the earlier Datadog block keep the keys and site they entered in the block. {/* MANUAL-CONTENT-END */} ## Use the block [#usage-instructions] @@ -30,8 +32,6 @@ Submit custom metrics to Datadog. Use for tracking application performance, busi | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `series` | string | Yes | JSON array of metric series to submit. Each entry needs "metric" and "points" \(objects with "timestamp" in POSIX seconds and a numeric "value"\); timestamps cannot be more than 10 minutes in the future or 1 hour in the past. Optional per entry: "type" \("count", "rate", or "gauge"; omit to let Datadog infer\), "interval" in seconds \(required by Datadog for count and rate\), "tags", "unit", "sourceTypeName", and "resources". | -| `apiKey` | string | Yes | Datadog API key | -| `site` | string | No | Datadog site/region \(default: datadoghq.com\) | #### Output @@ -55,9 +55,6 @@ Query metric timeseries data from Datadog. Use for analyzing trends, creating re | `query` | string | Yes | Datadog metrics query \(e.g., "avg:system.cpu.user\{*\}", "sum:nginx.requests\{env:prod\}.as_count\(\)"\) | | `from` | number | Yes | Start time as Unix timestamp in seconds \(e.g., 1705320000\) | | `to` | number | Yes | End time as Unix timestamp in seconds \(e.g., 1705323600\) | -| `apiKey` | string | Yes | Datadog API key | -| `applicationKey` | string | Yes | Datadog Application key | -| `site` | string | No | Datadog site/region \(default: datadoghq.com\) | #### Output @@ -92,8 +89,6 @@ Post an event to the Datadog event stream. Use for deployment notifications, ale | `aggregationKey` | string | No | Key to aggregate events together | | `sourceTypeName` | string | No | Source type name for the event | | `dateHappened` | number | No | Unix timestamp in seconds when the event occurred \(e.g., 1705320000, defaults to now\). Datadog limits this to events no older than 18 hours. | -| `apiKey` | string | Yes | Datadog API key | -| `site` | string | No | Datadog site/region \(default: datadoghq.com\) | #### Output @@ -129,9 +124,6 @@ Create a new monitor/alert in Datadog. Monitors can track metrics, service check | `tags` | string | No | Comma-separated list of tags | | `priority` | number | No | Monitor priority \(1-5, where 1 is highest\) | | `options` | string | No | JSON string of monitor options \(thresholds, notify_no_data, renotify_interval, etc.\) | -| `apiKey` | string | Yes | Datadog API key | -| `applicationKey` | string | Yes | Datadog Application key | -| `site` | string | No | Datadog site/region \(default: datadoghq.com\) | #### Output @@ -166,9 +158,6 @@ Retrieve details of a specific monitor by ID. | `monitorId` | string | Yes | The ID of the monitor to retrieve \(e.g., "12345678"\) | | `groupStates` | string | No | Comma-separated group states to include. Valid values are "all", "alert", "warn", and "no data" \(e.g., "alert,warn"\). | | `withDowntimes` | boolean | No | Include downtime data with the monitor | -| `apiKey` | string | Yes | Datadog API key | -| `applicationKey` | string | Yes | Datadog Application key | -| `site` | string | No | Datadog site/region \(default: datadoghq.com\) | #### Output @@ -207,9 +196,6 @@ List all monitors in Datadog with optional filtering by name, tags, or state. | `withDowntimes` | boolean | No | Include downtime data with monitors | | `page` | number | No | Page to start paginating from \(0-indexed, e.g., 0, 1, 2\). Datadog returns every monitor in the org without pagination when this is not specified, so set it to bound the response. Setting Page Size alone implies page 0. | | `pageSize` | number | No | Number of monitors per page \(e.g., 50, max: 1000\). Datadog only applies this when a page is specified — otherwise it returns all monitors with no page size limit — so setting this alone sends page 0. With a page but no page size, Datadog defaults to 100. | -| `apiKey` | string | Yes | Datadog API key | -| `applicationKey` | string | Yes | Datadog Application key | -| `site` | string | No | Datadog site/region \(default: datadoghq.com\) | #### Output @@ -244,9 +230,6 @@ Mute a monitor to temporarily suppress its notifications. Use Unmute Monitor to | `monitorId` | string | Yes | The ID of the monitor to mute \(e.g., "12345678"\) | | `scope` | string | No | Scope to mute \(e.g., "host:myhost", "env:prod"\). If not specified, mutes all scopes. | | `end` | number | No | Unix timestamp in seconds when the mute should end \(e.g., 1705323600\). If not specified, the monitor stays muted until it is unmuted. | -| `apiKey` | string | Yes | Datadog API key | -| `applicationKey` | string | Yes | Datadog Application key | -| `site` | string | No | Datadog site/region \(default: datadoghq.com\) | #### Output @@ -272,9 +255,6 @@ Unmute a monitor so it resumes sending notifications. Reverses Mute Monitor, eit | `monitorId` | string | Yes | The ID of the monitor to unmute \(e.g., "12345678"\) | | `scope` | string | No | Scope to unmute \(e.g., "host:myhost"\). Leave blank to unmute the monitor itself rather than a single scope. | | `allScopes` | boolean | No | Clear the mute settings for every scope on this monitor | -| `apiKey` | string | Yes | Datadog API key | -| `applicationKey` | string | Yes | Datadog Application key | -| `site` | string | No | Datadog site/region \(default: datadoghq.com\) | #### Output @@ -304,9 +284,6 @@ Search and retrieve logs from Datadog. Use for troubleshooting, analysis, or mon | `cursor` | string | No | Pagination cursor from a previous call, taken from its nextLogId output. Omit for the first page. | | `sort` | string | No | Sort order: "timestamp" for oldest first, "-timestamp" for newest first | | `indexes` | string | No | Comma-separated list of log indexes to search | -| `apiKey` | string | Yes | Datadog API key | -| `applicationKey` | string | Yes | Datadog Application key | -| `site` | string | No | Datadog site/region \(default: datadoghq.com\) | #### Output @@ -337,8 +314,6 @@ Send log entries to Datadog for centralized logging and analysis. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `logs` | string | Yes | JSON array of log entries. Each entry should have message and optionally ddsource, ddtags, hostname, service. Sim fills in ddsource="custom" when an entry omits it — that is a Sim default, not a Datadog one; set ddsource yourself to have Datadog apply the matching integration log pipeline. | -| `apiKey` | string | Yes | Datadog API key | -| `site` | string | No | Datadog site/region \(default: datadoghq.com\) | #### Output @@ -366,9 +341,6 @@ Schedule a downtime to suppress monitor notifications during maintenance windows | `monitorId` | string | No | Specific monitor ID to mute \(e.g., "12345678"\) | | `monitorTags` | string | No | Comma-separated monitor tags to match \(e.g., "team:backend,priority:high"\) | | `muteFirstRecoveryNotification` | boolean | No | Mute the first recovery notification | -| `apiKey` | string | Yes | Datadog API key | -| `applicationKey` | string | Yes | Datadog Application key | -| `site` | string | No | Datadog site/region \(default: datadoghq.com\) | #### Output @@ -400,9 +372,6 @@ List all scheduled downtimes in Datadog. | `currentOnly` | boolean | No | Only return currently active downtimes | | `limit` | number | No | Number of downtimes to return per page. Datadog defaults to 30 and declares no maximum; keep this at 100 or below to stay within the bound Sim recommends. | | `offset` | number | No | Index of the first downtime to return \(e.g., 0, 30, 60\) | -| `apiKey` | string | Yes | Datadog API key | -| `applicationKey` | string | Yes | Datadog Application key | -| `site` | string | No | Datadog site/region \(default: datadoghq.com\) | #### Output @@ -433,9 +402,6 @@ Cancel a scheduled downtime. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `downtimeId` | string | Yes | The ID of the downtime to cancel \(e.g., "abc123def456"\) | -| `apiKey` | string | Yes | Datadog API key | -| `applicationKey` | string | Yes | Datadog Application key | -| `site` | string | No | Datadog site/region \(default: datadoghq.com\) | #### Output @@ -458,9 +424,6 @@ List incidents for the organization. Requires the Incident Management `incident_ | `include` | string | No | Comma-separated related resources to include: "users" and/or "attachments" | | `pageSize` | number | No | Number of incidents to return per page \(default: 10, max: 100\) | | `pageOffset` | number | No | Index of the first incident to return \(e.g., 0, 10, 20\) | -| `apiKey` | string | Yes | Datadog API key | -| `applicationKey` | string | Yes | Datadog Application key | -| `site` | string | No | Datadog site/region \(default: datadoghq.com\) | #### Output @@ -494,9 +457,6 @@ Get the details of a single incident by ID. Requires the Incident Management `in | --------- | ---- | -------- | ----------- | | `incidentId` | string | Yes | The UUID of the incident \(e.g., "00000000-0000-0000-1234-000000000000"\) | | `include` | string | No | Comma-separated related resources to include \(e.g., "users", "attachments"\) | -| `apiKey` | string | Yes | Datadog API key | -| `applicationKey` | string | Yes | Datadog Application key | -| `site` | string | No | Datadog site/region \(default: datadoghq.com\) | #### Output @@ -537,9 +497,6 @@ Declare a new incident. Requires the Incident Management `incident_write` permis | `isTest` | boolean | No | Whether this is a test incident | | `fields` | string | No | JSON object of user-defined incident fields, e.g. \{"severity": \{"type": "dropdown", "value": "SEV-2"\}\} | | `notificationHandles` | string | No | Comma-separated handles to notify on creation \(e.g., "@slack-incidents,@user@example.com"\) | -| `apiKey` | string | Yes | Datadog API key | -| `applicationKey` | string | Yes | Datadog Application key | -| `site` | string | No | Datadog site/region \(default: datadoghq.com\) | #### Output @@ -577,9 +534,6 @@ Partially update an existing incident. Requires the Incident Management `inciden | `detected` | string | No | ISO-8601 timestamp when the incident was detected | | `fields` | string | No | JSON object of user-defined incident fields to update, e.g. \{"state": \{"type": "dropdown", "value": "resolved"\}\} | | `notificationHandles` | string | No | Comma-separated handles to notify about the update | -| `apiKey` | string | Yes | Datadog API key | -| `applicationKey` | string | Yes | Datadog Application key | -| `site` | string | No | Datadog site/region \(default: datadoghq.com\) | #### Output @@ -611,9 +565,6 @@ Add a follow-up task (todo) to an incident. Requires the Incident Management `in | `content` | string | Yes | The follow-up task content \(e.g., "Restore lost data"\) | | `assignees` | string | Yes | Comma-separated assignee handles \(e.g., "@jane@example.com,@on-call"\). Datadog requires at least one assignee | | `dueDate` | string | No | ISO-8601 timestamp for when the todo should be completed | -| `apiKey` | string | Yes | Datadog API key | -| `applicationKey` | string | Yes | Datadog Application key | -| `site` | string | No | Datadog site/region \(default: datadoghq.com\) | #### Output @@ -648,9 +599,6 @@ List service level objectives, optionally filtered by IDs, name, tags, or underl | `limit` | number | No | Number of SLOs to return \(default: 1000\) | | `offset` | number | No | Offset of the first SLO returned \(e.g., 0, 50\) | | `isDeleted` | boolean | No | Return only deleted SLOs | -| `apiKey` | string | Yes | Datadog API key | -| `applicationKey` | string | Yes | Datadog Application key | -| `site` | string | No | Datadog site/region \(default: datadoghq.com\) | #### Output @@ -684,9 +632,6 @@ Get the configuration of a single service level objective by ID. | --------- | ---- | -------- | ----------- | | `sloId` | string | Yes | The ID of the service level objective | | `withConfiguredAlertIds` | boolean | No | Include the IDs of SLO monitors that reference this SLO | -| `apiKey` | string | Yes | Datadog API key | -| `applicationKey` | string | Yes | Datadog Application key | -| `site` | string | No | Datadog site/region \(default: datadoghq.com\) | #### Output @@ -731,9 +676,6 @@ Create a service level objective from a metric query, monitors, or a time-slice | `targetThreshold` | number | No | Primary target threshold \(e.g., 99.9\) | | `warningThreshold` | number | No | Primary warning threshold, must be greater than the target \(e.g., 99.95\) | | `timeframe` | string | No | Primary timeframe: "7d", "30d", or "90d" | -| `apiKey` | string | Yes | Datadog API key | -| `applicationKey` | string | Yes | Datadog Application key | -| `site` | string | No | Datadog site/region \(default: datadoghq.com\) | #### Output @@ -773,9 +715,6 @@ Update a service level objective. Reads the current SLO first and applies only t | `targetThreshold` | number | No | Primary target threshold \(e.g., 99.9\) | | `warningThreshold` | number | No | Primary warning threshold, must be greater than the target \(e.g., 99.95\) | | `timeframe` | string | No | Primary timeframe: "7d", "30d", or "90d" | -| `apiKey` | string | Yes | Datadog API key | -| `applicationKey` | string | Yes | Datadog Application key | -| `site` | string | No | Datadog site/region \(default: datadoghq.com\) | #### Output @@ -804,9 +743,6 @@ Permanently delete a service level objective. Datadog returns a conflict when th | --------- | ---- | -------- | ----------- | | `sloId` | string | Yes | The ID of the service level objective to delete | | `force` | boolean | No | Delete even when the SLO is referenced by other resources | -| `apiKey` | string | Yes | Datadog API key | -| `applicationKey` | string | Yes | Datadog Application key | -| `site` | string | No | Datadog site/region \(default: datadoghq.com\) | #### Output @@ -832,9 +768,6 @@ Get an SLO’s history over a time window, including the overall SLI value and r | `toTs` | number | Yes | End of the query window as a Unix timestamp in seconds | | `target` | number | No | SLO target between 0 and 100. When supplied, the response includes the remaining error budget for a custom timeframe | | `applyCorrection` | boolean | No | Whether to apply SLO corrections \(defaults to true\) | -| `apiKey` | string | Yes | Datadog API key | -| `applicationKey` | string | Yes | Datadog Application key | -| `site` | string | No | Datadog site/region \(default: datadoghq.com\) | #### Output @@ -869,9 +802,6 @@ List custom created or cloned dashboards. Datadog preset dashboards are not retu | `filterDeleted` | boolean | No | Return only deleted dashboards. Incompatible with filterShared | | `count` | number | No | Maximum number of dashboards to return \(default: 100\) | | `start` | number | No | Offset of the first dashboard returned \(e.g., 0, 100\) | -| `apiKey` | string | Yes | Datadog API key | -| `applicationKey` | string | Yes | Datadog Application key | -| `site` | string | No | Datadog site/region \(default: datadoghq.com\) | #### Output @@ -901,9 +831,6 @@ Get the full definition of a dashboard, including its widgets. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `dashboardId` | string | Yes | The ID of the dashboard \(e.g., "abc-def-ghi"\) | -| `apiKey` | string | Yes | Datadog API key | -| `applicationKey` | string | Yes | Datadog Application key | -| `site` | string | No | Datadog site/region \(default: datadoghq.com\) | #### Output @@ -944,9 +871,6 @@ Create a dashboard from a title, layout type, and widget definitions. | `templateVariables` | string | No | JSON array of template variable definitions, e.g. \[\{"name": "env", "prefix": "env", "available_values": \["prod"\]\}\] | | `tags` | string | No | Comma-separated dashboard tags in the form "team:<name>" \(max 5\) | | `reflowType` | string | No | Reflow type for ordered layouts: "auto" or "fixed" | -| `apiKey` | string | Yes | Datadog API key | -| `applicationKey` | string | Yes | Datadog Application key | -| `site` | string | No | Datadog site/region \(default: datadoghq.com\) | #### Output @@ -975,9 +899,6 @@ Delete a dashboard by ID. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `dashboardId` | string | Yes | The ID of the dashboard to delete | -| `apiKey` | string | Yes | Datadog API key | -| `applicationKey` | string | Yes | Datadog Application key | -| `site` | string | No | Datadog site/region \(default: datadoghq.com\) | #### Output @@ -1000,9 +921,6 @@ List all Synthetic tests (API, browser, and mobile) with their current status. | --------- | ---- | -------- | ----------- | | `pageSize` | number | No | Number of tests returned per page \(default: 100\) | | `pageNumber` | number | No | Page to retrieve, starting at zero | -| `apiKey` | string | Yes | Datadog API key | -| `applicationKey` | string | Yes | Datadog Application key | -| `site` | string | No | Datadog site/region \(default: datadoghq.com\) | #### Output @@ -1032,9 +950,6 @@ Get the configuration of a Synthetic test by public ID. Browser test steps are n | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `publicId` | string | Yes | The public ID of the Synthetic test \(e.g., "abc-def-ghi"\) | -| `apiKey` | string | Yes | Datadog API key | -| `applicationKey` | string | Yes | Datadog Application key | -| `site` | string | No | Datadog site/region \(default: datadoghq.com\) | #### Output @@ -1070,9 +985,6 @@ Get the latest result summaries (up to the last 150 runs) for a Synthetic API te | `fromTs` | number | No | Timestamp in milliseconds from which to start querying results | | `toTs` | number | No | Timestamp in milliseconds up to which to query results | | `probeDc` | string | No | Comma-separated locations to query results for \(e.g., "aws:eu-west-3"\) | -| `apiKey` | string | Yes | Datadog API key | -| `applicationKey` | string | Yes | Datadog Application key | -| `site` | string | No | Datadog site/region \(default: datadoghq.com\) | #### Output @@ -1104,9 +1016,6 @@ Get the latest result summaries (up to the last 150 runs) for a Synthetic browse | `fromTs` | number | No | Timestamp in milliseconds from which to start querying results | | `toTs` | number | No | Timestamp in milliseconds up to which to query results | | `probeDc` | string | No | Comma-separated locations to query results for \(e.g., "aws:eu-west-3"\) | -| `apiKey` | string | Yes | Datadog API key | -| `applicationKey` | string | Yes | Datadog Application key | -| `site` | string | No | Datadog site/region \(default: datadoghq.com\) | #### Output @@ -1138,9 +1047,6 @@ Trigger an immediate run of one or more Synthetic tests by public ID. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `publicIds` | string | Yes | Comma-separated public IDs of the Synthetic tests to trigger | -| `apiKey` | string | Yes | Datadog API key | -| `applicationKey` | string | Yes | Datadog Application key | -| `site` | string | No | Datadog site/region \(default: datadoghq.com\) | #### Output @@ -1171,9 +1077,6 @@ Pause or resume a Synthetic test by setting its status to "paused" or "live". | --------- | ---- | -------- | ----------- | | `publicId` | string | Yes | The public ID of the Synthetic test to update | | `newStatus` | string | Yes | New status: "live" to start the test or "paused" to pause it | -| `apiKey` | string | Yes | Datadog API key | -| `applicationKey` | string | Yes | Datadog Application key | -| `site` | string | No | Datadog site/region \(default: datadoghq.com\) | #### Output @@ -1200,9 +1103,6 @@ Search Cloud SIEM security signals by query and time range. Requires the `securi | `sort` | string | No | Sort order: "timestamp" for oldest first, "-timestamp" for newest first | | `cursor` | string | No | Pagination cursor returned as nextCursor by a previous call | | `limit` | number | No | Maximum number of signals to return \(default: 10, max: 1000\) | -| `apiKey` | string | Yes | Datadog API key | -| `applicationKey` | string | Yes | Datadog Application key | -| `site` | string | No | Datadog site/region \(default: datadoghq.com\) | #### Output @@ -1231,9 +1131,6 @@ Get the details of a single Cloud SIEM security signal. Requires the `security_m | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `signalId` | string | Yes | The ID of the security signal | -| `apiKey` | string | Yes | Datadog API key | -| `applicationKey` | string | Yes | Datadog Application key | -| `site` | string | No | Datadog site/region \(default: datadoghq.com\) | #### Output @@ -1264,9 +1161,6 @@ Change the triage state of a Cloud SIEM security signal to open, under_review, o | `state` | string | Yes | New triage state: "open", "under_review", or "archived" | | `archiveReason` | string | No | Reason when archiving: none, false_positive, testing_or_maintenance, remediated, investigated_case_opened, true_positive_benign, true_positive_malicious, or other | | `archiveComment` | string | No | Comment explaining why the signal was archived | -| `apiKey` | string | Yes | Datadog API key | -| `applicationKey` | string | Yes | Datadog Application key | -| `site` | string | No | Datadog site/region \(default: datadoghq.com\) | #### Output @@ -1296,9 +1190,6 @@ Assign a Cloud SIEM security signal to a Datadog user by UUID. Requires the `sec | --------- | ---- | -------- | ----------- | | `signalId` | string | Yes | The ID of the security signal | | `assigneeUuid` | string | Yes | UUID of the Datadog user to assign the signal to \(e.g., "773b045d-ccf8-4808-bd3b-955ef6a8c940"\) | -| `apiKey` | string | Yes | Datadog API key | -| `applicationKey` | string | Yes | Datadog Application key | -| `site` | string | No | Datadog site/region \(default: datadoghq.com\) | #### Output @@ -1330,9 +1221,6 @@ List Cloud SIEM detection rules. Requires the `security_monitoring_rules_read` p | `sort` | string | No | Sort attribute, prefix with "-" for descending: name, creation_date, update_date, enabled, type, highest_severity, or source | | `pageSize` | number | No | Number of rules per page \(default: 10, max: 100\) | | `pageNumber` | number | No | Page to retrieve, starting at zero | -| `apiKey` | string | Yes | Datadog API key | -| `applicationKey` | string | Yes | Datadog Application key | -| `site` | string | No | Datadog site/region \(default: datadoghq.com\) | #### Output @@ -1367,9 +1255,6 @@ Search indexed APM spans using the span query syntax, with cursor pagination. | `sort` | string | No | Sort order: "timestamp" for oldest first, "-timestamp" for newest first | | `cursor` | string | No | Pagination cursor returned as nextCursor by a previous call | | `limit` | number | No | Maximum number of spans to return \(default: 10, max: 1000\) | -| `apiKey` | string | Yes | Datadog API key | -| `applicationKey` | string | Yes | Datadog Application key | -| `site` | string | No | Datadog site/region \(default: datadoghq.com\) | #### Output @@ -1409,9 +1294,6 @@ List service definitions from the Datadog Service Catalog. Requires the `apm_ser | `pageSize` | number | No | Number of service definitions per page \(default: 10, max: 100\) | | `pageNumber` | number | No | Page to retrieve, starting at zero | | `schemaVersion` | string | No | Schema version to return \(e.g., "v2", "v2.1", "v2.2"\) | -| `apiKey` | string | Yes | Datadog API key | -| `applicationKey` | string | Yes | Datadog Application key | -| `site` | string | No | Datadog site/region \(default: datadoghq.com\) | #### Output diff --git a/apps/docs/content/docs/integrations/gong.mdx b/apps/docs/content/docs/integrations/gong.mdx index 78c99697c27..b28db606ad6 100644 --- a/apps/docs/content/docs/integrations/gong.mdx +++ b/apps/docs/content/docs/integrations/gong.mdx @@ -2,7 +2,7 @@ title: Gong description: Read Gong data, upload calls, assign or unassign flow prospects, and request data erasure integration: - type: "gong" + type: "gong_v2" color: "#8039DF" --- @@ -12,7 +12,9 @@ import { ReferenceDetails } from "@/components/ui/reference-details" {/* MANUAL-CONTENT-START:intro */} Use [Gong](https://www.gong.io/) to list calls, read call details and transcripts, and retrieve topics, trackers, interaction statistics, and other extensive call data. The actions also read users, activity and coaching metrics, answered scorecards, scorecard definitions, tracker configuration, call-library folders and content, and Engage flows. -Look up an email address or phone number to find related calls, emails, meetings, CRM records, and engagement events. Connect your Gong API credentials and inspect the returned data when preparing coaching reports or syncing conversations to another system. +Look up an email address or phone number to find related calls, emails, meetings, CRM records, and engagement events, and inspect the returned data when preparing coaching reports or syncing conversations to another system. + +Connect Gong once as a Gong credential: a Gong technical administrator creates an access key and secret on the **API** page of the Gong company settings, then adds both. Sim calls `https://api.gong.io`; an account whose API page shows a different base URL is not supported by the credential yet. Workflows built with the earlier Gong block keep the access key and secret they entered in the block. **Create Call** uploads metadata and asks Gong to fetch the recording from a media URL. **Assign Flow Prospects** and **Unassign Flow Prospects** change a prospect’s Engage flow assignments. **Purge Email Address** and **Purge Phone Number** request deletion of associated Gong data. [Gong processes these deletions in the background](https://help.gong.io/docs/delete-personal-data); a returned request ID does not confirm completion. {/* MANUAL-CONTENT-END */} @@ -33,8 +35,6 @@ Retrieve call data by date range from Gong. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `accessKey` | string | Yes | Gong API Access Key | -| `accessKeySecret` | string | Yes | Gong API Access Key Secret | | `fromDateTime` | string | Yes | Start date/time in ISO-8601 format \(e.g., 2024-01-01T00:00:00Z\) | | `toDateTime` | string | No | End date/time in ISO-8601 format \(e.g., 2024-01-31T23:59:59Z\). Defaults to the current execution time when omitted. | | `cursor` | string | No | Pagination cursor from a previous response | @@ -83,8 +83,6 @@ Upload call metadata to Gong and let Gong pull the media from a URL. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `accessKey` | string | Yes | Gong API Access Key | -| `accessKeySecret` | string | Yes | Gong API Access Key Secret | | `clientUniqueId` | string | Yes | Unique call ID from the source telephony or recording system | | `actualStart` | string | Yes | Actual call start time in ISO-8601 format | | `primaryUser` | string | Yes | Gong user ID for the call's host or owner | @@ -117,8 +115,6 @@ Retrieve detailed data for a specific call from Gong. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `accessKey` | string | Yes | Gong API Access Key | -| `accessKeySecret` | string | Yes | Gong API Access Key Secret | | `callId` | string | Yes | The Gong call ID to retrieve | #### Output @@ -159,8 +155,6 @@ Retrieve transcripts of calls from Gong by call IDs or date range. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `accessKey` | string | Yes | Gong API Access Key | -| `accessKeySecret` | string | Yes | Gong API Access Key Secret | | `callIds` | string | No | Comma-separated list of call IDs to retrieve transcripts for | | `fromDateTime` | string | No | Start date/time filter in ISO-8601 format | | `toDateTime` | string | No | End date/time filter in ISO-8601 format | @@ -195,8 +189,6 @@ Retrieve detailed call data including trackers, topics, highlights, and AI spotl | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `accessKey` | string | Yes | Gong API Access Key | -| `accessKeySecret` | string | Yes | Gong API Access Key Secret | | `callIds` | string | No | Comma-separated list of call IDs to retrieve detailed data for | | `fromDateTime` | string | No | Start date/time filter in ISO-8601 format | | `toDateTime` | string | No | End date/time filter in ISO-8601 format | @@ -322,8 +314,6 @@ List all users in your Gong account. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `accessKey` | string | Yes | Gong API Access Key | -| `accessKeySecret` | string | Yes | Gong API Access Key Secret | | `cursor` | string | No | Pagination cursor from a previous response | | `includeAvatars` | string | No | Whether to include avatar URLs \(true/false\) | @@ -375,8 +365,6 @@ Retrieve details for a specific user from Gong. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `accessKey` | string | Yes | Gong API Access Key | -| `accessKeySecret` | string | Yes | Gong API Access Key Secret | | `userId` | string | Yes | The Gong user ID to retrieve | #### Output @@ -422,8 +410,6 @@ Retrieve aggregated activity statistics for users by date range from Gong. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `accessKey` | string | Yes | Gong API Access Key | -| `accessKeySecret` | string | Yes | Gong API Access Key Secret | | `userIds` | string | No | Comma-separated list of Gong user IDs \(up to 20 digits each\) | | `fromDate` | string | Yes | Start date in YYYY-MM-DD format \(inclusive, in company timezone\) | | `toDate` | string | Yes | End date in YYYY-MM-DD format \(exclusive, in company timezone, cannot exceed current day\) | @@ -469,8 +455,6 @@ Retrieve detailed day-by-day activity (call IDs per activity type) for users by | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `accessKey` | string | Yes | Gong API Access Key | -| `accessKeySecret` | string | Yes | Gong API Access Key Secret | | `userIds` | string | No | Comma-separated list of Gong user IDs \(up to 20 digits each\) | | `fromDate` | string | Yes | Start date in YYYY-MM-DD format \(inclusive, in company timezone\) | | `toDate` | string | Yes | End date in YYYY-MM-DD format \(exclusive, in company timezone, cannot exceed current day\) | @@ -516,8 +500,6 @@ Retrieve aggregated user activity grouped into time periods (day, week, month, q | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `accessKey` | string | Yes | Gong API Access Key | -| `accessKeySecret` | string | Yes | Gong API Access Key Secret | | `aggregationPeriod` | string | Yes | Calendar period to group activity by: DAY, WEEK, MONTH, QUARTER, or YEAR \(week starts Monday\) | | `userIds` | string | No | Comma-separated list of Gong user IDs \(up to 20 digits each\) | | `fromDate` | string | Yes | Start date in YYYY-MM-DD format \(inclusive, in company timezone\) | @@ -564,8 +546,6 @@ Retrieve interaction statistics for users by date range from Gong. Only includes | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `accessKey` | string | Yes | Gong API Access Key | -| `accessKeySecret` | string | Yes | Gong API Access Key Secret | | `userIds` | string | No | Comma-separated list of Gong user IDs \(up to 20 digits each\) | | `fromDate` | string | Yes | Start date in YYYY-MM-DD format \(inclusive, in company timezone\) | | `toDate` | string | Yes | End date in YYYY-MM-DD format \(exclusive, in company timezone, cannot exceed current day\) | @@ -599,8 +579,6 @@ Retrieve answered scorecards for reviewed users or by date range from Gong. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `accessKey` | string | Yes | Gong API Access Key | -| `accessKeySecret` | string | Yes | Gong API Access Key Secret | | `callFromDate` | string | No | Start date for calls in YYYY-MM-DD format \(inclusive, in company timezone\). Defaults to earliest recorded call. | | `callToDate` | string | No | End date for calls in YYYY-MM-DD format \(exclusive, in company timezone\). Defaults to latest recorded call. | | `reviewFromDate` | string | No | Start date for reviews in YYYY-MM-DD format \(inclusive, in company timezone\). Defaults to earliest reviewed call. | @@ -646,8 +624,6 @@ Retrieve library folders from Gong. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `accessKey` | string | Yes | Gong API Access Key | -| `accessKeySecret` | string | Yes | Gong API Access Key Secret | | `workspaceId` | string | No | Gong workspace ID to filter folders | #### Output @@ -674,8 +650,6 @@ Retrieve the list of calls in a specific library folder from Gong. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `accessKey` | string | Yes | Gong API Access Key | -| `accessKeySecret` | string | Yes | Gong API Access Key Secret | | `folderId` | string | No | The library folder ID to retrieve content for \(up to 20 digits\) | #### Output @@ -710,8 +684,6 @@ Retrieve scorecard definitions from Gong settings. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `accessKey` | string | Yes | Gong API Access Key | -| `accessKeySecret` | string | Yes | Gong API Access Key Secret | #### Output @@ -752,8 +724,6 @@ Retrieve smart tracker and keyword tracker definitions from Gong settings. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `accessKey` | string | Yes | Gong API Access Key | -| `accessKeySecret` | string | Yes | Gong API Access Key Secret | | `workspaceId` | string | No | The ID of the workspace the keyword trackers are in. When empty, all trackers in all workspaces are returned. | #### Output @@ -793,8 +763,6 @@ List all company workspaces in Gong. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `accessKey` | string | Yes | Gong API Access Key | -| `accessKeySecret` | string | Yes | Gong API Access Key Secret | #### Output @@ -818,8 +786,6 @@ List Gong Engage flows (sales engagement sequences). | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `accessKey` | string | Yes | Gong API Access Key | -| `accessKeySecret` | string | Yes | Gong API Access Key Secret | | `flowOwnerEmail` | string | Yes | Email of a Gong user. The API will return 'PERSONAL' flows belonging to this user in addition to 'COMPANY' flows. | | `workspaceId` | string | No | Optional workspace ID to filter flows to a specific workspace | | `cursor` | string | No | Pagination cursor from a previous API call to retrieve the next page of records | @@ -854,8 +820,6 @@ Assign up to 200 CRM prospects (contacts or leads) to a Gong Engage flow. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `accessKey` | string | Yes | Gong API Access Key | -| `accessKeySecret` | string | Yes | Gong API Access Key Secret | | `flowId` | string | Yes | The Gong Engage flow ID to assign the prospects to | | `crmProspectsIds` | string | Yes | Comma-separated list of CRM prospect IDs \(contacts or leads\) to assign | | `flowInstanceOwnerEmail` | string | Yes | Email of the Gong user who owns the flow instance and its to-dos | @@ -894,8 +858,6 @@ Remove a prospect from Gong Engage flows. Omit the flow ID to remove the prospec | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `accessKey` | string | Yes | Gong API Access Key | -| `accessKeySecret` | string | Yes | Gong API Access Key Secret | | `crmProspectId` | string | Yes | The CRM ID of the prospect to unassign | | `flowId` | string | No | The ID of the flow to unassign the prospect from. If omitted, the prospect is removed from all flows they are assigned to. | | `unassignedByUserEmail` | string | No | Email address of the Gong user requesting to remove the prospect from the flow | @@ -919,8 +881,6 @@ Get the Gong Engage flows currently assigned to the given CRM prospects. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `accessKey` | string | Yes | Gong API Access Key | -| `accessKeySecret` | string | Yes | Gong API Access Key Secret | | `crmProspectsIds` | string | Yes | Comma-separated list of CRM prospect IDs \(contacts or leads\) to look up | #### Output @@ -952,8 +912,6 @@ Retrieve coaching metrics for a manager from Gong. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `accessKey` | string | Yes | Gong API Access Key | -| `accessKeySecret` | string | Yes | Gong API Access Key Secret | | `managerId` | string | Yes | Gong user ID of the manager | | `workspaceId` | string | Yes | Gong workspace ID | | `fromDate` | string | Yes | Start date in ISO-8601 format | @@ -992,8 +950,6 @@ Ask a natural-language question about a CRM account, deal, contact, or lead. Gon | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `accessKey` | string | Yes | Gong API Access Key | -| `accessKeySecret` | string | Yes | Gong API Access Key Secret | | `workspaceId` | string | Yes | Gong workspace ID the entity belongs to | | `crmEntityType` | string | Yes | Type of the CRM entity: ACCOUNT, CONTACT, DEAL, or LEAD | | `crmEntityId` | string | Yes | The CRM ID of the entity the question is asked about | @@ -1026,8 +982,6 @@ Generate an AI brief (configured in Gong Agent Studio) for a CRM account, deal, | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `accessKey` | string | Yes | Gong API Access Key | -| `accessKeySecret` | string | Yes | Gong API Access Key Secret | | `workspaceId` | string | Yes | Gong workspace ID the entity belongs to | | `briefName` | string | Yes | Name of the brief to generate, as configured in Gong Agent Studio > AI Briefer | | `crmEntityType` | string | Yes | Type of the CRM entity: ACCOUNT, CONTACT, DEAL, or LEAD | @@ -1063,8 +1017,6 @@ Retrieve Gong log entries of a specific type within a time range. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `accessKey` | string | Yes | Gong API Access Key | -| `accessKeySecret` | string | Yes | Gong API Access Key Secret | | `logType` | string | Yes | Type of logs requested: AccessLog, UserActivityLog, UserCallPlay, ExternallySharedCallAccess, or ExternallySharedCallPlay | | `fromDateTime` | string | Yes | Time from which to retrieve log records, in ISO-8601 format \(e.g., '2024-01-01T00:00:00Z'\) | | `toDateTime` | string | No | Time until which to retrieve log records, in ISO-8601 format. Defaults to the latest available logs when omitted. | @@ -1102,8 +1054,6 @@ Find all references to an email address in Gong (calls, email messages, meetings | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `accessKey` | string | Yes | Gong API Access Key | -| `accessKeySecret` | string | Yes | Gong API Access Key Secret | | `emailAddress` | string | Yes | Email address to look up | #### Output @@ -1158,8 +1108,6 @@ Find all references to a phone number in Gong (calls, email messages, meetings, | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `accessKey` | string | Yes | Gong API Access Key | -| `accessKeySecret` | string | Yes | Gong API Access Key Secret | | `phoneNumber` | string | Yes | Phone number to look up \(must start with + followed by country code\) | #### Output @@ -1209,8 +1157,6 @@ Erase all Gong data (calls, email messages, leads, contacts) referencing an emai | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `accessKey` | string | Yes | Gong API Access Key | -| `accessKeySecret` | string | Yes | Gong API Access Key Secret | | `emailAddress` | string | Yes | Email address whose associated data should be permanently erased from Gong | #### Output @@ -1231,8 +1177,6 @@ Erase all Gong data (calls, leads, contacts) referencing a phone number. Asynchr | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `accessKey` | string | Yes | Gong API Access Key | -| `accessKeySecret` | string | Yes | Gong API Access Key Secret | | `phoneNumber` | string | Yes | Phone number whose associated data should be permanently erased from Gong. Must include a leading "+" and country code \(e.g., +14255552671\) | #### Output diff --git a/apps/docs/content/docs/integrations/google-service-account.mdx b/apps/docs/content/docs/integrations/google-service-account.mdx index 5a2e8d86514..4fcb62b7ba2 100644 --- a/apps/docs/content/docs/integrations/google-service-account.mdx +++ b/apps/docs/content/docs/integrations/google-service-account.mdx @@ -180,7 +180,7 @@ A credential can serve multiple supported Google services when their APIs and sc A Google service account credential can also be **keyless**: Sim stores no key. Instead, your Google Cloud project trusts Sim through [Workload Identity Federation](https://cloud.google.com/iam/docs/workload-identity-federation-with-other-providers). On each run, Sim signs a short-lived token for this credential's own subject, exchanges it at Google's Security Token Service for a federated token, and uses that to impersonate your service account. The service account's IAM roles decide what workflows can do, exactly as with a JSON key. -Keyless credentials work with Google Cloud APIs that the service account calls as itself: **BigQuery**, and **Vertex AI** wherever a Google service account credential is accepted. They can't use domain-wide delegation, so Gmail, Drive, Sheets, Calendar, and other Google Workspace blocks don't offer them, and a keyless credential refuses an **Impersonated Account**. Use a JSON key for those. +Keyless credentials work with Google Cloud APIs that the service account calls as itself: **BigQuery**, and **Vertex AI** in every block that runs a Vertex model (pick the credential in its **Google Cloud Account** field). They can't use domain-wide delegation, so Gmail, Drive, Sheets, Calendar, and other Google Workspace blocks don't offer them, and a keyless credential refuses an **Impersonated Account**. Use a JSON key for those. diff --git a/apps/docs/content/docs/integrations/hubspot-service-account.mdx b/apps/docs/content/docs/integrations/hubspot-service-account.mdx index a142b0a62bb..9c4e66301d6 100644 --- a/apps/docs/content/docs/integrations/hubspot-service-account.mdx +++ b/apps/docs/content/docs/integrations/hubspot-service-account.mdx @@ -1,22 +1,43 @@ --- -title: HubSpot private app tokens -description: Set up a HubSpot private app with a scoped access token to use HubSpot in Sim workflows +title: HubSpot service keys and private app tokens +description: Connect HubSpot to Sim with a scoped service key or an existing private app access token --- import { Callout } from '@/components/ui/callout' import { Step, Steps } from '@/components/ui/steps' -Connect HubSpot with a private app access token created by a super admin. Select the CRM scopes your workflows need and keep the creating admin's account permissions in mind when managing the app. +Connect HubSpot with a service key, or with the access token of an existing legacy private app. Both are scoped Bearer credentials for your HubSpot account: select the CRM scopes your workflows need when you create one. ## Legacy app availability -HubSpot [ended legacy private-app creation](https://developers.hubspot.com/changelog/legacy-private-app-creation-sunset) on September 28, 2026 for accounts created on or after that date. Creation ends on October 26, 2026 for older accounts. Existing legacy private apps continue to work; the creation steps below apply only while the option remains available in your account. +HubSpot [ended legacy private-app creation](https://developers.hubspot.com/changelog/legacy-private-app-creation-sunset) on September 28, 2026 for accounts created on or after that date, and on October 26, 2026 for older accounts. Existing legacy private apps continue to work, so the private app steps below are for finding and managing an app you already have. -For a new Sim connection when that option is unavailable, use [HubSpot OAuth](/integrations/hubspot-setup). HubSpot recommends Service Keys for new direct integrations, but this guide does not establish that those keys are compatible with Sim’s private-app-token connection. +For a new connection, create a [service key](https://developers.hubspot.com/docs/apps/developer-platform/build-apps/authentication/account-service-keys), which HubSpot recommends for new direct integrations, or use [HubSpot OAuth](/integrations/hubspot-setup). Sim calls HubSpot's REST APIs with a service key as a Bearer token, exactly as it does with a private app token. -## Prerequisites +## Create a service key [#creating-a-service-key] + +Super admins and users with the **Developer tools access** permission can create service keys. + + + + In HubSpot, navigate to **Development**, then **Keys** → **Service keys**. In some accounts the page is under **Settings** → **Integrations** → **Service keys** instead. + + + Click **Create service key** and give it a name (e.g. `sim-hubspot`) + + + Click **Add new scope** and select the scopes your workflows need (see [Choose scopes](#2-choose-scopes)), then click **Create** and confirm + + + Open the key, click **Show**, then **Copy**. Continue at [Add the key to Sim](#adding-the-private-app-token-to-sim) + + + +A service key works only for REST API calls. If you turn on automatic rotation, HubSpot replaces the key every 3, 6, or 12 months and emails admins before the old key expires; paste the new key into the credential in Sim each time. + +## Prerequisites for a private app [#prerequisites] You need a HubSpot **super admin** to create the private app. Private apps can only be created and managed by super admins in the portal. @@ -47,7 +68,7 @@ The token is tied to the super admin who created the private app. If that user i ### 2. Choose scopes [#2-choose-scopes] -Private apps use the same scope catalog as HubSpot OAuth apps. Grant only what your workflows use. The full set Sim's HubSpot tools can draw on is: +Service keys and private apps use the same scope catalog as HubSpot OAuth apps. Grant only what your workflows use. The full set Sim's HubSpot tools can draw on is: **CRM objects (read/write as needed):** ``` @@ -76,7 +97,7 @@ crm.lists.read crm.lists.write ``` -Ticket tools need `tickets`, email tools need `sales-email-read`, list tools need `crm.lists.*`, and each object type's tools need its `crm.objects.*` scope. Notes and association tools need the CRM object scopes of the records involved. A missing scope surfaces at run time as a `403` error with category `MISSING_SCOPES` naming the scopes required — widen the app's scopes from the same Scopes tab, commit the change in HubSpot, and re-run the workflow. If the `403` persists, rotate the token and update the credential in Sim. +Ticket tools need `tickets`, email tools need `sales-email-read`, list tools need `crm.lists.*`, and each object type's tools need its `crm.objects.*` scope. Notes and association tools need the CRM object scopes of the records involved. A missing scope surfaces at run time as a `403` error with category `MISSING_SCOPES` naming the scopes required — widen the key's or app's scopes in HubSpot, save the change, and re-run the workflow. If the `403` persists, rotate the token and update the credential in Sim. Sim's OAuth flow also requests an `oauth` scope — that one is OAuth-app-only and doesn't exist for private apps. You don't need it and can't grant it. @@ -88,37 +109,37 @@ The token looks like `pat-na1-…` (North America) or `pat-eu1-…` (EU data res The access token is bearer credentials for your entire portal, limited only by its scopes. Treat it like a password — do not commit it to source control or share it publicly. Sim encrypts the token at rest. -## Add the private app token to Sim [#adding-the-private-app-token-to-sim] +## Add the key to Sim [#adding-the-private-app-token-to-sim] Open **Integrations** from your workspace sidebar - Search for "HubSpot" and open it, then click **Add to Sim** and choose **Add private app token** + Search for "HubSpot" and open it, then click **Add to Sim** and choose **Add service key** - Paste the **Private app access token**, and optionally set a display name and description + Paste the service key or private app token into **Service key or private app token**, and optionally set a display name and description - Click **Add private app token**. Sim checks HubSpot’s token-info endpoint and, for ambiguous responses, its Account Information API — if it fails, you'll see a specific error explaining what went wrong. + Click **Add service key**. Sim checks HubSpot’s token-info endpoint and, when that endpoint doesn't recognize the key, its Account Information API — if it fails, you'll see a specific error explaining what went wrong. ## Use the credential in workflows [#using-the-credential-in-workflows] -Add a HubSpot block or the HubSpot CRM Trigger to your workflow. In the credential dropdown, select the saved HubSpot private app token. Configure the operation and its required fields. Private app tokens also work with the polling trigger. If the creating admin leaves or loses permissions, follow the account-management guidance above. +Add a HubSpot block or the HubSpot CRM Trigger to your workflow. In the credential dropdown, select the saved HubSpot credential. Configure the operation and its required fields. Service keys and private app tokens also work with the polling trigger. If the creating admin leaves or loses permissions, follow the account-management guidance above. -The block calls `api.hubapi.com` using the private app token — the same API requests used by the OAuth connection. Availability depends on the app’s scopes and account access. +The block calls `api.hubapi.com` using the key — the same API requests used by the OAuth connection. Availability depends on the key's scopes and account access. ## Rotate the token [#rotating-the-token] -Private app tokens don't auto-expire, but HubSpot recommends rotating them every six months and emails super admins once a token hasn't rotated in about 180 days. From the app's Auth tab you can: +Service keys and private app tokens don't expire unless rotated, but HubSpot recommends rotating them every six months. From the service key's page, or the private app's Auth tab, you can: -- **Rotate and expire now** — the old token dies immediately -- **Rotate and expire later** — the old token keeps working for 7 days while you migrate +- **Rotate and expire now**: the old key stops working immediately +- **Rotate and expire later**: the old key keeps working for 7 days while you migrate -Prefer the 7-day option: rotate in HubSpot, paste the new token into the credential in Sim, confirm your workflows run, and let the old token lapse. +Prefer the 7-day option: rotate in HubSpot, paste the new key into the credential in Sim, confirm your workflows run, and let the old key lapse. ## Handle permission failures diff --git a/apps/docs/content/docs/integrations/hubspot-setup.mdx b/apps/docs/content/docs/integrations/hubspot-setup.mdx index 0277b318055..ae371682814 100644 --- a/apps/docs/content/docs/integrations/hubspot-setup.mdx +++ b/apps/docs/content/docs/integrations/hubspot-setup.mdx @@ -24,7 +24,7 @@ You need: 1. Log in to Sim, open your workspace, and click **Integrations** in the sidebar. 2. Search for **HubSpot** and open it. -3. Click **Add to Sim** and choose **Connect with OAuth**. For a private app token, follow the [Private App Tokens guide](/integrations/hubspot-service-account). +3. Click **Add to Sim** and choose **Connect with OAuth**. For a service key or an existing private app token, follow the [service keys and private app tokens guide](/integrations/hubspot-service-account). + + In Linear, create or edit the OAuth app under **Settings** → **API** → **OAuth applications** and turn on **Client credentials tokens** + + + In Sim, add the Linear credential, set **Authentication** to **OAuth app (client credentials)**, and paste the app's client ID and client secret + + + Save. Sim requests a `read,write` token from Linear and checks it with a `viewer` and `organization` query before saving. Credentials whose token has no viewer are named after the workspace + + + + +Use a dedicated OAuth app for Sim. Requesting a client credentials token **replaces an existing authorization-code installation of the same app** in that workspace; revoke that authorization first if you rely on it. A client credentials request for the app with **different scopes** replaces its existing tokens, so another service using the same app revokes Sim's tokens and Sim's requests (always `read,write`) revoke theirs. Rotating the client secret immediately invalidates every client credentials token, so reconnect the Sim credential with the new secret right away. Tokens last 30 days and cannot be refreshed; Sim shares one token across all of its credentials for the app, replaces it daily, and requests a new one as soon as Linear rejects it. + + ## Use the credential in workflows [#using-the-credential-in-workflows] Add a Linear block to your workflow. In the credential dropdown, select the saved Linear API key. Configure the operation and its required fields. diff --git a/apps/docs/content/docs/integrations/microsoft-app-credentials.mdx b/apps/docs/content/docs/integrations/microsoft-app-credentials.mdx index 4e91652bc1d..40895257e5d 100644 --- a/apps/docs/content/docs/integrations/microsoft-app-credentials.mdx +++ b/apps/docs/content/docs/integrations/microsoft-app-credentials.mdx @@ -1,12 +1,12 @@ --- title: Microsoft app credentials -description: Run Microsoft Entra ID, SharePoint, Dataverse, and Dynamics 365 blocks as an app registration with a client secret, a certificate, or a keyless federated credential that trusts Sim +description: Run Microsoft Entra ID, SharePoint, Intune, Dataverse, and Dynamics 365 blocks as an app registration with a client secret, a certificate, or a keyless federated credential that trusts Sim --- import { Callout } from '@/components/ui/callout' import { Step, Steps } from '@/components/ui/steps' -A Microsoft app credential signs in as a Microsoft Entra app registration instead of a person (the OAuth 2.0 client credentials flow). One credential works for the **Azure AD** (Microsoft Entra ID), **SharePoint**, **Microsoft Dataverse**, and **Dynamics 365** blocks. Connecting a Microsoft account through OAuth keeps working exactly as before; the app credential is an additional option in the same account picker. +A Microsoft app credential signs in as a Microsoft Entra app registration instead of a person (the OAuth 2.0 client credentials flow). One credential works for the **Azure AD** (Microsoft Entra ID), **SharePoint**, **Microsoft Intune**, **Microsoft Dataverse**, and **Dynamics 365** blocks. Connecting a Microsoft account through OAuth keeps working exactly as before; the app credential is an additional option in the same account picker. Outlook, OneDrive, Excel, Word, Planner, and Teams act on behalf of a signed-in person and don't accept an app credential. The Azure AD **Reset password** operation also needs a signed-in admin, because Microsoft Graph doesn't support application permissions for it. @@ -20,7 +20,7 @@ Outlook, OneDrive, Excel, Word, Planner, and Teams act on behalf of a signed-in | **Client secret** | The tenant ID, client ID, and client secret | Quick setup. Secrets expire, so plan to rotate them. | | **Certificate** | The tenant ID, client ID, the certificate, and its private key | When your organization requires certificate credentials. Sim signs each token request with the key. | -Every option resolves on Sim's servers at run time, and tokens never appear in block outputs, logs, or anything a model sees. Azure AD and SharePoint blocks get a Microsoft Graph token; Dataverse and Dynamics 365 blocks get a token for the credential's own environment. +Every option resolves on Sim's servers at run time, and tokens never appear in block outputs, logs, or anything a model sees. Azure AD, SharePoint, and Intune blocks get a Microsoft Graph token; Dataverse and Dynamics 365 blocks get a token for the credential's own environment. ## Register the app and grant permissions @@ -32,6 +32,7 @@ Every option resolves on Sim's servers at run time, and tokens never appear in b Under **API permissions** → **Add a permission** → **Microsoft Graph** → **Application permissions**, add what your workflows need, then select **Grant admin consent**. Application permissions always need admin consent. For example: - Azure AD: `User.Read.All` or `User.ReadWrite.All`, `Group.ReadWrite.All`, `GroupMember.ReadWrite.All`, `AuditLog.Read.All` for sign-in and audit logs, `Policy.Read.All` for conditional access, `UserAuthenticationMethod.Read.All` for authentication methods, `User.RevokeSessions.All` to revoke sessions. Setting a password needs `User-PasswordProfile.ReadWrite.All` **and** the app assigned at least the User Administrator role. - SharePoint: `Sites.Read.All` or `Sites.ReadWrite.All`. Creating and editing pages needs `Sites.ReadWrite.All`. `Sites.Selected` limits the app to sites you grant it, but searching for sites doesn't work with it, so enter site IDs directly. + - Intune: `DeviceManagementManagedDevices.Read.All` for devices and detected apps, `DeviceManagementConfiguration.Read.All` for compliance policies, their device statuses, and device configurations, and `DeviceManagementManagedDevices.PrivilegedOperations.All` to sync, restart, remotely lock, or retire a device. The tenant needs an active Intune license. Dataverse and Dynamics 365 don't use API permissions. Instead, in the [Power Platform admin center](https://admin.powerplatform.microsoft.com), open the environment → **Settings** → **Users + permissions** → **Application users** → **New app user**, add this app, pick a business unit, and assign a security role. Then enter the environment URL (like `https://contoso.crm.dynamics.com`) on the credential. diff --git a/apps/docs/content/docs/integrations/planetscale.mdx b/apps/docs/content/docs/integrations/planetscale.mdx index f6589c52722..6476bdd4f59 100644 --- a/apps/docs/content/docs/integrations/planetscale.mdx +++ b/apps/docs/content/docs/integrations/planetscale.mdx @@ -2,7 +2,7 @@ title: PlanetScale description: Manage PlanetScale databases, branches, backups, and deploy requests integration: - type: "planetscale" + type: "planetscale_v2" color: "#111111" --- @@ -12,7 +12,7 @@ import { ReferenceDetails } from "@/components/ui/reference-details" {/* MANUAL-CONTENT-START:intro */} Use [PlanetScale](https://planetscale.com/) to inspect databases and branches, create or delete branches, restore backups into a new branch, and create or inspect backups. Vitess deploy-request actions create, review, approve, queue, or close schema-change requests. -Create an organization service token with access to the databases and actions your workflow needs. Enter the service token ID, token, and organization slug in the block. Deploy requests are specific to Vitess; use the MySQL or PostgreSQL block to execute SQL. +Connect PlanetScale once as a PlanetScale credential: create a service token on your organization's **Service tokens** page, grant it access to the databases and actions your workflows need, then add its ID and token. The organization slug stays in the block. Workflows built with the earlier PlanetScale block keep the service token ID and token they entered in the block. Deploy requests are specific to Vitess; use the MySQL or PostgreSQL block to execute SQL. {/* MANUAL-CONTENT-END */} ## Use the block [#usage-instructions] @@ -31,8 +31,6 @@ List databases | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `serviceTokenId` | string | Yes | PlanetScale service token ID | -| `serviceToken` | string | Yes | PlanetScale service token secret | | `organization` | string | Yes | PlanetScale organization slug | | `q` | string | No | Search term to filter databases by name | | `page` | number | No | If provided, specifies the page offset of returned results | @@ -74,8 +72,6 @@ Get a database | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `serviceTokenId` | string | Yes | PlanetScale service token ID | -| `serviceToken` | string | Yes | PlanetScale service token secret | | `organization` | string | Yes | PlanetScale organization slug | | `database` | string | Yes | Database name slug from `list_databases`. Example: `app-db`. | @@ -109,8 +105,6 @@ List branches | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `serviceTokenId` | string | Yes | PlanetScale service token ID | -| `serviceToken` | string | Yes | PlanetScale service token secret | | `organization` | string | Yes | PlanetScale organization slug | | `database` | string | Yes | Database name slug from `list_databases`. Example: `app-db`. | | `q` | string | No | Search branches by name | @@ -156,8 +150,6 @@ Get a branch | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `serviceTokenId` | string | Yes | PlanetScale service token ID | -| `serviceToken` | string | Yes | PlanetScale service token secret | | `organization` | string | Yes | PlanetScale organization slug | | `database` | string | Yes | Database name slug from `list_databases`. Example: `app-db`. | | `branch` | string | Yes | Branch name from `list_branches`. Example: `main`. | @@ -192,8 +184,6 @@ Create a branch | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `serviceTokenId` | string | Yes | PlanetScale service token ID | -| `serviceToken` | string | Yes | PlanetScale service token secret | | `organization` | string | Yes | PlanetScale organization slug | | `database` | string | Yes | The name of the database the branch belongs to | | `name` | string | Yes | The name of the branch to create | @@ -237,8 +227,6 @@ Delete a branch | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `serviceTokenId` | string | Yes | PlanetScale service token ID | -| `serviceToken` | string | Yes | PlanetScale service token secret | | `organization` | string | Yes | PlanetScale organization slug | | `database` | string | Yes | The name of the database the branch belongs to | | `branch` | string | Yes | The name of the branch | @@ -262,8 +250,6 @@ List backups | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `serviceTokenId` | string | Yes | PlanetScale service token ID | -| `serviceToken` | string | Yes | PlanetScale service token secret | | `organization` | string | Yes | PlanetScale organization slug | | `database` | string | Yes | The name of the database the branch belongs to | | `branch` | string | Yes | The name of the branch | @@ -312,8 +298,6 @@ Get a backup | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `serviceTokenId` | string | Yes | PlanetScale service token ID | -| `serviceToken` | string | Yes | PlanetScale service token secret | | `organization` | string | Yes | PlanetScale organization slug | | `backupId` | string | Yes | The ID for the backup | | `database` | string | Yes | The name of the database the branch belongs to | @@ -348,8 +332,6 @@ Create a backup | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `serviceTokenId` | string | Yes | PlanetScale service token ID | -| `serviceToken` | string | Yes | PlanetScale service token secret | | `organization` | string | Yes | PlanetScale organization slug | | `database` | string | Yes | The name of the database the branch belongs to | | `branch` | string | Yes | The name of the branch | @@ -387,8 +369,6 @@ List deploy requests (Vitess only) | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `serviceTokenId` | string | Yes | PlanetScale service token ID | -| `serviceToken` | string | Yes | PlanetScale service token secret | | `organization` | string | Yes | PlanetScale organization slug | | `database` | string | Yes | The name of the deploy request's database | | `state` | string | No | Filter by state of the deploy request \(open, closed, deployed\) | @@ -437,8 +417,6 @@ Get a deploy request (Vitess only) | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `serviceTokenId` | string | Yes | PlanetScale service token ID | -| `serviceToken` | string | Yes | PlanetScale service token secret | | `organization` | string | Yes | PlanetScale organization slug | | `database` | string | Yes | The name of the deploy request's database | | `deployRequestNumber` | number | Yes | The number of the deploy request | @@ -475,8 +453,6 @@ Create a deploy request (Vitess only) | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `serviceTokenId` | string | Yes | PlanetScale service token ID | -| `serviceToken` | string | Yes | PlanetScale service token secret | | `organization` | string | Yes | PlanetScale organization slug | | `database` | string | Yes | The name of the deploy request's database | | `branch` | string | Yes | The name of the branch the deploy request is created from | @@ -517,8 +493,6 @@ Review a deploy request (Vitess only) | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `serviceTokenId` | string | Yes | PlanetScale service token ID | -| `serviceToken` | string | Yes | PlanetScale service token secret | | `organization` | string | Yes | PlanetScale organization slug | | `database` | string | Yes | The name of the database the deploy request belongs to | | `deployRequestNumber` | number | Yes | The number of the deploy request | @@ -548,8 +522,6 @@ Queue a deploy request (Vitess only) | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `serviceTokenId` | string | Yes | PlanetScale service token ID | -| `serviceToken` | string | Yes | PlanetScale service token secret | | `organization` | string | Yes | PlanetScale organization slug | | `database` | string | Yes | The name of the deploy request's database | | `deployRequestNumber` | number | Yes | The number of the deploy request | @@ -587,8 +559,6 @@ Close a deploy request (Vitess only) | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `serviceTokenId` | string | Yes | PlanetScale service token ID | -| `serviceToken` | string | Yes | PlanetScale service token secret | | `organization` | string | Yes | PlanetScale organization slug | | `database` | string | Yes | The name of the deploy request's database | | `deployRequestNumber` | number | Yes | The number of the deploy request | diff --git a/apps/docs/content/docs/integrations/sailpoint.mdx b/apps/docs/content/docs/integrations/sailpoint.mdx index fdafe235639..a98b62309b8 100644 --- a/apps/docs/content/docs/integrations/sailpoint.mdx +++ b/apps/docs/content/docs/integrations/sailpoint.mdx @@ -2,7 +2,7 @@ title: SailPoint description: Govern identities and access in SailPoint Identity Security Cloud integration: - type: "sailpoint" + type: "sailpoint_v2" color: "#FFFFFF" --- @@ -10,7 +10,9 @@ import { ReferenceDetails } from "@/components/ui/reference-details" {/* MANUAL-CONTENT-START:intro */} -The SailPoint integration connects Sim workflows to SailPoint Identity Security Cloud (ISC) with a Personal Access Token (PAT). Enter the tenant name from your ISC URL—or the full `*.api.identitynow.com` / `*.api.identitynowgov.com` host—plus the PAT client ID and client secret. Sim exchanges those credentials at the tenant's `/oauth/token` endpoint and calls SailPoint's current service-versioned endpoints, such as `/identities/v1`, `/access-requests/v1`, and `/certifications/v1`. There is no global API-version setting. +The SailPoint integration connects Sim workflows to SailPoint Identity Security Cloud (ISC) with a Personal Access Token (PAT) or an API client that has the client credentials grant. Connect it once as a SailPoint credential: enter the tenant name from your ISC URL (or its `*.api.identitynow.com` host) plus the client ID and client secret. Sim checks the credential by exchanging it at the tenant's `/oauth/token` endpoint when you save it, then calls SailPoint's current service-versioned endpoints, such as `/identities/v1`, `/access-requests/v1`, and `/certifications/v1`. There is no global API-version setting. + +Government tenants enter their `*.api.identitynowgov.com` host. Workflows built with the earlier SailPoint block keep the tenant, client ID, and client secret they entered in the block. ## Permissions @@ -37,7 +39,7 @@ The 40 actions cover six connected workflows: search and entity lookup; account, ## Use the block [#usage-instructions] -Connect SailPoint Identity Security Cloud with a Personal Access Token to search identity-governance data, manage access requests and approvals, review certifications, and import source data. The token owner and scopes determine the operations available to the integration. +Connect SailPoint Identity Security Cloud with a Personal Access Token or a client-credentials API client to search identity-governance data, manage access requests and approvals, review certifications, and import source data. The token owner and scopes determine the operations available to the integration. ## Actions @@ -51,9 +53,6 @@ Approve one pending access-request approval. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | SailPoint Personal Access Token client ID | -| `clientSecret` | string | Yes | SailPoint Personal Access Token client secret | -| `tenant` | string | Yes | SailPoint tenant name or full *.api.identitynow.com / *.api.identitynowgov.com host | | `approvalId` | string | Yes | Approval ID | | `comment` | string | No | Optional reviewer comment | @@ -76,9 +75,6 @@ Cancel an access request that has not passed approval. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | SailPoint Personal Access Token client ID | -| `clientSecret` | string | Yes | SailPoint Personal Access Token client secret | -| `tenant` | string | Yes | SailPoint tenant name or full *.api.identitynow.com / *.api.identitynowgov.com host | | `accountActivityId` | string | Yes | Account activity / identity request ID | | `comment` | string | Yes | Cancellation reason | @@ -101,9 +97,6 @@ Approve or revoke 1-250 review items in an identity certification. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | SailPoint Personal Access Token client ID | -| `clientSecret` | string | Yes | SailPoint Personal Access Token client secret | -| `tenant` | string | Yes | SailPoint tenant name or full *.api.identitynow.com / *.api.identitynowgov.com host | | `id` | string | Yes | Certification ID | | `decisions` | array | Yes | Array of \{id, decision: APPROVE\|REVOKE, bulk, proposedEndDate?, recommendation?, comments?\} | @@ -142,9 +135,6 @@ Get an access profile by ID. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | SailPoint Personal Access Token client ID | -| `clientSecret` | string | Yes | SailPoint Personal Access Token client secret | -| `tenant` | string | Yes | SailPoint tenant name or full *.api.identitynow.com / *.api.identitynowgov.com host | | `id` | string | Yes | Access profile ID | #### Output @@ -181,9 +171,6 @@ List entitlements in one access profile. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | SailPoint Personal Access Token client ID | -| `clientSecret` | string | Yes | SailPoint Personal Access Token client secret | -| `tenant` | string | Yes | SailPoint tenant name or full *.api.identitynow.com / *.api.identitynowgov.com host | | `id` | string | Yes | Access profile ID | | `filters` | string | No | SailPoint standard collection filter expression for this operation | | `sorters` | string | No | Comma-separated supported sort fields, prefixed with - for descending order | @@ -253,9 +240,6 @@ Get tenant access-request, request-on-behalf-of, and machine-identity configurat | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | SailPoint Personal Access Token client ID | -| `clientSecret` | string | Yes | SailPoint Personal Access Token client secret | -| `tenant` | string | Yes | SailPoint tenant name or full *.api.identitynow.com / *.api.identitynowgov.com host | #### Output @@ -301,9 +285,6 @@ List requested-item status records for access requests. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | SailPoint Personal Access Token client ID | -| `clientSecret` | string | Yes | SailPoint Personal Access Token client secret | -| `tenant` | string | Yes | SailPoint tenant name or full *.api.identitynow.com / *.api.identitynowgov.com host | | `requestedFor` | string | No | Identity ID for whom the access was requested | | `requestedBy` | string | No | Identity ID that submitted the access request | | `regardingIdentity` | string | No | Identity ID that is either the requester or the request target | @@ -366,9 +347,6 @@ Get an account from the current /accounts/v1 service by ID. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | SailPoint Personal Access Token client ID | -| `clientSecret` | string | Yes | SailPoint Personal Access Token client secret | -| `tenant` | string | Yes | SailPoint tenant name or full *.api.identitynow.com / *.api.identitynowgov.com host | | `id` | string | Yes | Account ID | #### Output @@ -417,9 +395,6 @@ Get an account activity by ID. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | SailPoint Personal Access Token client ID | -| `clientSecret` | string | Yes | SailPoint Personal Access Token client secret | -| `tenant` | string | Yes | SailPoint tenant name or full *.api.identitynow.com / *.api.identitynowgov.com host | | `id` | string | Yes | Account activity ID | #### Output @@ -454,9 +429,6 @@ List entitlements granted to one account. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | SailPoint Personal Access Token client ID | -| `clientSecret` | string | Yes | SailPoint Personal Access Token client secret | -| `tenant` | string | Yes | SailPoint tenant name or full *.api.identitynow.com / *.api.identitynowgov.com host | | `id` | string | Yes | Account ID | | `limit` | number | No | Maximum records for this page \(0-250; default 250\) | | `offset` | number | No | Zero-based record offset \(default 0\) | @@ -524,9 +496,6 @@ Resolve eligible source accounts before submitting a machine or multi-account ac | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | SailPoint Personal Access Token client ID | -| `clientSecret` | string | Yes | SailPoint Personal Access Token client secret | -| `tenant` | string | Yes | SailPoint tenant name or full *.api.identitynow.com / *.api.identitynowgov.com host | | `requestType` | string | No | GRANT_ACCESS \(default\), REVOKE_ACCESS, or MODIFY_ACCESS | | `requestedFor` | array | No | Human identity IDs for the flat request shape | | `requestedItems` | array | No | Flat human request items | @@ -573,9 +542,6 @@ Get a certification campaign by ID. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | SailPoint Personal Access Token client ID | -| `clientSecret` | string | Yes | SailPoint Personal Access Token client secret | -| `tenant` | string | Yes | SailPoint tenant name or full *.api.identitynow.com / *.api.identitynowgov.com host | | `id` | string | Yes | Campaign ID | | `detail` | string | No | SLIM or FULL | @@ -620,9 +586,6 @@ Get an identity certification by ID. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | SailPoint Personal Access Token client ID | -| `clientSecret` | string | Yes | SailPoint Personal Access Token client secret | -| `tenant` | string | Yes | SailPoint tenant name or full *.api.identitynow.com / *.api.identitynowgov.com host | | `id` | string | Yes | Certification ID | #### Output @@ -660,9 +623,6 @@ Get an entitlement by ID. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | SailPoint Personal Access Token client ID | -| `clientSecret` | string | Yes | SailPoint Personal Access Token client secret | -| `tenant` | string | Yes | SailPoint tenant name or full *.api.identitynow.com / *.api.identitynowgov.com host | | `id` | string | Yes | Entitlement ID | #### Output @@ -727,9 +687,6 @@ Get grant, revocation, duration, approval, and form settings for an entitlement. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | SailPoint Personal Access Token client ID | -| `clientSecret` | string | Yes | SailPoint Personal Access Token client secret | -| `tenant` | string | Yes | SailPoint tenant name or full *.api.identitynow.com / *.api.identitynowgov.com host | | `id` | string | Yes | Entitlement ID | #### Output @@ -766,9 +723,6 @@ Get an identity from the current /identities/v1 service by ID. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | SailPoint Personal Access Token client ID | -| `clientSecret` | string | Yes | SailPoint Personal Access Token client secret | -| `tenant` | string | Yes | SailPoint tenant name or full *.api.identitynow.com / *.api.identitynowgov.com host | | `id` | string | Yes | Identity ID | #### Output @@ -802,9 +756,6 @@ Get a role by ID. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | SailPoint Personal Access Token client ID | -| `clientSecret` | string | Yes | SailPoint Personal Access Token client secret | -| `tenant` | string | Yes | SailPoint tenant name or full *.api.identitynow.com / *.api.identitynowgov.com host | | `id` | string | Yes | Role ID | #### Output @@ -845,9 +796,6 @@ List entitlements in one role using the current non-experimental roles service. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | SailPoint Personal Access Token client ID | -| `clientSecret` | string | Yes | SailPoint Personal Access Token client secret | -| `tenant` | string | Yes | SailPoint tenant name or full *.api.identitynow.com / *.api.identitynowgov.com host | | `id` | string | Yes | Role ID | | `filters` | string | No | SailPoint standard collection filter expression for this operation | | `sorters` | string | No | Comma-separated supported sort fields, prefixed with - for descending order | @@ -917,9 +865,6 @@ Get an identity source by ID. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | SailPoint Personal Access Token client ID | -| `clientSecret` | string | Yes | SailPoint Personal Access Token client secret | -| `tenant` | string | Yes | SailPoint tenant name or full *.api.identitynow.com / *.api.identitynowgov.com host | | `id` | string | Yes | Source ID | #### Output @@ -971,9 +916,6 @@ Get the current status of a SailPoint background task by ID. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | SailPoint Personal Access Token client ID | -| `clientSecret` | string | Yes | SailPoint Personal Access Token client secret | -| `tenant` | string | Yes | SailPoint tenant name or full *.api.identitynow.com / *.api.identitynowgov.com host | | `id` | string | Yes | Task ID | #### Output @@ -1029,9 +971,6 @@ List access profiles with current visibility and segmentation controls. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | SailPoint Personal Access Token client ID | -| `clientSecret` | string | Yes | SailPoint Personal Access Token client secret | -| `tenant` | string | Yes | SailPoint tenant name or full *.api.identitynow.com / *.api.identitynowgov.com host | | `forSubadmin` | string | No | Subadmin identity ID or 'me' whose visible resources should be returned | | `forSegmentIds` | string | No | Comma-separated segment IDs used to restrict the returned resources | | `includeUnsegmented` | boolean | No | Include resources not assigned to a segment \(default true\) | @@ -1077,9 +1016,6 @@ List provisioning activities with identity, filter, sort, and page controls. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | SailPoint Personal Access Token client ID | -| `clientSecret` | string | Yes | SailPoint Personal Access Token client secret | -| `tenant` | string | Yes | SailPoint tenant name or full *.api.identitynow.com / *.api.identitynowgov.com host | | `requestedFor` | string | No | Target identity ID or 'me'; mutually exclusive with regardingIdentity | | `requestedBy` | string | No | Requester identity ID or 'me'; mutually exclusive with regardingIdentity | | `regardingIdentity` | string | No | Requester-or-target identity ID or 'me'; excludes requestedFor/requestedBy | @@ -1123,9 +1059,6 @@ List accounts with documented filtering, sorting, detail, and pagination. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | SailPoint Personal Access Token client ID | -| `clientSecret` | string | Yes | SailPoint Personal Access Token client secret | -| `tenant` | string | Yes | SailPoint tenant name or full *.api.identitynow.com / *.api.identitynowgov.com host | | `filters` | string | No | SailPoint standard collection filter expression for this operation | | `sorters` | string | No | Comma-separated supported sort fields, prefixed with - for descending order | | `detailLevel` | string | No | SLIM or FULL \(default FULL\) | @@ -1181,9 +1114,6 @@ List certification campaigns with detail, filtering, sorting, and pagination. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | SailPoint Personal Access Token client ID | -| `clientSecret` | string | Yes | SailPoint Personal Access Token client secret | -| `tenant` | string | Yes | SailPoint tenant name or full *.api.identitynow.com / *.api.identitynowgov.com host | | `detail` | string | No | SLIM \(default\) or FULL | | `filters` | string | No | SailPoint standard collection filter expression for this operation | | `sorters` | string | No | Comma-separated supported sort fields, prefixed with - for descending order | @@ -1234,9 +1164,6 @@ List access-review items in one identity certification. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | SailPoint Personal Access Token client ID | -| `clientSecret` | string | Yes | SailPoint Personal Access Token client secret | -| `tenant` | string | Yes | SailPoint tenant name or full *.api.identitynow.com / *.api.identitynowgov.com host | | `id` | string | Yes | Certification ID | | `filters` | string | No | SailPoint standard collection filter expression for this operation | | `sorters` | string | No | Comma-separated supported sort fields, prefixed with - for descending order | @@ -1274,9 +1201,6 @@ List identity certifications assigned to a reviewer. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | SailPoint Personal Access Token client ID | -| `clientSecret` | string | Yes | SailPoint Personal Access Token client secret | -| `tenant` | string | Yes | SailPoint tenant name or full *.api.identitynow.com / *.api.identitynowgov.com host | | `reviewerIdentity` | string | No | Reviewer identity ID or 'me' | | `filters` | string | No | SailPoint standard collection filter expression for this operation | | `sorters` | string | No | Comma-separated supported sort fields, prefixed with - for descending order | @@ -1321,9 +1245,6 @@ List entitlements with current segmentation, cursor, filter, and page controls. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | SailPoint Personal Access Token client ID | -| `clientSecret` | string | Yes | SailPoint Personal Access Token client secret | -| `tenant` | string | Yes | SailPoint tenant name or full *.api.identitynow.com / *.api.identitynowgov.com host | | `segmentedForIdentity` | string | No | Identity ID whose visible segments restrict the results | | `forSegmentIds` | string | No | Comma-separated segment IDs used to restrict the returned resources | | `includeUnsegmented` | boolean | No | Include resources not assigned to a segment \(default true\) | @@ -1398,9 +1319,6 @@ List identities with documented filtering, sorting, and pagination. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | SailPoint Personal Access Token client ID | -| `clientSecret` | string | Yes | SailPoint Personal Access Token client secret | -| `tenant` | string | Yes | SailPoint tenant name or full *.api.identitynow.com / *.api.identitynowgov.com host | | `filters` | string | No | SailPoint standard collection filter expression for this operation | | `sorters` | string | No | Comma-separated supported sort fields, prefixed with - for descending order | | `defaultFilter` | string | No | CORRELATED_ONLY \(default\) or NONE | @@ -1441,9 +1359,6 @@ List tagged entitlement references held by one identity. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | SailPoint Personal Access Token client ID | -| `clientSecret` | string | Yes | SailPoint Personal Access Token client secret | -| `tenant` | string | Yes | SailPoint tenant name or full *.api.identitynow.com / *.api.identitynowgov.com host | | `id` | string | Yes | Identity ID | | `limit` | number | No | Maximum records for this page \(0-250; default 250\) | | `offset` | number | No | Zero-based record offset \(default 0\) | @@ -1471,9 +1386,6 @@ List pending access-request approvals visible to the caller. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | SailPoint Personal Access Token client ID | -| `clientSecret` | string | Yes | SailPoint Personal Access Token client secret | -| `tenant` | string | Yes | SailPoint tenant name or full *.api.identitynow.com / *.api.identitynowgov.com host | | `ownerId` | string | No | Approval owner identity ID or 'me'; admins may omit it for all approvals | | `filters` | string | No | SailPoint standard collection filter expression for this operation | | `sorters` | string | No | Comma-separated supported sort fields, prefixed with - for descending order | @@ -1531,9 +1443,6 @@ List roles with current visibility, segmentation, filtering, and pagination cont | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | SailPoint Personal Access Token client ID | -| `clientSecret` | string | Yes | SailPoint Personal Access Token client secret | -| `tenant` | string | Yes | SailPoint tenant name or full *.api.identitynow.com / *.api.identitynowgov.com host | | `forSubadmin` | string | No | Subadmin identity ID or 'me' whose visible resources should be returned | | `forSegmentIds` | string | No | Comma-separated segment IDs used to restrict the returned resources | | `includeUnsegmented` | boolean | No | Include resources not assigned to a segment \(default true\) | @@ -1583,9 +1492,6 @@ List identity sources with visibility, filtering, sorting, and pagination contro | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | SailPoint Personal Access Token client ID | -| `clientSecret` | string | Yes | SailPoint Personal Access Token client secret | -| `tenant` | string | Yes | SailPoint tenant name or full *.api.identitynow.com / *.api.identitynowgov.com host | | `filters` | string | No | SailPoint standard collection filter expression for this operation | | `sorters` | string | No | Comma-separated supported sort fields, prefixed with - for descending order | | `forSubadmin` | string | No | Subadmin identity ID or 'me' whose visible resources should be returned | @@ -1645,9 +1551,6 @@ Start account aggregation for a source, optionally using a CSV file. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | SailPoint Personal Access Token client ID | -| `clientSecret` | string | Yes | SailPoint Personal Access Token client secret | -| `tenant` | string | Yes | SailPoint tenant name or full *.api.identitynow.com / *.api.identitynowgov.com host | | `sourceId` | string | Yes | Source ID | | `file` | file | No | Delimited-file source account CSV | | `disableOptimization` | boolean | No | Reprocess every account instead of using optimized aggregation | @@ -1692,9 +1595,6 @@ Start entitlement aggregation for a source, optionally using a CSV file. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | SailPoint Personal Access Token client ID | -| `clientSecret` | string | Yes | SailPoint Personal Access Token client secret | -| `tenant` | string | Yes | SailPoint tenant name or full *.api.identitynow.com / *.api.identitynowgov.com host | | `sourceId` | string | Yes | Source ID | | `file` | file | No | Delimited-file source entitlement CSV | @@ -1725,9 +1625,6 @@ Reject one pending access-request approval with a reviewer comment. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | SailPoint Personal Access Token client ID | -| `clientSecret` | string | Yes | SailPoint Personal Access Token client secret | -| `tenant` | string | Yes | SailPoint tenant name or full *.api.identitynow.com / *.api.identitynowgov.com host | | `approvalId` | string | Yes | Approval ID | | `comment` | string | Yes | Reviewer rejection comment | @@ -1750,9 +1647,6 @@ Submit a current human or machine identity access request. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | SailPoint Personal Access Token client ID | -| `clientSecret` | string | Yes | SailPoint Personal Access Token client secret | -| `tenant` | string | Yes | SailPoint tenant name or full *.api.identitynow.com / *.api.identitynowgov.com host | | `requestType` | string | No | GRANT_ACCESS \(default\), REVOKE_ACCESS, or MODIFY_ACCESS | | `requestedFor` | array | No | Human identity IDs for the flat request shape | | `requestedItems` | array | No | Flat human request items | @@ -1792,9 +1686,6 @@ Search current SailPoint indices with every documented search query mode. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | SailPoint Personal Access Token client ID | -| `clientSecret` | string | Yes | SailPoint Personal Access Token client secret | -| `tenant` | string | Yes | SailPoint tenant name or full *.api.identitynow.com / *.api.identitynowgov.com host | | `indices` | array | No | Indices to search: accessprofiles, accountactivities, entitlements, events, identities, roles, or *. Omit to search all. | | `queryType` | string | No | SAILPOINT \(default\), DSL, TEXT, or TYPEAHEAD | | `queryVersion` | string | No | Elasticsearch query language version \(default 5.2\) | @@ -1835,9 +1726,6 @@ Run an Elasticsearch DSL or SailPoint aggregation over current search indices. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | SailPoint Personal Access Token client ID | -| `clientSecret` | string | Yes | SailPoint Personal Access Token client secret | -| `tenant` | string | Yes | SailPoint tenant name or full *.api.identitynow.com / *.api.identitynowgov.com host | | `indices` | array | No | Indices to search: accessprofiles, accountactivities, entitlements, events, identities, roles, or *. Omit to search all. | | `queryType` | string | No | SAILPOINT \(default\), DSL, TEXT, or TYPEAHEAD | | `queryVersion` | string | No | Elasticsearch query language version \(default 5.2\) | @@ -1878,9 +1766,6 @@ Count documents matching a complete SailPoint search body. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | SailPoint Personal Access Token client ID | -| `clientSecret` | string | Yes | SailPoint Personal Access Token client secret | -| `tenant` | string | Yes | SailPoint tenant name or full *.api.identitynow.com / *.api.identitynowgov.com host | | `indices` | array | No | Indices to search: accessprofiles, accountactivities, entitlements, events, identities, roles, or *. Omit to search all. | | `queryType` | string | No | SAILPOINT \(default\), DSL, TEXT, or TYPEAHEAD | | `queryVersion` | string | No | Elasticsearch query language version \(default 5.2\) | @@ -1916,9 +1801,6 @@ Sign off a completed identity certification. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | SailPoint Personal Access Token client ID | -| `clientSecret` | string | Yes | SailPoint Personal Access Token client secret | -| `tenant` | string | Yes | SailPoint tenant name or full *.api.identitynow.com / *.api.identitynowgov.com host | | `id` | string | Yes | Certification ID | #### Output diff --git a/apps/docs/content/docs/integrations/servicenow.mdx b/apps/docs/content/docs/integrations/servicenow.mdx index 91f21ff398e..8dc4d79cf78 100644 --- a/apps/docs/content/docs/integrations/servicenow.mdx +++ b/apps/docs/content/docs/integrations/servicenow.mdx @@ -2,7 +2,7 @@ title: ServiceNow description: Create, read, update, and delete ServiceNow records integration: - type: "servicenow_v2" + type: "servicenow_v3" color: "#032D42" --- @@ -10,7 +10,9 @@ import { ReferenceDetails } from "@/components/ui/reference-details" {/* MANUAL-CONTENT-START:intro */} -Use [ServiceNow](https://www.servicenow.com/) in Sim to create or update incidents and change requests, order catalog items, and process approvals. Read configuration items, knowledge articles, users, and groups, and transfer attachments. Generic record and aggregate operations accept a table name, such as incident, task, change_request, or sys_user, and the fields required by the operation. List Attachments and Upload Attachment also require a table name. Connect with your ServiceNow instance URL, username, and password. +Use [ServiceNow](https://www.servicenow.com/) in Sim to create or update incidents and change requests, order catalog items, and process approvals. Read configuration items, knowledge articles, users, and groups, and transfer attachments. Generic record and aggregate operations accept a table name, such as incident, task, change_request, or sys_user, and the fields required by the operation. List Attachments and Upload Attachment also require a table name. + +Connect ServiceNow once as a ServiceNow credential: enter the instance URL plus the username and password of a dedicated integration user that has the roles your workflows need and does not use multi-factor authentication. Enter the instance's https URL, such as `acme.service-now.com` or a custom instance URL; the instance must be reachable from the internet. Workflows built with the earlier ServiceNow block keep the instance URL, username, and password they entered in the block. {/* MANUAL-CONTENT-END */} ## Use the block [#usage-instructions] @@ -29,9 +31,6 @@ Create a new record in a ServiceNow table | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `instanceUrl` | string | Yes | ServiceNow instance URL \(e.g., https://instance.service-now.com) | -| `username` | string | Yes | ServiceNow username | -| `password` | string | Yes | ServiceNow password | | `tableName` | string | Yes | Table name \(e.g., incident, task, sys_user\) | | `fields` | json | Yes | Fields to set on the record as JSON object \(e.g., \{"short_description": "Issue title", "priority": "1"\}\) | @@ -54,9 +53,6 @@ Read records from a ServiceNow table | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `instanceUrl` | string | Yes | ServiceNow instance URL \(e.g., https://instance.service-now.com) | -| `username` | string | Yes | ServiceNow username | -| `password` | string | Yes | ServiceNow password | | `tableName` | string | Yes | Table name \(e.g., incident, task, sys_user, change_request\) | | `sysId` | string | No | Specific record sys_id \(e.g., 6816f79cc0a8016401c5a33be04be441\) | | `number` | string | No | Record number \(e.g., INC0010001\) | @@ -85,9 +81,6 @@ Update an existing record in a ServiceNow table | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `instanceUrl` | string | Yes | ServiceNow instance URL \(e.g., https://instance.service-now.com) | -| `username` | string | Yes | ServiceNow username | -| `password` | string | Yes | ServiceNow password | | `tableName` | string | Yes | Table name \(e.g., incident, task, sys_user, change_request\) | | `sysId` | string | Yes | Record sys_id to update \(e.g., 6816f79cc0a8016401c5a33be04be441\) | | `fields` | json | Yes | Fields to update as JSON object \(e.g., \{"state": "2", "priority": "1"\}\) | @@ -111,9 +104,6 @@ Delete a record from a ServiceNow table | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `instanceUrl` | string | Yes | ServiceNow instance URL \(e.g., https://instance.service-now.com) | -| `username` | string | Yes | ServiceNow username | -| `password` | string | Yes | ServiceNow password | | `tableName` | string | Yes | Table name \(e.g., incident, task, sys_user, change_request\) | | `sysId` | string | Yes | Record sys_id to delete \(e.g., 6816f79cc0a8016401c5a33be04be441\) | @@ -136,9 +126,6 @@ Compute aggregate statistics (count, sum, average, min, max, group by) over a Se | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `instanceUrl` | string | Yes | ServiceNow instance URL \(e.g., https://instance.service-now.com) | -| `username` | string | Yes | ServiceNow username | -| `password` | string | Yes | ServiceNow password | | `tableName` | string | Yes | Table name \(e.g., incident, change_request, task\) | | `query` | string | No | Encoded query string to filter records before aggregating \(e.g., "active=true"\) | | `count` | boolean | No | Return the count of matching records | @@ -170,9 +157,6 @@ List the attachments on a ServiceNow record | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `instanceUrl` | string | Yes | ServiceNow instance URL \(e.g., https://instance.service-now.com) | -| `username` | string | Yes | ServiceNow username | -| `password` | string | Yes | ServiceNow password | | `tableName` | string | Yes | Table that owns the record \(e.g., incident, change_request\) | | `recordSysId` | string | Yes | sys_id of the record whose attachments should be listed | | `limit` | number | No | Maximum number of attachments to return | @@ -201,9 +185,6 @@ Download an attachment file from ServiceNow by its sys_id | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `instanceUrl` | string | Yes | ServiceNow instance URL \(e.g., https://instance.service-now.com) | -| `username` | string | Yes | ServiceNow username | -| `password` | string | Yes | ServiceNow password | | `attachmentSysId` | string | Yes | sys_id of the attachment to download \(from List Attachments\) | #### Output @@ -224,9 +205,6 @@ Attach a file to a ServiceNow record | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `instanceUrl` | string | Yes | ServiceNow instance URL \(e.g., https://instance.service-now.com) | -| `username` | string | Yes | ServiceNow username | -| `password` | string | Yes | ServiceNow password | | `tableName` | string | Yes | Table that owns the record \(e.g., incident, change_request\) | | `recordSysId` | string | Yes | sys_id of the record to attach the file to | | `fileName` | string | Yes | Name to give the uploaded file \(e.g., logs.txt\) | @@ -251,9 +229,6 @@ Create an incident in ServiceNow. Reference fields (caller, assignment group, as | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `instanceUrl` | string | Yes | ServiceNow instance URL \(e.g., https://instance.service-now.com) | -| `username` | string | Yes | ServiceNow username | -| `password` | string | Yes | ServiceNow password | | `shortDescription` | string | Yes | Short description — the one-line summary of the incident. | | `description` | string | No | Detailed description of the incident. | | `callerId` | string | No | Caller \(caller_id\) — sys_id of the sys_user who reported the incident. Use Find ServiceNow User to resolve an email address to a sys_id. | @@ -319,9 +294,6 @@ Retrieve a single ServiceNow incident by number (e.g., INC0010001) or sys_id. Re | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `instanceUrl` | string | Yes | ServiceNow instance URL \(e.g., https://instance.service-now.com) | -| `username` | string | Yes | ServiceNow username | -| `password` | string | Yes | ServiceNow password | | `sysId` | string | No | Record sys_id. Provide either this or the record number. | | `number` | string | No | Record number \(e.g., INC0010001\). Provide either this or the sys_id. | | `fields` | string | No | Comma-separated list of fields to return \(e.g., number,short_description,state\). Returns all fields when omitted. | @@ -371,9 +343,6 @@ Search ServiceNow incidents by state, priority, assignment, caller, or text. All | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `instanceUrl` | string | Yes | ServiceNow instance URL \(e.g., https://instance.service-now.com) | -| `username` | string | Yes | ServiceNow username | -| `password` | string | Yes | ServiceNow password | | `searchText` | string | No | Text to match against the incident short description using the ServiceNow LIKE operator, which matches anywhere in the field. | | `state` | string | No | Incident state coded value. Base system: 1=New, 2=In Progress, 3=On Hold, 6=Resolved, 7=Closed, 8=Canceled. | | `priority` | string | No | Priority coded value 1-5 \(1 Critical … 5 Planning\). | @@ -431,9 +400,6 @@ Update fields on an existing ServiceNow incident. Only the fields you supply are | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `instanceUrl` | string | Yes | ServiceNow instance URL \(e.g., https://instance.service-now.com) | -| `username` | string | Yes | ServiceNow username | -| `password` | string | Yes | ServiceNow password | | `sysId` | string | Yes | sys_id of the record to update. If you only have the record number, look it up first \(for example with Get ServiceNow Incident\). | | `shortDescription` | string | No | New short description. | | `description` | string | No | New detailed description. | @@ -497,9 +463,6 @@ Move a ServiceNow incident to Resolved (state 6) with a resolution code and reso | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `instanceUrl` | string | Yes | ServiceNow instance URL \(e.g., https://instance.service-now.com) | -| `username` | string | Yes | ServiceNow username | -| `password` | string | Yes | ServiceNow password | | `sysId` | string | Yes | sys_id of the record to update. If you only have the record number, look it up first \(for example with Get ServiceNow Incident\). | | `closeCode` | string | Yes | Resolution code \(close_code\). This is a choice field whose values are configured per instance — read the choice list on your incident table and pass one of its values. | | `closeNotes` | string | Yes | Resolution notes \(close_notes\) documenting how the incident was resolved. | @@ -553,9 +516,6 @@ Move a ServiceNow incident to Closed (state 7) with a resolution code and resolu | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `instanceUrl` | string | Yes | ServiceNow instance URL \(e.g., https://instance.service-now.com) | -| `username` | string | Yes | ServiceNow username | -| `password` | string | Yes | ServiceNow password | | `sysId` | string | Yes | sys_id of the record to update. If you only have the record number, look it up first \(for example with Get ServiceNow Incident\). | | `closeCode` | string | Yes | Resolution code \(close_code\). This is a choice field whose values are configured per instance — read the choice list on your incident table and pass one of its values. | | `closeNotes` | string | Yes | Resolution notes \(close_notes\) documenting how the incident was resolved. | @@ -609,9 +569,6 @@ Append an internal work note or a customer-visible additional comment to a Servi | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `instanceUrl` | string | Yes | ServiceNow instance URL \(e.g., https://instance.service-now.com) | -| `username` | string | Yes | ServiceNow username | -| `password` | string | Yes | ServiceNow password | | `sysId` | string | Yes | sys_id of the record to update. If you only have the record number, look it up first \(for example with Get ServiceNow Incident\). | | `comment` | string | Yes | Text to append to the journal field. | | `commentField` | string | No | Which journal field to write to: "work_notes" for an internal note \(default\) or "comments" for a customer-visible additional comment. | @@ -663,9 +620,6 @@ Create a change request in ServiceNow. Reference fields (assignment group, assig | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `instanceUrl` | string | Yes | ServiceNow instance URL \(e.g., https://instance.service-now.com) | -| `username` | string | Yes | ServiceNow username | -| `password` | string | Yes | ServiceNow password | | `shortDescription` | string | Yes | Short description — the one-line summary of the change. | | `description` | string | No | Detailed description of the change. | | `type` | string | No | Change type: "normal", "standard", or "emergency". | @@ -732,9 +686,6 @@ Retrieve a single ServiceNow change request by number (e.g., CHG0030001) or sys_ | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `instanceUrl` | string | Yes | ServiceNow instance URL \(e.g., https://instance.service-now.com) | -| `username` | string | Yes | ServiceNow username | -| `password` | string | Yes | ServiceNow password | | `sysId` | string | No | Record sys_id. Provide either this or the record number. | | `number` | string | No | Record number \(e.g., INC0010001\). Provide either this or the sys_id. | | `fields` | string | No | Comma-separated list of fields to return \(e.g., number,short_description,state\). Returns all fields when omitted. | @@ -784,9 +735,6 @@ Search ServiceNow change requests by state, type, risk, assignment, or text. All | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `instanceUrl` | string | Yes | ServiceNow instance URL \(e.g., https://instance.service-now.com) | -| `username` | string | Yes | ServiceNow username | -| `password` | string | Yes | ServiceNow password | | `searchText` | string | No | Text to match against the change short description using the ServiceNow LIKE operator, which matches anywhere in the field. | | `state` | string | No | Change state coded value. Base system: -5=New, -4=Assess, -3=Authorize, -2=Scheduled, -1=Implement, 0=Review, 3=Closed, 4=Canceled. | | `type` | string | No | Change type: "normal", "standard", or "emergency". | @@ -844,9 +792,6 @@ Update fields on an existing ServiceNow change request. Only the fields you supp | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `instanceUrl` | string | Yes | ServiceNow instance URL \(e.g., https://instance.service-now.com) | -| `username` | string | Yes | ServiceNow username | -| `password` | string | Yes | ServiceNow password | | `sysId` | string | Yes | sys_id of the record to update. If you only have the record number, look it up first \(for example with Get ServiceNow Incident\). | | `shortDescription` | string | No | New short description. | | `description` | string | No | New detailed description. | @@ -910,9 +855,6 @@ Move a ServiceNow change request to another state. Base-system change model stat | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `instanceUrl` | string | Yes | ServiceNow instance URL \(e.g., https://instance.service-now.com) | -| `username` | string | Yes | ServiceNow username | -| `password` | string | Yes | ServiceNow password | | `sysId` | string | Yes | sys_id of the record to update. If you only have the record number, look it up first \(for example with Get ServiceNow Incident\). | | `state` | string | Yes | Target state coded value: -5 \(New\), -4 \(Assess\), -3 \(Authorize\), -2 \(Scheduled\), -1 \(Implement\), 0 \(Review\), 3 \(Closed\), or 4 \(Canceled\). | | `closeCode` | string | No | Close code, required when moving to Closed \(3\): "successful", "successful_issues", or "unsuccessful". | @@ -967,9 +909,6 @@ List the change tasks belonging to a ServiceNow change request, via the Change M | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `instanceUrl` | string | Yes | ServiceNow instance URL \(e.g., https://instance.service-now.com) | -| `username` | string | Yes | ServiceNow username | -| `password` | string | Yes | ServiceNow password | | `changeSysId` | string | Yes | sys_id of the change request whose tasks should be listed. | | `query` | string | No | ServiceNow encoded query used to filter the tasks \(e.g., "active=true^ORDERBYnumber"\). | | `order` | string | No | Field to sort the returned tasks by. | @@ -996,9 +935,6 @@ Read the states a ServiceNow change request can actually move to next, with the | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `instanceUrl` | string | Yes | ServiceNow instance URL \(e.g., https://instance.service-now.com) | -| `username` | string | Yes | ServiceNow username | -| `password` | string | Yes | ServiceNow password | | `changeSysId` | string | Yes | sys_id of the change request. Use Get ServiceNow Change Request to resolve a CHG number to its sys_id. | #### Output @@ -1033,9 +969,6 @@ Browse or search the ServiceNow service catalog. Returns each item with its sys_ | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `instanceUrl` | string | Yes | ServiceNow instance URL \(e.g., https://instance.service-now.com) | -| `username` | string | Yes | ServiceNow username | -| `password` | string | Yes | ServiceNow password | | `searchText` | string | No | Text to search for in the catalog items \(e.g., "iPhone"\). | | `catalogSysId` | string | No | Restrict results to a specific catalog, by catalog sys_id. | | `categorySysId` | string | No | Restrict results to a specific category, by category sys_id. | @@ -1079,9 +1012,6 @@ Submit a service catalog request for a catalog item using the Service Catalog AP | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `instanceUrl` | string | Yes | ServiceNow instance URL \(e.g., https://instance.service-now.com) | -| `username` | string | Yes | ServiceNow username | -| `password` | string | Yes | ServiceNow password | | `catalogItemSysId` | string | Yes | sys_id of the catalog item to order. Use List ServiceNow Catalog Items to find it. | | `quantity` | number | No | Quantity to order. Must not be negative. Defaults to 1. | | `requestedFor` | string | No | sys_id of the sys_user the item is ordered for. Ordering on behalf of another user is governed by the glide.sc.req_for.roles instance properties. | @@ -1109,9 +1039,6 @@ List requested items (RITMs) from the ServiceNow Requested Item [sc_req_item] ta | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `instanceUrl` | string | Yes | ServiceNow instance URL \(e.g., https://instance.service-now.com) | -| `username` | string | Yes | ServiceNow username | -| `password` | string | Yes | ServiceNow password | | `requestSysId` | string | No | sys_id of the parent request \(sc_request\) whose items should be listed. | | `catalogItemSysId` | string | No | sys_id of the catalog item \(cat_item\) to filter by. | | `active` | string | No | Restrict to active \("true"\) or inactive \("false"\) requested items. | @@ -1165,9 +1092,6 @@ Retrieve a single ServiceNow requested item (RITM) by number (e.g., RITM0010001) | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `instanceUrl` | string | Yes | ServiceNow instance URL \(e.g., https://instance.service-now.com) | -| `username` | string | Yes | ServiceNow username | -| `password` | string | Yes | ServiceNow password | | `sysId` | string | No | Record sys_id. Provide either this or the record number. | | `number` | string | No | Record number \(e.g., INC0010001\). Provide either this or the sys_id. | | `fields` | string | No | Comma-separated list of fields to return \(e.g., number,short_description,state\). Returns all fields when omitted. | @@ -1217,9 +1141,6 @@ List approval records from the ServiceNow Approval [sysapproval_approver] table. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `instanceUrl` | string | Yes | ServiceNow instance URL \(e.g., https://instance.service-now.com) | -| `username` | string | Yes | ServiceNow username | -| `password` | string | Yes | ServiceNow password | | `approverSysId` | string | No | sys_id of the approver \(sys_user\) whose approvals should be listed. Use Find ServiceNow User to resolve an email address to a sys_id. | | `state` | string | No | Approval state: "requested" \(pending, the default\), "approved", or "rejected". Pass an empty string with a custom query to list every state. | | `approvalFor` | string | No | sys_id of the record being approved, matched against the sysapproval reference field. | @@ -1273,9 +1194,6 @@ Approve or reject a ServiceNow approval record by setting its state on the Appro | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `instanceUrl` | string | Yes | ServiceNow instance URL \(e.g., https://instance.service-now.com) | -| `username` | string | Yes | ServiceNow username | -| `password` | string | Yes | ServiceNow password | | `approvalSysId` | string | Yes | sys_id of the approval record on the sysapproval_approver table. Use List ServiceNow Approvals to find it. | | `decision` | string | Yes | Decision to record: "approved" or "rejected". | | `comments` | string | No | Comment to record alongside the decision. This is a journal field. | @@ -1327,9 +1245,6 @@ Search the ServiceNow CMDB for configuration items. Defaults to the base cmdb_ci | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `instanceUrl` | string | Yes | ServiceNow instance URL \(e.g., https://instance.service-now.com) | -| `username` | string | Yes | ServiceNow username | -| `password` | string | Yes | ServiceNow password | | `ciClass` | string | No | CMDB class \(table\) to search, e.g., cmdb_ci_linux_server or cmdb_ci_app_server. Defaults to cmdb_ci, which covers every CI class. | | `name` | string | No | Text to match against the CI name using the ServiceNow LIKE operator, which matches anywhere in the field. | | `operationalStatus` | string | No | Operational status coded value \(operational_status\). The choice list is configured per instance. | @@ -1383,9 +1298,6 @@ Retrieve a configuration item and its CMDB relationships through the CMDB Instan | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `instanceUrl` | string | Yes | ServiceNow instance URL \(e.g., https://instance.service-now.com) | -| `username` | string | Yes | ServiceNow username | -| `password` | string | Yes | ServiceNow password | | `ciClass` | string | Yes | CMDB class \(table\) the CI belongs to, e.g., cmdb_ci_linux_server. Read it from the sys_class_name field returned by Search ServiceNow Configuration Items. | | `sysId` | string | Yes | sys_id of the configuration item to retrieve. | @@ -1418,9 +1330,6 @@ List rows from the CI Relationship [cmdb_rel_ci] table for a configuration item. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `instanceUrl` | string | Yes | ServiceNow instance URL \(e.g., https://instance.service-now.com) | -| `username` | string | Yes | ServiceNow username | -| `password` | string | Yes | ServiceNow password | | `ciSysId` | string | Yes | sys_id of the configuration item whose relationships should be listed. | | `direction` | string | No | Which side of the relationship the CI sits on: "parent", "child", or "both" \(default\). "both" matches rows where the CI is either the parent or the child. | | `query` | string | No | Additional ServiceNow encoded query, ANDed with the other filters \(e.g., "opened_at>=javascript:gs.beginningOfLastMonth\(\)"\). | @@ -1473,9 +1382,6 @@ Search ServiceNow knowledge base articles through the Knowledge Management API. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `instanceUrl` | string | Yes | ServiceNow instance URL \(e.g., https://instance.service-now.com) | -| `username` | string | Yes | ServiceNow username | -| `password` | string | Yes | ServiceNow password | | `query` | string | No | Text to search for across knowledge articles. | | `filter` | string | No | Encoded query used to filter the results, against the Knowledge \[kb_knowledge\] table \(e.g., "workflow_state=published"\). | | `knowledgeBaseSysIds` | string | No | Comma-separated knowledge base sys_ids \(from kb_knowledge_base\) to restrict results to. | @@ -1513,9 +1419,6 @@ Retrieve the full content of a ServiceNow knowledge article by sys_id or KB numb | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `instanceUrl` | string | Yes | ServiceNow instance URL \(e.g., https://instance.service-now.com) | -| `username` | string | Yes | ServiceNow username | -| `password` | string | Yes | ServiceNow password | | `articleId` | string | Yes | sys_id or KB number of the knowledge article \(e.g., KB0000011\). | | `fields` | string | No | Comma-separated kb_knowledge fields to include alongside the article content \(e.g., workflow_state,author\). | | `language` | string | No | Two-letter ISO 639-1 language code. Only applies when the article is addressed by KB number and a translation exists. | @@ -1548,9 +1451,6 @@ Look up ServiceNow users by email, user name, or display name. Use this to resol | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `instanceUrl` | string | Yes | ServiceNow instance URL \(e.g., https://instance.service-now.com) | -| `username` | string | Yes | ServiceNow username | -| `password` | string | Yes | ServiceNow password | | `email` | string | No | Exact email address to match. | | `userName` | string | No | Exact user name \(user_name\) to match. | | `name` | string | No | Text to match against the display name using the ServiceNow LIKE operator, which matches anywhere in the field. | @@ -1605,9 +1505,6 @@ List the members of a ServiceNow group from the Group Member [sys_user_grmember] | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `instanceUrl` | string | Yes | ServiceNow instance URL \(e.g., https://instance.service-now.com) | -| `username` | string | Yes | ServiceNow username | -| `password` | string | Yes | ServiceNow password | | `groupSysId` | string | No | sys_id of the sys_user_group whose members should be listed. | | `groupName` | string | No | Exact group name, resolved against the referenced group record. Provide this or the group sys_id. | | `query` | string | No | Additional ServiceNow encoded query, ANDed with the other filters \(e.g., "opened_at>=javascript:gs.beginningOfLastMonth\(\)"\). | diff --git a/apps/docs/content/docs/integrations/shopify-service-account.mdx b/apps/docs/content/docs/integrations/shopify-service-account.mdx index 93660d04247..0dbacb38bbd 100644 --- a/apps/docs/content/docs/integrations/shopify-service-account.mdx +++ b/apps/docs/content/docs/integrations/shopify-service-account.mdx @@ -1,12 +1,15 @@ --- title: Shopify Admin API tokens -description: Set up a Shopify custom app with an Admin API access token to use Shopify in Sim workflows +description: Connect a Shopify Dev Dashboard app with client credentials, or a custom app with an Admin API access token, to use Shopify in Sim workflows --- import { Callout } from '@/components/ui/callout' import { Step, Steps } from '@/components/ui/steps' -Connect a Shopify store with a **non-expiring offline Admin API access token** and its `.myshopify.com` domain. Sim stores the pasted token; this connection does not renew expiring tokens. +Connect a Shopify store in one of two ways, both with the store's `.myshopify.com` domain: + +- **Client credentials (Dev Dashboard app)**: the app's client ID and secret. Sim requests a 24-hour Admin API token from Shopify when a workflow needs one and renews it before it expires. Use this for apps created in the Dev Dashboard, which never show an Admin API token. +- **Admin API access token**: a **non-expiring offline** `shpat_` token pasted once. Sim stores the pasted token and does not renew expiring tokens. @@ -14,7 +17,28 @@ Connect a Shopify store with a **non-expiring offline Admin API access token** a You need store owner or staff permissions that allow managing apps on the store. You'll also need your store's permanent domain (`your-store.myshopify.com`) — Sim asks for it alongside the token. -Shopify no longer allows creating new custom apps from the store admin — that path is legacy. New custom apps are created from the **Dev Dashboard** ([dev.shopify.com](https://dev.shopify.com/)) or the Shopify CLI. If you already have an admin-created custom app, its `shpat_` token still works and can be pasted into Sim directly. +Shopify no longer allows creating new custom apps from the store admin — that path is legacy. New custom apps are created from the **Dev Dashboard** ([dev.shopify.com](https://dev.shopify.com/)) or the Shopify CLI; connect those with client credentials. If you already have an admin-created custom app, its `shpat_` token still works and can be pasted into Sim directly. + +## Connect a Dev Dashboard app with client credentials [#client-credentials] + +Shopify's [client credentials grant](https://shopify.dev/docs/apps/build/authentication-authorization/access-tokens/client-credentials-grant) only works when the app and the store belong to the **same Shopify organization**. If they don't, Shopify refuses the grant (`shop_not_permitted`) and Sim tells you so when you save the connection. + + + + In the [Dev Dashboard](https://dev.shopify.com/), create an app, configure the Admin API access scopes listed below, and install it on your store + + + Copy the app's **Client ID** and **Client secret** from its settings + + + In Sim, open **Integrations**, search for "Shopify", click **Add to Sim**, and choose **Add admin API token**. Leave **Authentication** on **Client credentials (Dev Dashboard app)**, then paste the client ID, the client secret, and the store domain + + + Save. Sim requests a token from Shopify and runs a scope-free `shop` query with it before saving. The client ID, client secret, and store domain are encrypted. Sim keeps one encrypted 24-hour token per app and store, separate from the credential, requests a new one before it expires, and replaces it early if Shopify rejects it + + + +Tokens carry every scope the store granted the app; Sim never requests a narrower set. Rotating the client secret in the Dev Dashboard means reconnecting the credential in Sim with the new secret. ## Set up the custom app [#setting-up-the-custom-app] @@ -48,10 +72,10 @@ Shopify no longer allows creating new custom apps from the store admin — that How you obtain the `shpat_` token depends on how the app was created: - **Legacy admin-created custom apps** (created from the store admin before Shopify removed that path) show the **Admin API access token** — it starts with `shpat_` — once on the app's API credentials page. Reveal it, copy it immediately, and paste it into Sim. For legacy admin-created custom apps, Shopify shows the token only once, at creation — if you lose it, you have to reinstall or recreate the app to get a new one. -- **New Dev Dashboard apps** do **not** display a permanent `shpat_` token in any UI. Complete an [authorization code grant](https://shopify.dev/docs/apps/build/authentication-authorization/authenticate-standalone-apps) install and obtain a **non-expiring offline** access token. Check the token response and access mode: the `shpat_` prefix is also used by expiring tokens. The Dev Dashboard's client-credentials tokens are different — they expire after 24 hours and cannot be stored in Sim. +- **New Dev Dashboard apps** do **not** display a permanent `shpat_` token in any UI. Connect them with [client credentials](#client-credentials) instead. Don't paste a 24-hour client-credentials token here: it stops working after a day. -Don't confuse the token types: `shpat_` is the Admin API access token Sim needs. `shpss_` is the app's client secret and `shpca_` identifies an older custom-app token format. Use the documented Admin API token for your app and confirm its lifetime; a client secret is not an access token. If your Dev Dashboard app only shows a client ID and secret, that is expected: Dev Dashboard apps issue tokens via OAuth, not a UI reveal (see the setup steps above). +Don't confuse the token types: `shpat_` is the Admin API access token Sim needs. `shpss_` is the app's client secret and `shpca_` identifies an older custom-app token format. Use the documented Admin API token for your app and confirm its lifetime; a client secret is not an access token. If your Dev Dashboard app only shows a client ID and secret, that is expected: choose **Client credentials** and paste those instead. ### 3. Find your store domain [#3-find-your-store-domain] @@ -68,7 +92,7 @@ Use the permanent `.myshopify.com` domain — for example, `your-store.myshopify Search for "Shopify" and open it, then click **Add to Sim** and choose **Add admin API token** - Paste the Admin API access token, enter the store domain (e.g. `your-store.myshopify.com`), and optionally set a display name and description + Set **Authentication** to **Admin API access token (shpat_)**, paste the token, enter the store domain (e.g. `your-store.myshopify.com`), and optionally set a display name and description Click **Add admin API token**. Sim verifies the token with a scope-free `shop` query on the store’s GraphQL Admin API — if it fails, you'll see a specific error explaining what went wrong. @@ -85,7 +109,7 @@ The block calls your store's Admin API (`your-store.myshopify.com/admin/api/...` ## Token lifecycle and recovery -A pasted credential must use a non-expiring offline token; Sim does not renew online, expiring offline, or 24-hour client-credentials tokens. A `shpat_` prefix alone does not establish the token’s lifetime. See [Shopify’s access-token reference](https://shopify.dev/docs/apps/build/authentication-authorization/access-tokens). +A pasted credential must use a non-expiring offline token; Sim does not renew a pasted online, expiring offline, or 24-hour client-credentials token. Connect a Dev Dashboard app with client credentials instead, and Sim renews its tokens itself. A `shpat_` prefix alone does not establish the token’s lifetime. See [Shopify’s access-token reference](https://shopify.dev/docs/apps/build/authentication-authorization/access-tokens). Tokens are scoped to the app’s installation on a store. Add a separate credential for each store. If an operation fails with access denied, review the app’s scopes and the store’s approval for protected data. Apply the scope change and re-authorize as required; update Sim if the procedure issues a replacement token. diff --git a/apps/docs/content/docs/integrations/twilio_sms.mdx b/apps/docs/content/docs/integrations/twilio_sms.mdx index 39bdd4f6cfb..ee940ea75b2 100644 --- a/apps/docs/content/docs/integrations/twilio_sms.mdx +++ b/apps/docs/content/docs/integrations/twilio_sms.mdx @@ -11,6 +11,8 @@ import { ReferenceDetails } from "@/components/ui/reference-details" {/* MANUAL-CONTENT-START:intro */} Use [Twilio SMS](https://www.twilio.com/en-us/sms) to send a text message from a workflow. Configure the Twilio credentials, sending number, recipient, and message body documented below. + +Connect Twilio once as a Twilio credential with your Account SID and either the account's Auth Token or an [API key](https://www.twilio.com/docs/iam/api-keys). Enter an API key as its SID and secret joined by a colon (`SKxxx:secret`). A Standard or Main key works; a restricted key needs read access to phone numbers. Workflows built with the earlier Twilio block keep the Account SID and Auth Token they entered in the block. {/* MANUAL-CONTENT-END */} ## Use the block [#usage-instructions] diff --git a/apps/docs/content/docs/integrations/twilio_voice.mdx b/apps/docs/content/docs/integrations/twilio_voice.mdx index 8e7527ab8ae..cc8d55d5ef8 100644 --- a/apps/docs/content/docs/integrations/twilio_voice.mdx +++ b/apps/docs/content/docs/integrations/twilio_voice.mdx @@ -11,6 +11,8 @@ import { ReferenceDetails } from "@/components/ui/reference-details" {/* MANUAL-CONTENT-START:intro */} [Twilio Voice](https://www.twilio.com/en-us/voice) connects phone calls to workflows. Use this integration to place outbound calls, list calls, retrieve recordings, and receive call events through a webhook. Supply the call instructions and phone numbers required by the selected operation. + +Connect Twilio once as a Twilio credential with your Account SID and either the account's Auth Token or an [API key](https://www.twilio.com/docs/iam/api-keys). Enter an API key as its SID and secret joined by a colon (`SKxxx:secret`). A Standard or Main key works; a restricted key needs read access to phone numbers. Workflows built with the earlier Twilio block keep the Account SID and Auth Token they entered in the block. {/* MANUAL-CONTENT-END */} ## Use the block [#usage-instructions] diff --git a/apps/docs/content/docs/integrations/zendesk.mdx b/apps/docs/content/docs/integrations/zendesk.mdx index d8e6e39f8ce..883b298cc3e 100644 --- a/apps/docs/content/docs/integrations/zendesk.mdx +++ b/apps/docs/content/docs/integrations/zendesk.mdx @@ -2,7 +2,7 @@ title: Zendesk description: Manage support tickets, users, and organizations in Zendesk integration: - type: "zendesk" + type: "zendesk_v2" color: "#03363D" --- @@ -11,6 +11,15 @@ import { ReferenceDetails } from "@/components/ui/reference-details" {/* MANUAL-CONTENT-START:intro */} Use [Zendesk](https://www.zendesk.com/) in Sim to manage tickets, users, and organizations and search support records. The reference includes single-record and bulk operations, ticket merging, organization autocomplete, and search counts. + +### Connect Zendesk + +Add a Zendesk credential under **Integrations** and pick it in the block. A credential holds your subdomain (`your-company` from `your-company.zendesk.com`) and one of two sign-ins: + +- **OAuth client (client credentials)**: the unique identifier and secret of a **confidential** OAuth client from Admin Center (**Apps and integrations** > **APIs** > **OAuth Clients**), plus the permissions to request (read only, or read and write). Sim requests a one-hour token with [Zendesk's client credentials grant](https://developer.zendesk.com/api-reference/ticketing/oauth/grant_type_tokens/) when a workflow needs one. Every call runs as the user the OAuth client belongs to. +- **API token**: an agent or admin email and an [API token](https://developer.zendesk.com/api-reference/introduction/security-and-auth/). Every call runs as that agent. Zendesk has deprecated API tokens and asks customers to move to OAuth, so prefer the OAuth client. + +When you save, Sim checks that the credential signs in as a real agent or admin of that subdomain. Workflows that still type an email and API token into a Zendesk block keep working unchanged. {/* MANUAL-CONTENT-END */} ## Use the block [#usage-instructions] @@ -29,9 +38,6 @@ Retrieve a list of tickets from Zendesk with optional filtering | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `email` | string | Yes | Your Zendesk email address | -| `apiToken` | string | Yes | Zendesk API token | -| `subdomain` | string | Yes | Your Zendesk subdomain \(e.g., "mycompany" for mycompany.zendesk.com\) | | `status` | string | No | Filter by status: "new", "open", "pending", "hold", "solved", or "closed" | | `priority` | string | No | Filter by priority: "low", "normal", "high", or "urgent" | | `type` | string | No | Filter by type: "problem", "incident", "question", or "task" | @@ -118,9 +124,6 @@ Get a single ticket by ID from Zendesk | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `email` | string | Yes | Your Zendesk email address | -| `apiToken` | string | Yes | Zendesk API token | -| `subdomain` | string | Yes | Your Zendesk subdomain | | `ticketId` | string | Yes | Ticket ID to retrieve as a numeric string \(e.g., "12345"\) | #### Output @@ -194,9 +197,6 @@ Create a new ticket in Zendesk with support for custom fields | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `email` | string | Yes | Your Zendesk email address | -| `apiToken` | string | Yes | Zendesk API token | -| `subdomain` | string | Yes | Your Zendesk subdomain | | `subject` | string | No | Ticket subject \(optional - will be auto-generated if not provided\) | | `description` | string | Yes | Ticket description text \(first comment\) | | `priority` | string | No | Priority: "low", "normal", "high", or "urgent" | @@ -279,9 +279,6 @@ Create multiple tickets in Zendesk at once (max 100) | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `email` | string | Yes | Your Zendesk email address | -| `apiToken` | string | Yes | Zendesk API token | -| `subdomain` | string | Yes | Your Zendesk subdomain | | `tickets` | string | Yes | JSON array of ticket objects to create \(max 100\). Each ticket should have subject and comment properties \(e.g., \[\{"subject": "Issue 1", "comment": \{"body": "Description"\}\}\]\) | #### Output @@ -317,9 +314,6 @@ Update an existing ticket in Zendesk with support for custom fields | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `email` | string | Yes | Your Zendesk email address | -| `apiToken` | string | Yes | Zendesk API token | -| `subdomain` | string | Yes | Your Zendesk subdomain | | `ticketId` | string | Yes | Ticket ID to update as a numeric string \(e.g., "12345"\) | | `subject` | string | No | New ticket subject text | | `comment` | string | No | Comment text to add to the ticket | @@ -402,9 +396,6 @@ Update multiple tickets in Zendesk at once (max 100) | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `email` | string | Yes | Your Zendesk email address | -| `apiToken` | string | Yes | Zendesk API token | -| `subdomain` | string | Yes | Your Zendesk subdomain | | `ticketIds` | string | Yes | Comma-separated ticket IDs to update \(max 100, e.g., "111, 222, 333"\) | | `status` | string | No | New status for all tickets: "new", "open", "pending", "hold", "solved", or "closed" | | `priority` | string | No | New priority for all tickets: "low", "normal", "high", or "urgent" | @@ -445,9 +436,6 @@ Delete a ticket from Zendesk | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `email` | string | Yes | Your Zendesk email address | -| `apiToken` | string | Yes | Zendesk API token | -| `subdomain` | string | Yes | Your Zendesk subdomain | | `ticketId` | string | Yes | Ticket ID to delete as a numeric string \(e.g., "12345"\) | #### Output @@ -469,9 +457,6 @@ Merge multiple tickets into a target ticket | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `email` | string | Yes | Your Zendesk email address | -| `apiToken` | string | Yes | Zendesk API token | -| `subdomain` | string | Yes | Your Zendesk subdomain | | `targetTicketId` | string | Yes | Target ticket ID as a numeric string \(tickets will be merged into this one, e.g., "12345"\) | | `sourceTicketIds` | string | Yes | Comma-separated source ticket IDs to merge \(e.g., "111, 222, 333"\) | | `targetComment` | string | No | Comment text to add to target ticket after merge | @@ -510,9 +495,6 @@ Retrieve a list of users from Zendesk with optional filtering | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `email` | string | Yes | Your Zendesk email address | -| `apiToken` | string | Yes | Zendesk API token | -| `subdomain` | string | Yes | Your Zendesk subdomain \(e.g., "mycompany" for mycompany.zendesk.com\) | | `role` | string | No | Filter by role: "end-user", "agent", or "admin" | | `permissionSet` | string | No | Filter by permission set ID as a numeric string \(e.g., "12345"\) | | `perPage` | string | No | Results per page as a number string \(default: "100", max: "100"\) | @@ -584,9 +566,6 @@ Get a single user by ID from Zendesk | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `email` | string | Yes | Your Zendesk email address | -| `apiToken` | string | Yes | Zendesk API token | -| `subdomain` | string | Yes | Your Zendesk subdomain | | `userId` | string | Yes | User ID to retrieve as a numeric string \(e.g., "12345"\) | #### Output @@ -649,9 +628,6 @@ Get the currently authenticated user from Zendesk | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `email` | string | Yes | Your Zendesk email address | -| `apiToken` | string | Yes | Zendesk API token | -| `subdomain` | string | Yes | Your Zendesk subdomain | #### Output @@ -713,9 +689,6 @@ Search for users in Zendesk using a query string | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `email` | string | Yes | Your Zendesk email address | -| `apiToken` | string | Yes | Zendesk API token | -| `subdomain` | string | Yes | Your Zendesk subdomain | | `query` | string | No | Search query string \(e.g., user name or email\) | | `externalId` | string | No | External ID to search by \(your system identifier\) | | `perPage` | string | No | Results per page as a number string \(default: "100", max: "100"\) | @@ -787,9 +760,6 @@ Create a new user in Zendesk | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `email` | string | Yes | Your Zendesk email address | -| `apiToken` | string | Yes | Zendesk API token | -| `subdomain` | string | Yes | Your Zendesk subdomain | | `name` | string | Yes | User full name \(e.g., "John Smith"\) | | `userEmail` | string | No | User email address \(e.g., "john@example.com"\) | | `role` | string | No | User role: "end-user", "agent", or "admin" | @@ -859,9 +829,6 @@ Create multiple users in Zendesk using bulk import | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `email` | string | Yes | Your Zendesk email address | -| `apiToken` | string | Yes | Zendesk API token | -| `subdomain` | string | Yes | Your Zendesk subdomain | | `users` | string | Yes | JSON array of user objects to create \(e.g., \[\{"name": "User1", "email": "user1@example.com"\}\]\) | #### Output @@ -897,9 +864,6 @@ Update an existing user in Zendesk | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `email` | string | Yes | Your Zendesk email address | -| `apiToken` | string | Yes | Zendesk API token | -| `subdomain` | string | Yes | Your Zendesk subdomain | | `userId` | string | Yes | User ID to update as a numeric string \(e.g., "12345"\) | | `name` | string | No | New user full name \(e.g., "John Smith"\) | | `userEmail` | string | No | New user email address \(e.g., "john@example.com"\) | @@ -970,9 +934,6 @@ Update multiple users in Zendesk using bulk update | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `email` | string | Yes | Your Zendesk email address | -| `apiToken` | string | Yes | Zendesk API token | -| `subdomain` | string | Yes | Your Zendesk subdomain | | `users` | string | Yes | JSON array of user objects to update, each must include id field \(e.g., \[\{"id": "123", "name": "New Name"\}\]\) | #### Output @@ -1008,9 +969,6 @@ Delete a user from Zendesk | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `email` | string | Yes | Your Zendesk email address | -| `apiToken` | string | Yes | Zendesk API token | -| `subdomain` | string | Yes | Your Zendesk subdomain | | `userId` | string | Yes | User ID to delete as a numeric string \(e.g., "12345"\) | #### Output @@ -1032,9 +990,6 @@ Retrieve a list of organizations from Zendesk | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `email` | string | Yes | Your Zendesk email address | -| `apiToken` | string | Yes | Zendesk API token | -| `subdomain` | string | Yes | Your Zendesk subdomain \(e.g., "mycompany" for mycompany.zendesk.com\) | | `perPage` | string | No | Results per page as a number string \(default: "100", max: "100"\) | | `pageAfter` | string | No | Cursor from a previous response to fetch the next page of results | @@ -1077,9 +1032,6 @@ Get a single organization by ID from Zendesk | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `email` | string | Yes | Your Zendesk email address | -| `apiToken` | string | Yes | Zendesk API token | -| `subdomain` | string | Yes | Your Zendesk subdomain | | `organizationId` | string | Yes | Organization ID to retrieve as a numeric string \(e.g., "12345"\) | #### Output @@ -1115,9 +1067,6 @@ Autocomplete organizations in Zendesk by name prefix (for name matching/autocomp | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `email` | string | Yes | Your Zendesk email address | -| `apiToken` | string | Yes | Zendesk API token | -| `subdomain` | string | Yes | Your Zendesk subdomain | | `name` | string | Yes | Organization name prefix to search for \(e.g., "Acme"\) | | `perPage` | string | No | Results per page as a number string \(default: "100", max: "100"\) | | `page` | string | No | Page number for pagination \(1-based\) | @@ -1161,9 +1110,6 @@ Create a new organization in Zendesk | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `email` | string | Yes | Your Zendesk email address | -| `apiToken` | string | Yes | Zendesk API token | -| `subdomain` | string | Yes | Your Zendesk subdomain | | `name` | string | Yes | Organization name \(e.g., "Acme Corporation"\) | | `domainNames` | string | No | Comma-separated domain names \(e.g., "acme.com, acme.org"\) | | `details` | string | No | Organization details text | @@ -1204,9 +1150,6 @@ Create multiple organizations in Zendesk using bulk import | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `email` | string | Yes | Your Zendesk email address | -| `apiToken` | string | Yes | Zendesk API token | -| `subdomain` | string | Yes | Your Zendesk subdomain | | `organizations` | string | Yes | JSON array of organization objects to create \(e.g., \[\{"name": "Org1"\}, \{"name": "Org2"\}\]\) | #### Output @@ -1242,9 +1185,6 @@ Update an existing organization in Zendesk | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `email` | string | Yes | Your Zendesk email address | -| `apiToken` | string | Yes | Zendesk API token | -| `subdomain` | string | Yes | Your Zendesk subdomain | | `organizationId` | string | Yes | Organization ID to update as a numeric string \(e.g., "12345"\) | | `name` | string | No | New organization name \(e.g., "Acme Corporation"\) | | `domainNames` | string | No | Comma-separated domain names \(e.g., "acme.com, acme.org"\) | @@ -1286,9 +1226,6 @@ Delete an organization from Zendesk | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `email` | string | Yes | Your Zendesk email address | -| `apiToken` | string | Yes | Zendesk API token | -| `subdomain` | string | Yes | Your Zendesk subdomain | | `organizationId` | string | Yes | Organization ID to delete as a numeric string \(e.g., "12345"\) | #### Output @@ -1310,9 +1247,6 @@ Unified search across tickets, users, and organizations in Zendesk | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `email` | string | Yes | Your Zendesk email address | -| `apiToken` | string | Yes | Zendesk API token | -| `subdomain` | string | Yes | Your Zendesk subdomain | | `query` | string | Yes | Search query string using Zendesk search syntax \(e.g., "type:ticket status:open"\) | | `filterType` | string | Yes | Resource type to search for: "ticket", "user", "organization", or "group" | | `perPage` | string | No | Results per page as a number string \(default: "100", max: "100"\) | @@ -1343,9 +1277,6 @@ Count the number of search results matching a query in Zendesk | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `email` | string | Yes | Your Zendesk email address | -| `apiToken` | string | Yes | Zendesk API token | -| `subdomain` | string | Yes | Your Zendesk subdomain | | `query` | string | Yes | Search query string using Zendesk search syntax \(e.g., "type:ticket status:open"\) | #### Output diff --git a/apps/docs/content/docs/integrations/zoominfo.mdx b/apps/docs/content/docs/integrations/zoominfo.mdx index aedf441c1ba..39481c9a471 100644 --- a/apps/docs/content/docs/integrations/zoominfo.mdx +++ b/apps/docs/content/docs/integrations/zoominfo.mdx @@ -2,7 +2,7 @@ title: ZoomInfo description: Search and enrich B2B company and contact data with ZoomInfo. integration: - type: "zoominfo" + type: "zoominfo_v2" color: "#EA1B15" --- @@ -16,7 +16,7 @@ Use [ZoomInfo](https://www.zoominfo.com/) to search company and contact data, en **Search Intent** returns companies researching selected topics, signal scores, and recommended contacts. **Search News** filters by category, source URL, or publication date. -Supply a client ID and secret. Sim exchanges them for a short-lived OAuth bearer token on each call; you do not supply the access token yourself. +Connect ZoomInfo once as a ZoomInfo credential: create a client credentials app in the ZoomInfo Developer Portal, choose the integration user it acts as, and add its client ID and client secret. Sim checks the credential by requesting a token, without calling a data endpoint, and requests a new token before each one expires. Every call acts as the app's integration user with the scopes configured on the app, and data calls consume credits as usual. Workflows built with the earlier ZoomInfo block keep the client ID and secret they entered in the block. {/* MANUAL-CONTENT-END */} ## Use the block [#usage-instructions] @@ -35,8 +35,6 @@ Search the ZoomInfo company database by name, industry, location, and size. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | ZoomInfo OAuth client ID | -| `clientSecret` | string | Yes | ZoomInfo OAuth client secret | | `companyName` | string | No | Company name to search for | | `companyWebsite` | string | No | Company website \(comma-separated for multiple\) | | `companyTicker` | string | No | Stock ticker symbols — JSON array, comma-separated list, or single ticker. Sent to the API as an array. | @@ -75,8 +73,6 @@ Search ZoomInfo for contacts (people) by name, job title, company, and other fil | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | ZoomInfo OAuth client ID | -| `clientSecret` | string | Yes | ZoomInfo OAuth client secret | | `firstName` | string | No | First name | | `lastName` | string | No | Last name | | `fullName` | string | No | Full name | @@ -115,8 +111,6 @@ Enrich up to 25 companies in one request with detailed firmographics, industry, | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | ZoomInfo OAuth client ID | -| `clientSecret` | string | Yes | ZoomInfo OAuth client secret | | `matchCompanyInput` | string | Yes | JSON array \(1-25 items\) of company matching criteria, e.g. \[\{"companyName":"Acme","companyWebsite":"acme.com"\}\] | | `outputFields` | string | No | JSON array or comma-separated list of fields to return \(e.g. \["id","name","website","revenue","employeeCount"\]\). Defaults to a standard firmographic set if omitted. | @@ -138,8 +132,6 @@ Enrich up to 25 contacts in one request with verified emails, phone numbers, job | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | ZoomInfo OAuth client ID | -| `clientSecret` | string | Yes | ZoomInfo OAuth client secret | | `matchPersonInput` | string | Yes | JSON array \(1-25 items\) of contact matching criteria, e.g. \[\{"firstName":"Jane","lastName":"Doe","companyName":"Acme"\}\] | | `outputFields` | string | No | JSON array or comma-separated list of fields to return \(e.g. \["id","firstName","email","phone","jobTitle"\]\). Defaults to a standard contact set if omitted. | | `requiredFields` | string | No | JSON array or comma-separated list of fields that must exist in results \(e.g. \["email"\]\) | @@ -162,8 +154,6 @@ Search for companies showing intent signals on specific topics. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | ZoomInfo OAuth client ID | -| `clientSecret` | string | Yes | ZoomInfo OAuth client secret | | `topics` | string | Yes | Up to 50 intent topics as JSON array or comma-separated list \(e.g. \["CRM Software","Marketing Automation"\]\) | | `signalStartDate` | string | No | Earliest signal date \(YYYY-MM-DD\) | | `signalEndDate` | string | No | Latest signal date \(YYYY-MM-DD\) | @@ -199,8 +189,6 @@ Search ZoomInfo news articles by category, URL, or date range. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `clientId` | string | Yes | ZoomInfo OAuth client ID | -| `clientSecret` | string | Yes | ZoomInfo OAuth client secret | | `categories` | string | No | News categories as JSON array or comma-separated list | | `url` | string | No | News source URLs as JSON array or comma-separated list | | `pageDateMin` | string | No | Earliest publish date \(YYYY-MM-DD\) | diff --git a/apps/docs/content/docs/platform/self-hosting/environment-variables.mdx b/apps/docs/content/docs/platform/self-hosting/environment-variables.mdx index 9261787a85d..11412054792 100644 --- a/apps/docs/content/docs/platform/self-hosting/environment-variables.mdx +++ b/apps/docs/content/docs/platform/self-hosting/environment-variables.mdx @@ -167,7 +167,7 @@ Saved AWS, Databricks, Snowflake, Microsoft app, and Google Cloud credentials ca | Variable | Description | | --- | --- | | `WORKLOAD_IDENTITY_SIGNING_KEY` | PKCS#8 PEM RSA private key that signs Sim's workload-identity tokens: `openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048` | -| `WORKLOAD_IDENTITY_ISSUER` | Issuer URL clouds trust. Defaults to `/oidc/workload`; must be `https` on the default port, with no query or fragment, and reachable from the public internet, or keyless stays off. Never change it once trust policies reference it. | +| `WORKLOAD_IDENTITY_ISSUER` | Issuer URL clouds trust. Defaults to `/oidc/workload`; must be `https` on the default port, with no query or fragment, and reachable from the public internet, or keyless stays off. A pinned issuer may use another host that reaches Sim; its path should end in `/oidc/workload`, where Sim serves discovery and keys, and any other path works only if a proxy routes it there (Sim logs a warning). Never change it once trust policies reference it. | | `WORKLOAD_IDENTITY_PREVIOUS_KEY` | A second published key (private or public PEM) that never signs: the incoming key before a rotation, or the retiring key after it | See [AWS credentials](/integrations/aws-credentials#self-hosted-deployments) for setup and the rotation steps. diff --git a/apps/docs/content/integration-navigation.json b/apps/docs/content/integration-navigation.json index 17a8067e6a1..1f757240e19 100644 --- a/apps/docs/content/integration-navigation.json +++ b/apps/docs/content/integration-navigation.json @@ -47,7 +47,7 @@ }, "hubspot-service-account": { "integration": "hubspot", - "title": "Private app tokens" + "title": "Service keys and private app tokens" }, "hubspot-setup": { "integration": "hubspot", diff --git a/apps/docs/openapi-v2-workflows.json b/apps/docs/openapi-v2-workflows.json index d18c6fee64d..c8c15ead5ab 100644 --- a/apps/docs/openapi-v2-workflows.json +++ b/apps/docs/openapi-v2-workflows.json @@ -19739,6 +19739,10 @@ "planetscale.branches", "planetscale.backups", "planetscale.deployRequests", + "planetscale.savedDatabases", + "planetscale.savedBranches", + "planetscale.savedBackups", + "planetscale.savedDeployRequests", "cloudwatch.logGroups", "cloudwatch.logStreams", "cloudwatch.savedLogGroups", @@ -19938,6 +19942,10 @@ "planetscale.branches", "planetscale.backups", "planetscale.deployRequests", + "planetscale.savedDatabases", + "planetscale.savedBranches", + "planetscale.savedBackups", + "planetscale.savedDeployRequests", "cloudwatch.logGroups", "cloudwatch.logStreams", "cloudwatch.savedLogGroups", diff --git a/apps/sim/app/api/auth/oauth/credentials/route.test.ts b/apps/sim/app/api/auth/oauth/credentials/route.test.ts index 4f4329eda28..2d0724db92b 100644 --- a/apps/sim/app/api/auth/oauth/credentials/route.test.ts +++ b/apps/sim/app/api/auth/oauth/credentials/route.test.ts @@ -166,6 +166,69 @@ describe('OAuth Credentials API Route', () => { ) }) + describe('Vertex AI account listing', () => { + const workspaceId = '3f1c8a54-1c2e-4a1b-9d6e-2b7c5a9f0e11' + + beforeEach(() => { + hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockReset().mockResolvedValue({ + success: true, + userId: 'user-123', + userName: 'Test User', + userEmail: 'test@example.com', + authType: 'session', + }) + permissionsMockFns.mockCheckWorkspaceAccess.mockResolvedValue({ + exists: true, + hasAccess: true, + canWrite: true, + canAdmin: true, + }) + permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue( + DEFAULT_PERMISSION_GROUP_CONFIG + ) + }) + + it('lists the workspace’s Google service accounts beside its Vertex AI OAuth accounts', async () => { + const updatedAt = new Date('2026-01-01T00:00:00Z') + dbChainMockFns.where + .mockResolvedValueOnce([ + { + id: 'vertex-oauth-1', + displayName: 'Vertex account', + providerId: 'vertex-ai', + scope: getCanonicalScopesForProvider('vertex-ai').join(' '), + updatedAt, + }, + ]) + .mockResolvedValueOnce([ + { + id: 'google-sa-1', + displayName: 'Keyless Vertex', + providerId: 'google-service-account', + updatedAt, + }, + ]) + + const response = await GET( + createMockRequestWithQuery('GET', `?provider=vertex-ai&workspaceId=${workspaceId}`), + { params: Promise.resolve({}) } + ) + + expect(response.status).toBe(200) + const { credentials } = await response.json() + expect( + credentials.map((credential: { id: string; type: string; provider: string }) => ({ + id: credential.id, + type: credential.type, + provider: credential.provider, + })) + ).toEqual([ + { id: 'vertex-oauth-1', type: 'oauth', provider: 'vertex-ai' }, + { id: 'google-sa-1', type: 'service_account', provider: 'google-service-account' }, + ]) + }) + }) + /** The session/executor split documented on {@link integrationsWithheldFromSession} in the route. */ describe('integrations.manage', () => { const INTEGRATIONS_WITHHELD = { diff --git a/apps/sim/app/api/credentials/route.test.ts b/apps/sim/app/api/credentials/route.test.ts index 2491d6af8c0..73321238d2d 100644 --- a/apps/sim/app/api/credentials/route.test.ts +++ b/apps/sim/app/api/credentials/route.test.ts @@ -409,4 +409,44 @@ describe('POST /api/credentials', () => { expect(body).not.toContain('exact passphrase') expect(body).not.toContain('encrypted-oci-blob') }) + + it('forwards a password with surrounding spaces unchanged', async () => { + mockVerifyAndBuildServiceAccountSecret.mockRejectedValueOnce( + new TokenServiceAccountValidationError('invalid_credentials', 400, { step: 'verify' }) + ) + + await POST( + createMockRequest('POST', { + workspaceId: WORKSPACE_ID, + type: 'service_account', + providerId: 'servicenow-service-account', + apiToken: ' pass word ', + providerWorkspaceId: 'integration.user', + domain: 'acme.service-now.com', + }) + ) + + expect(mockVerifyAndBuildServiceAccountSecret).toHaveBeenCalledWith( + 'servicenow-service-account', + expect.objectContaining({ apiToken: ' pass word ' }) + ) + }) + + it('refuses a blank token before verifying it', async () => { + mockVerifyAndBuildServiceAccountSecret.mockClear() + + const response = await POST( + createMockRequest('POST', { + workspaceId: WORKSPACE_ID, + type: 'service_account', + providerId: 'servicenow-service-account', + apiToken: ' ', + providerWorkspaceId: 'integration.user', + domain: 'acme.service-now.com', + }) + ) + + expect(response.status).toBe(400) + expect(mockVerifyAndBuildServiceAccountSecret).not.toHaveBeenCalled() + }) }) diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/client-credentials-fields.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/client-credentials-fields.tsx new file mode 100644 index 00000000000..8528724014f --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/client-credentials-fields.tsx @@ -0,0 +1,58 @@ +'use client' + +import { ChipModalField, SecretInput } from '@sim/emcn' + +interface ClientCredentialsFieldsProps { + clientId: string + clientSecret: string + onClientIdChange: (value: string) => void + onClientSecretChange: (value: string) => void + /** Guidance under the client ID field, such as the scopes the credential needs. */ + clientIdHint?: string + /** Form field name for the secret, distinct per provider so password managers keep them apart. */ + secretInputName: string +} + +/** + * The client ID and secret a credential's client-credentials mode signs in with, shared by every + * connect form that offers that mode so their labels, autofill behavior, and accessibility match. + */ +export function ClientCredentialsFields({ + clientId, + clientSecret, + onClientIdChange, + onClientSecretChange, + clientIdHint, + secretInputName, +}: ClientCredentialsFieldsProps) { + return ( + <> + + + {(aria) => ( + + )} + + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/connect-service-account-modal.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/connect-service-account-modal.tsx index 15249331c63..b1f42e51c46 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/connect-service-account-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/connect-service-account-modal.tsx @@ -32,6 +32,7 @@ import { } from '@/lib/credentials/google-workload-identity' import type { ServiceAccountSecretFields } from '@/lib/credentials/service-account-secret' import { + CLIENT_CREDENTIALS_AUTH_METHOD, getTokenServiceAccountDescriptor, SNOWFLAKE_SERVICE_ACCOUNT_PROVIDER_ID, type TokenServiceAccountProviderId, @@ -49,6 +50,7 @@ import { } from '@/lib/oauth/types' import { AwsAccountModal } from '@/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/aws-account-modal' import { ClientCredentialAccountModal } from '@/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/client-credential-account-modal' +import { ClientCredentialsFields } from '@/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/client-credentials-fields' import { DatabricksAccountModal } from '@/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/databricks-account-modal' import { buildGoogleKeylessConfig, @@ -127,8 +129,18 @@ const ATLASSIAN_ERROR_MESSAGES: Record = { duplicate_display_name: 'A credential with that name already exists in this workspace.', atlassian_unavailable: "We couldn't reach Atlassian to verify these credentials. Try again in a moment.", + provider_unavailable: + "We couldn't reach Atlassian to verify these credentials. Try again in a moment.", +} + +/** Copy that differs when the service account signs in with an OAuth 2.0 credential. */ +const ATLASSIAN_CLIENT_CREDENTIAL_ERROR_MESSAGES: Record = { + invalid_credentials: + "We couldn't authenticate with that client ID and secret. Check that they belong to an OAuth 2.0 credential of a service account with access to this site and product.", } +type AtlassianAuthMethod = 'api_token' | typeof CLIENT_CREDENTIALS_AUTH_METHOD + const FALLBACK_ERROR_MESSAGE = "We couldn't add this service account. Try again in a moment." function normalizeAtlassianDomain(raw: string): string { @@ -138,11 +150,13 @@ function normalizeAtlassianDomain(raw: string): string { .replace(/\/+$/, '') } -function messageForAtlassianError(err: unknown): string { - if (isApiClientError(err) && err.code && ATLASSIAN_ERROR_MESSAGES[err.code]) { - return ATLASSIAN_ERROR_MESSAGES[err.code] - } - return FALLBACK_ERROR_MESSAGE +function messageForAtlassianError(err: unknown, authMethod: AtlassianAuthMethod | null): string { + if (!isApiClientError(err) || !err.code) return FALLBACK_ERROR_MESSAGE + const clientMessage = + authMethod === CLIENT_CREDENTIALS_AUTH_METHOD + ? ATLASSIAN_CLIENT_CREDENTIAL_ERROR_MESSAGES[err.code] + : undefined + return clientMessage ?? ATLASSIAN_ERROR_MESSAGES[err.code] ?? FALLBACK_ERROR_MESSAGE } interface ConnectServiceAccountModalProps { @@ -960,7 +974,16 @@ function AtlassianServiceAccountModalForm({ initialDescription, onCreated, }: AtlassianServiceAccountModalProps) { + /** + * A reconnect cannot pre-select the sign-in: the stored one lives inside the encrypted secret + * and is never returned to the client, so the admin restates it while retyping the secret. + */ + const [authMethod, setAuthMethod] = useState( + credentialId ? null : 'api_token' + ) const [apiToken, setApiToken] = useState('') + const [clientId, setClientId] = useState('') + const [clientSecret, setClientSecret] = useState('') const [domain, setDomain] = useState('') const [displayName, setDisplayName] = useState(initialDisplayName ?? '') const [description, setDescription] = useState(initialDescription ?? '') @@ -969,26 +992,41 @@ function AtlassianServiceAccountModalForm({ const createCredential = useCreateScopedCredential() const updateCredential = useUpdateScopedCredential() + const usesClientCredentials = authMethod === CLIENT_CREDENTIALS_AUTH_METHOD const trimmedToken = apiToken.trim() const normalizedDomain = normalizeAtlassianDomain(domain) const showDomainHint = normalizedDomain.length > 0 && !ATLASSIAN_DOMAIN_HINT_REGEX.test(normalizedDomain) const isPending = createCredential.isPending || updateCredential.isPending - const isDisabled = !trimmedToken || !normalizedDomain || isPending + const isDisabled = + !authMethod || + (usesClientCredentials ? !clientId.trim() || !clientSecret.trim() : !trimmedToken) || + !normalizedDomain || + isPending + + const clearError = () => { + if (error) setError(null) + } const handleSubmit = async () => { setError(null) if (isDisabled) return + const secretFields = usesClientCredentials + ? { + authMethod: CLIENT_CREDENTIALS_AUTH_METHOD, + clientId: clientId.trim(), + clientSecret: clientSecret.trim(), + domain: normalizedDomain, + } + : { apiToken: trimmedToken, domain: normalizedDomain } try { let connectedCredentialId = credentialId if (credentialId) { await updateCredential.mutateAsync({ ...resourceScopeFields(resourceScopeFromOwner({ workspaceId, organizationId })), credentialId, - apiToken: trimmedToken, - domain: normalizedDomain, - + ...secretFields, displayName: displayName.trim() || undefined, description: description.trim() || undefined, }) @@ -997,8 +1035,7 @@ function AtlassianServiceAccountModalForm({ ...resourceScopeFields(resourceScopeFromOwner({ workspaceId, organizationId })), type: 'service_account', providerId: ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID, - apiToken: trimmedToken, - domain: normalizedDomain, + ...secretFields, atlassianProduct, displayName: displayName.trim() || undefined, description: description.trim() || undefined, @@ -1008,7 +1045,7 @@ function AtlassianServiceAccountModalForm({ if (connectedCredentialId) onCreated?.(connectedCredentialId) onOpenChange(false) } catch (err: unknown) { - setError(messageForAtlassianError(err)) + setError(messageForAtlassianError(err, authMethod)) logger.error('Failed to add Atlassian service account credential', err) } } @@ -1024,33 +1061,77 @@ function AtlassianServiceAccountModalForm({ { + setAuthMethod(value as AtlassianAuthMethod) + clearError() + }} + options={[ + { value: 'api_token', label: 'Scoped API token' }, + { + value: CLIENT_CREDENTIALS_AUTH_METHOD, + label: 'OAuth 2.0 credential (client credentials)', + }, + ]} required hint={ - atlassianProduct === 'confluence' - ? 'Use a service-account token with Confluence access. Required scopes are listed in the setup guide.' - : ATLASSIAN_COVERAGE_HINT + usesClientCredentials + ? 'Atlassian supports these credentials for Jira and Confluence.' + : undefined } - > - {(aria) => ( - { - setApiToken(value) - if (error) setError(null) - }} - placeholder='Paste API token' - name='atlassian_service_account_api_token' - autoComplete='new-password' - autoCorrect='off' - autoCapitalize='off' - data-lpignore='true' - data-form-type='other' - /> - )} - + /> + + {usesClientCredentials ? ( + { + setClientId(value) + clearError() + }} + onClientSecretChange={(value) => { + setClientSecret(value) + clearError() + }} + clientIdHint={ + atlassianProduct === 'confluence' + ? 'Use an OAuth 2.0 credential with Confluence scopes. Required scopes are listed in the setup guide.' + : 'Use an OAuth 2.0 credential with Jira scopes. Jira Service Management needs a scoped API token.' + } + secretInputName='atlassian_service_account_client_secret' + /> + ) : authMethod === 'api_token' ? ( + + {(aria) => ( + { + setApiToken(value) + clearError() + }} + placeholder='Paste API token' + name='atlassian_service_account_api_token' + autoComplete='new-password' + autoCorrect='off' + autoCapitalize='off' + data-lpignore='true' + data-form-type='other' + /> + )} + + ) : null} (null) + /** + * A reconnect cannot pre-select the mode: the stored one lives inside the encrypted secret and + * is never returned to the client, so the admin restates it while retyping the secret. Until + * then neither mode's fields show, so a reconnect never switches modes by default. + */ + const mustRestateAuthMethod = Boolean(clientCredentials && credentialId && !selectedAuthMethod) + const authMethod: TokenAuthMethod | null = + selectedAuthMethod ?? + (mustRestateAuthMethod + ? null + : clientCredentials?.preferred + ? CLIENT_CREDENTIALS_AUTH_METHOD + : 'token') + const usesClientCredentials = authMethod === CLIENT_CREDENTIALS_AUTH_METHOD const [apiToken, setApiToken] = useState('') + const [clientId, setClientId] = useState('') + const [clientSecret, setClientSecret] = useState('') const [domain, setDomain] = useState('') const [providerWorkspaceId, setProviderWorkspaceId] = useState('') const [displayName, setDisplayName] = useState(initialDisplayName ?? '') @@ -114,7 +136,8 @@ function TokenServiceAccountModalForm({ const isPending = createCredential.isPending || updateCredential.isPending || submitPending === true const isDisabled = - !trimmedToken || + !authMethod || + (usesClientCredentials ? !clientId.trim() || !clientSecret.trim() : !trimmedToken) || (Boolean(domainField) && !normalizedDomain) || (Boolean(workspaceField) && !providerWorkspaceId.trim()) || isPending @@ -129,7 +152,13 @@ function TokenServiceAccountModalForm({ if (isDisabled) return try { const secretFields = { - apiToken: trimmedToken, + ...(usesClientCredentials + ? { + authMethod: CLIENT_CREDENTIALS_AUTH_METHOD, + clientId: clientId.trim(), + clientSecret: clientSecret.trim(), + } + : { apiToken: tokenField?.preserveWhitespace ? apiToken : trimmedToken }), ...(workspaceField ? { providerWorkspaceId: providerWorkspaceId.trim() } : {}), ...(domainField ? { domain: normalizedDomain } : {}), } @@ -170,7 +199,8 @@ function TokenServiceAccountModalForm({ getTokenServiceAccountErrorMessage( descriptor, code, - onSubmitCredentials ? getErrorMessage(err, 'Could not save this connection.') : undefined + onSubmitCredentials ? getErrorMessage(err, 'Could not save this connection.') : undefined, + authMethod ?? undefined ) ) logger.error(`Failed to add ${descriptor.serviceLabel} service account credential`, err) @@ -187,7 +217,41 @@ function TokenServiceAccountModalForm({ Add {serviceName} {descriptor.connectNoun} - {tokenField && ( + {clientCredentials && ( + { + setSelectedAuthMethod(value as TokenAuthMethod) + if (error) setError(null) + }} + options={[ + { value: CLIENT_CREDENTIALS_AUTH_METHOD, label: clientCredentials.label }, + { value: 'token', label: clientCredentials.tokenLabel }, + ]} + required + /> + )} + + {clientCredentials && usesClientCredentials && ( + { + setClientId(value) + if (error) setError(null) + }} + onClientSecretChange={(value) => { + setClientSecret(value) + if (error) setError(null) + }} + clientIdHint={clientCredentials.helpText} + secretInputName={`${descriptor.providerId}_client_secret`} + /> + )} + + {tokenField && authMethod === 'token' && ( = Object.freeze({ export const CrowdStrikeBlock: BlockConfig = { type: 'crowdstrike', + hideFromToolbar: true, + sunset: { status: 'legacy', replacedBy: 'crowdstrike_v2' }, name: 'CrowdStrike', description: 'Investigate and respond to CrowdStrike Falcon alerts, hosts, IOCs, and vulnerabilities', @@ -1296,3 +1299,17 @@ export const CrowdStrikeBlockMeta = { }, ], } as const satisfies BlockMeta + +export const CrowdStrikeV2Block: BlockConfig = { + ...CrowdStrikeBlock, + type: 'crowdstrike_v2', + authMode: AuthMode.OAuth, + hideFromToolbar: false, + sunset: undefined, + ...withSavedCredential(CrowdStrikeBlock, { + id: 'credential', + type: 'oauth-input', + serviceId: 'crowdstrike', + title: 'CrowdStrike Credential', + }), +} diff --git a/apps/sim/blocks/blocks/datadog.ts b/apps/sim/blocks/blocks/datadog.ts index 7d0ca1d191d..e9f6fae27c2 100644 --- a/apps/sim/blocks/blocks/datadog.ts +++ b/apps/sim/blocks/blocks/datadog.ts @@ -1,6 +1,8 @@ import { DatadogIcon } from '@/components/icons' import type { BlockConfig, BlockMeta } from '@/blocks/types' import { AuthMode, IntegrationType } from '@/blocks/types' +import { withSavedCredential } from '@/blocks/utils' +import { DATADOG_SITE_LABELS, DATADOG_SITES } from '@/tools/datadog/types' /** * Normalizes a `switch` sub-block value. Switches serialize their state as the @@ -33,6 +35,8 @@ function datadogNumber(value: unknown): number | undefined { export const DatadogBlock: BlockConfig = { type: 'datadog', + hideFromToolbar: true, + sunset: { status: 'legacy', replacedBy: 'datadog_v2' }, name: 'Datadog', description: 'Monitor infrastructure, applications, and logs with Datadog', authMode: AuthMode.ApiKey, @@ -1906,17 +1910,7 @@ Return ONLY the search query string - no explanations.`, id: 'site', title: 'Datadog Site', type: 'dropdown', - options: [ - { label: 'US1 (datadoghq.com)', id: 'datadoghq.com' }, - { label: 'US3 (us3.datadoghq.com)', id: 'us3.datadoghq.com' }, - { label: 'US5 (us5.datadoghq.com)', id: 'us5.datadoghq.com' }, - { label: 'EU (datadoghq.eu)', id: 'datadoghq.eu' }, - { label: 'AP1 (ap1.datadoghq.com)', id: 'ap1.datadoghq.com' }, - { label: 'AP2 (ap2.datadoghq.com)', id: 'ap2.datadoghq.com' }, - { label: 'UK1 (uk1.datadoghq.com)', id: 'uk1.datadoghq.com' }, - { label: 'US1-FED (ddog-gov.com)', id: 'ddog-gov.com' }, - { label: 'US2-FED (us2.ddog-gov.com)', id: 'us2.ddog-gov.com' }, - ], + options: DATADOG_SITES.map((site) => ({ label: DATADOG_SITE_LABELS[site], id: site })), value: () => 'datadoghq.com', mode: 'advanced', }, @@ -2625,3 +2619,18 @@ export const DatadogBlockMeta = { }, ], } as const satisfies BlockMeta + +export const DatadogV2Block: BlockConfig = { + ...DatadogBlock, + type: 'datadog_v2', + authMode: AuthMode.OAuth, + hideFromToolbar: false, + sunset: undefined, + ...withSavedCredential( + DatadogBlock, + { id: 'credential', type: 'oauth-input', serviceId: 'datadog', title: 'Datadog Credential' }, + // The API key shows in every operation and the application key only where it is sent, + // and the site is optional; one credential carries all three, in every operation. + { condition: null, required: true } + ), +} diff --git a/apps/sim/blocks/blocks/gong.ts b/apps/sim/blocks/blocks/gong.ts index 4bb41f893c3..7599830cc87 100644 --- a/apps/sim/blocks/blocks/gong.ts +++ b/apps/sim/blocks/blocks/gong.ts @@ -1,9 +1,12 @@ import { GongIcon } from '@/components/icons' import { AuthMode, type BlockConfig, type BlockMeta, IntegrationType } from '@/blocks/types' +import { withSavedCredential } from '@/blocks/utils' import { getTrigger } from '@/triggers' export const GongBlock: BlockConfig = { type: 'gong', + hideFromToolbar: true, + sunset: { status: 'legacy', replacedBy: 'gong_v2' }, name: 'Gong', description: 'Read Gong data, upload calls, assign or unassign flow prospects, and request data erasure', @@ -1539,3 +1542,17 @@ export const GongBlockMeta = { }, ], } as const satisfies BlockMeta + +export const GongV2Block: BlockConfig = { + ...GongBlock, + type: 'gong_v2', + authMode: AuthMode.OAuth, + hideFromToolbar: false, + sunset: undefined, + ...withSavedCredential(GongBlock, { + id: 'credential', + type: 'oauth-input', + serviceId: 'gong', + title: 'Gong Credential', + }), +} diff --git a/apps/sim/blocks/blocks/intune.ts b/apps/sim/blocks/blocks/intune.ts index 5a6de413bad..c8bdca0cd48 100644 --- a/apps/sim/blocks/blocks/intune.ts +++ b/apps/sim/blocks/blocks/intune.ts @@ -8,7 +8,7 @@ export const IntuneBlock: BlockConfig = { name: 'Microsoft Intune', description: 'Manage devices, application inventory, and compliance with Microsoft Intune', longDescription: - 'Read managed device and detected application inventories, inspect compliance policy status and device configuration metadata, and request device sync, restart, remote lock, or retirement. Uses Microsoft Graph v1.0 with a work or school account and an active Intune tenant license. Remote actions depend on device platform and the connected administrator’s Intune permissions.', + 'Read managed device and detected application inventories, inspect compliance policy status and device configuration metadata, and request device sync, restart, remote lock, or retirement. Uses Microsoft Graph v1.0 with a work or school account, or a Microsoft app credential with application permissions, and an active Intune tenant license. Remote actions depend on device platform and on the connected administrator’s Intune permissions or the app’s application permissions.', docsLink: 'https://docs.sim.ai/integrations/intune', category: 'tools', integrationType: IntegrationType.Security, diff --git a/apps/sim/blocks/blocks/planetscale.ts b/apps/sim/blocks/blocks/planetscale.ts index aa6013ae855..b9fa9557230 100644 --- a/apps/sim/blocks/blocks/planetscale.ts +++ b/apps/sim/blocks/blocks/planetscale.ts @@ -1,5 +1,6 @@ import { PlanetScaleIcon } from '@/components/icons' import { AuthMode, type BlockConfig, type BlockMeta, IntegrationType } from '@/blocks/types' +import { withSavedCredential } from '@/blocks/utils' function optionalBoolean(value: unknown): boolean | undefined { if (value === undefined || value === null || value === '') return undefined @@ -12,6 +13,8 @@ function optionalNumber(value: unknown): number | undefined { } export const PlanetScaleBlock: BlockConfig = { type: 'planetscale', + hideFromToolbar: true, + sunset: { status: 'legacy', replacedBy: 'planetscale_v2' }, name: 'PlanetScale', description: 'Manage PlanetScale databases, branches, backups, and deploy requests', longDescription: @@ -1185,3 +1188,28 @@ export const PlanetScaleBlockMeta = { }, ], } as const satisfies BlockMeta + +export const PlanetScaleV2Block: BlockConfig = { + ...PlanetScaleBlock, + type: 'planetscale_v2', + authMode: AuthMode.OAuth, + hideFromToolbar: false, + sunset: undefined, + ...withSavedCredential( + PlanetScaleBlock, + { + id: 'credential', + type: 'oauth-input', + serviceId: 'planetscale', + title: 'PlanetScale Credential', + }, + { + selectorKeys: { + 'planetscale.databases': 'planetscale.savedDatabases', + 'planetscale.branches': 'planetscale.savedBranches', + 'planetscale.backups': 'planetscale.savedBackups', + 'planetscale.deployRequests': 'planetscale.savedDeployRequests', + }, + } + ), +} diff --git a/apps/sim/blocks/blocks/sailpoint.ts b/apps/sim/blocks/blocks/sailpoint.ts index 69309d04f29..b164a1633e2 100644 --- a/apps/sim/blocks/blocks/sailpoint.ts +++ b/apps/sim/blocks/blocks/sailpoint.ts @@ -5,6 +5,7 @@ import { normalizeFileInput, parseOptionalJsonInput, parseOptionalNumberInput, + withSavedCredential, } from '@/blocks/utils' const SAILPOINT_OPERATIONS = [ @@ -134,6 +135,8 @@ const ACCEPTED_OUTPUT_OPERATIONS = [ export const SailPointBlock: BlockConfig = { type: 'sailpoint', + hideFromToolbar: true, + sunset: { status: 'legacy', replacedBy: 'sailpoint_v2' }, name: 'SailPoint', description: 'Govern identities and access in SailPoint Identity Security Cloud', longDescription: @@ -1891,3 +1894,19 @@ export const SailPointBlockMeta = { }, ], } as const satisfies BlockMeta + +export const SailPointV2Block: BlockConfig = { + ...SailPointBlock, + type: 'sailpoint_v2', + longDescription: + 'Connect SailPoint Identity Security Cloud with a Personal Access Token or a client-credentials API client to search identity-governance data, manage access requests and approvals, review certifications, and import source data. The token owner and scopes determine the operations available to the integration.', + authMode: AuthMode.OAuth, + hideFromToolbar: false, + sunset: undefined, + ...withSavedCredential(SailPointBlock, { + id: 'credential', + type: 'oauth-input', + serviceId: 'sailpoint', + title: 'SailPoint Credential', + }), +} diff --git a/apps/sim/blocks/blocks/servicenow.ts b/apps/sim/blocks/blocks/servicenow.ts index b01c3364a8a..5724ad3ae06 100644 --- a/apps/sim/blocks/blocks/servicenow.ts +++ b/apps/sim/blocks/blocks/servicenow.ts @@ -1,7 +1,7 @@ import { ServiceNowIcon } from '@/components/icons' import type { BlockConfig, BlockMeta } from '@/blocks/types' import { AuthMode, IntegrationType } from '@/blocks/types' -import { normalizeFileInput } from '@/blocks/utils' +import { normalizeFileInput, withSavedCredential } from '@/blocks/utils' import { APPROVAL_DECISION_OPTIONS, APPROVAL_STATE, @@ -165,7 +165,7 @@ export const ServiceNowBlock = { type: 'servicenow', name: 'ServiceNow (Legacy)', hideFromToolbar: true, - sunset: { status: 'legacy', replacedBy: 'servicenow_v2' }, + sunset: { status: 'legacy', replacedBy: 'servicenow_v3' }, description: 'Create, read, update, and delete ServiceNow records', longDescription: 'Create or update incidents and change requests, order catalog items, and process approvals. Read configuration items, knowledge articles, users, and groups, and transfer attachments. Generic record, aggregate, and table-scoped attachment operations accept a table name. Connect with your ServiceNow instance URL, username, and password.', @@ -1928,8 +1928,8 @@ export const ServiceNowV2Block: BlockConfig = { ...ServiceNowBlock, type: 'servicenow_v2', name: 'ServiceNow', - hideFromToolbar: false, - sunset: undefined, + hideFromToolbar: true, + sunset: { status: 'legacy', replacedBy: 'servicenow_v3' }, tools: { ...ServiceNowBlock.tools, access: ServiceNowBlock.tools.access.map((toolId) => @@ -1951,6 +1951,20 @@ export const ServiceNowV2Block: BlockConfig = { }, } +export const ServiceNowV3Block: BlockConfig = { + ...ServiceNowV2Block, + type: 'servicenow_v3', + authMode: AuthMode.OAuth, + hideFromToolbar: false, + sunset: undefined, + ...withSavedCredential(ServiceNowV2Block, { + id: 'credential', + type: 'oauth-input', + serviceId: 'servicenow', + title: 'ServiceNow Credential', + }), +} + export const ServiceNowBlockMeta = { tags: ['customer-support', 'ticketing', 'incident-management'], url: 'https://www.servicenow.com', diff --git a/apps/sim/blocks/blocks/zendesk.ts b/apps/sim/blocks/blocks/zendesk.ts index a8d6ca9e07f..fe2063ccfac 100644 --- a/apps/sim/blocks/blocks/zendesk.ts +++ b/apps/sim/blocks/blocks/zendesk.ts @@ -1,10 +1,13 @@ import { ZendeskIcon } from '@/components/icons' import type { BlockConfig, BlockMeta } from '@/blocks/types' import { AuthMode, IntegrationType } from '@/blocks/types' +import { withSavedCredential } from '@/blocks/utils' import { getTrigger } from '@/triggers' export const ZendeskBlock: BlockConfig = { type: 'zendesk', + hideFromToolbar: true, + sunset: { status: 'legacy', replacedBy: 'zendesk_v2' }, name: 'Zendesk', description: 'Manage support tickets, users, and organizations in Zendesk', triggerAllowed: true, @@ -798,6 +801,20 @@ Return ONLY the search query - no explanations.`, }, } +export const ZendeskV2Block: BlockConfig = { + ...ZendeskBlock, + type: 'zendesk_v2', + authMode: AuthMode.OAuth, + hideFromToolbar: false, + sunset: undefined, + ...withSavedCredential(ZendeskBlock, { + id: 'credential', + type: 'oauth-input', + serviceId: 'zendesk', + title: 'Zendesk Credential', + }), +} + export const ZendeskBlockMeta = { tags: ['customer-support', 'ticketing'], url: 'https://www.zendesk.com', diff --git a/apps/sim/blocks/blocks/zoominfo.ts b/apps/sim/blocks/blocks/zoominfo.ts index 9f771f46014..023fc34fcba 100644 --- a/apps/sim/blocks/blocks/zoominfo.ts +++ b/apps/sim/blocks/blocks/zoominfo.ts @@ -1,9 +1,12 @@ import { ZoomInfoIcon } from '@/components/icons' import type { BlockConfig, BlockMeta } from '@/blocks/types' import { AuthMode, IntegrationType } from '@/blocks/types' +import { withSavedCredential } from '@/blocks/utils' export const ZoomInfoBlock: BlockConfig = { type: 'zoominfo', + hideFromToolbar: true, + sunset: { status: 'legacy', replacedBy: 'zoominfo_v2' }, name: 'ZoomInfo', description: 'Search and enrich B2B company and contact data with ZoomInfo.', authMode: AuthMode.ApiKey, @@ -687,3 +690,17 @@ export const ZoomInfoBlockMeta = { }, ], } as const satisfies BlockMeta + +export const ZoomInfoV2Block: BlockConfig = { + ...ZoomInfoBlock, + type: 'zoominfo_v2', + authMode: AuthMode.OAuth, + hideFromToolbar: false, + sunset: undefined, + ...withSavedCredential(ZoomInfoBlock, { + id: 'credential', + type: 'oauth-input', + serviceId: 'zoominfo', + title: 'ZoomInfo Credential', + }), +} diff --git a/apps/sim/blocks/registry-maps.ts b/apps/sim/blocks/registry-maps.ts index 1e0b4959fb3..78fea85728d 100644 --- a/apps/sim/blocks/registry-maps.ts +++ b/apps/sim/blocks/registry-maps.ts @@ -68,12 +68,16 @@ import { ContextDevBlock, ContextDevBlockMeta } from '@/blocks/blocks/context_de import { ConvexBlock, ConvexBlockMeta, ConvexV2Block } from '@/blocks/blocks/convex' import { CredentialBlock } from '@/blocks/blocks/credential' import { CredentialGroupBlock } from '@/blocks/blocks/credential-group' -import { CrowdStrikeBlock, CrowdStrikeBlockMeta } from '@/blocks/blocks/crowdstrike' +import { + CrowdStrikeBlock, + CrowdStrikeBlockMeta, + CrowdStrikeV2Block, +} from '@/blocks/blocks/crowdstrike' import { CrunchbaseBlock, CrunchbaseBlockMeta, CrunchbaseV2Block } from '@/blocks/blocks/crunchbase' import { CursorBlock, CursorBlockMeta, CursorV2Block, CursorV3Block } from '@/blocks/blocks/cursor' import { DagsterBlock, DagsterBlockMeta } from '@/blocks/blocks/dagster' import { DatabricksBlock, DatabricksBlockMeta, DatabricksV2Block } from '@/blocks/blocks/databricks' -import { DatadogBlock, DatadogBlockMeta } from '@/blocks/blocks/datadog' +import { DatadogBlock, DatadogBlockMeta, DatadogV2Block } from '@/blocks/blocks/datadog' import { DatagmaBlock, DatagmaBlockMeta } from '@/blocks/blocks/datagma' import { DaytonaBlock, DaytonaBlockMeta, DaytonaV2Block } from '@/blocks/blocks/daytona' import { DeploymentsBlock } from '@/blocks/blocks/deployments' @@ -126,7 +130,7 @@ import { } from '@/blocks/blocks/github' import { GitLabBlock, GitLabBlockMeta, GitLabV2Block } from '@/blocks/blocks/gitlab' import { GmailBlock, GmailBlockMeta, GmailV2Block, GmailV2BlockMeta } from '@/blocks/blocks/gmail' -import { GongBlock, GongBlockMeta } from '@/blocks/blocks/gong' +import { GongBlock, GongBlockMeta, GongV2Block } from '@/blocks/blocks/gong' import { GoogleSearchBlock, GoogleSearchBlockMeta } from '@/blocks/blocks/google' import { GoogleAdsBlock, GoogleAdsBlockMeta } from '@/blocks/blocks/google_ads' import { GoogleAppsheetBlock, GoogleAppsheetBlockMeta } from '@/blocks/blocks/google_appsheet' @@ -327,7 +331,11 @@ import { PineconeBlock, PineconeBlockMeta } from '@/blocks/blocks/pinecone' import { PipedriveBlock, PipedriveBlockMeta } from '@/blocks/blocks/pipedrive' import { PitchBookBlock, PitchBookBlockMeta } from '@/blocks/blocks/pitchbook' import { PlaneBlock, PlaneBlockMeta, PlaneV2Block } from '@/blocks/blocks/plane' -import { PlanetScaleBlock, PlanetScaleBlockMeta } from '@/blocks/blocks/planetscale' +import { + PlanetScaleBlock, + PlanetScaleBlockMeta, + PlanetScaleV2Block, +} from '@/blocks/blocks/planetscale' import { PolymarketBlock, PolymarketBlockMeta } from '@/blocks/blocks/polymarket' import { PostgreSQLBlock, PostgreSQLBlockMeta } from '@/blocks/blocks/postgresql' import { PostHogBlock, PostHogBlockMeta } from '@/blocks/blocks/posthog' @@ -361,7 +369,7 @@ import { RootlyBlock, RootlyBlockMeta, RootlyV2Block } from '@/blocks/blocks/roo import { RouterBlock, RouterV2Block } from '@/blocks/blocks/router' import { RssBlock, RssBlockMeta } from '@/blocks/blocks/rss' import { S3Block, S3BlockMeta, S3V2Block } from '@/blocks/blocks/s3' -import { SailPointBlock, SailPointBlockMeta } from '@/blocks/blocks/sailpoint' +import { SailPointBlock, SailPointBlockMeta, SailPointV2Block } from '@/blocks/blocks/sailpoint' import { SalesforceBlock, SalesforceBlockMeta } from '@/blocks/blocks/salesforce' import { SapConcurBlock, SapConcurBlockMeta } from '@/blocks/blocks/sap_concur' import { SapS4HanaBlock, SapS4HanaBlockMeta } from '@/blocks/blocks/sap_s4hana' @@ -377,7 +385,12 @@ import { SendblueBlock, SendblueBlockMeta } from '@/blocks/blocks/sendblue' import { SendGridBlock, SendGridBlockMeta, SendGridV2Block } from '@/blocks/blocks/sendgrid' import { SentryBlock, SentryBlockMeta, SentryV2Block } from '@/blocks/blocks/sentry' import { SerperBlock, SerperBlockMeta } from '@/blocks/blocks/serper' -import { ServiceNowBlock, ServiceNowBlockMeta, ServiceNowV2Block } from '@/blocks/blocks/servicenow' +import { + ServiceNowBlock, + ServiceNowBlockMeta, + ServiceNowV2Block, + ServiceNowV3Block, +} from '@/blocks/blocks/servicenow' import { SESBlock, SESBlockMeta, SESV2Block } from '@/blocks/blocks/ses' import { SftpBlock, SftpBlockMeta, SftpV2Block } from '@/blocks/blocks/sftp' import { SharepointBlock, SharepointBlockMeta, SharepointV2Block } from '@/blocks/blocks/sharepoint' @@ -465,12 +478,12 @@ import { WorkflowInputBlock } from '@/blocks/blocks/workflow_input' import { XBlock, XBlockMeta } from '@/blocks/blocks/x' import { YouComBlock, YouComBlockMeta, YouComV2Block } from '@/blocks/blocks/youcom' import { YouTubeBlock, YouTubeBlockMeta, YouTubeV2Block } from '@/blocks/blocks/youtube' -import { ZendeskBlock, ZendeskBlockMeta } from '@/blocks/blocks/zendesk' +import { ZendeskBlock, ZendeskBlockMeta, ZendeskV2Block } from '@/blocks/blocks/zendesk' import { ZepBlock, ZepBlockMeta, ZepV2Block } from '@/blocks/blocks/zep' import { ZeroBounceBlock, ZeroBounceBlockMeta } from '@/blocks/blocks/zerobounce' import { ZohoDeskBlock, ZohoDeskBlockMeta } from '@/blocks/blocks/zoho-desk' import { ZoomBlock, ZoomBlockMeta } from '@/blocks/blocks/zoom' -import { ZoomInfoBlock, ZoomInfoBlockMeta } from '@/blocks/blocks/zoominfo' +import { ZoomInfoBlock, ZoomInfoBlockMeta, ZoomInfoV2Block } from '@/blocks/blocks/zoominfo' import type { BlockConfig, BlockMeta } from '@/blocks/types' /** All block configs keyed by block type. The execution source of truth. */ @@ -554,6 +567,7 @@ export const BLOCK_REGISTRY: Record = { credential: CredentialBlock, credential_group: CredentialGroupBlock, crowdstrike: CrowdStrikeBlock, + crowdstrike_v2: CrowdStrikeV2Block, crunchbase: CrunchbaseBlock, crunchbase_v2: CrunchbaseV2Block, cursor: CursorBlock, @@ -563,6 +577,7 @@ export const BLOCK_REGISTRY: Record = { databricks: DatabricksBlock, databricks_v2: DatabricksV2Block, datadog: DatadogBlock, + datadog_v2: DatadogV2Block, datagma: DatagmaBlock, daytona: DaytonaBlock, daytona_v2: DaytonaV2Block, @@ -634,6 +649,7 @@ export const BLOCK_REGISTRY: Record = { gmail: GmailBlock, gmail_v2: GmailV2Block, gong: GongBlock, + gong_v2: GongV2Block, google_ads: GoogleAdsBlock, google_appsheet: GoogleAppsheetBlock, google_bigquery: GoogleBigQueryBlock, @@ -798,6 +814,7 @@ export const BLOCK_REGISTRY: Record = { plane: PlaneBlock, plane_v2: PlaneV2Block, planetscale: PlanetScaleBlock, + planetscale_v2: PlanetScaleV2Block, polymarket: PolymarketBlock, postgresql: PostgreSQLBlock, posthog: PostHogBlock, @@ -843,6 +860,7 @@ export const BLOCK_REGISTRY: Record = { s3: S3Block, s3_v2: S3V2Block, sailpoint: SailPointBlock, + sailpoint_v2: SailPointV2Block, salesforce: SalesforceBlock, sap_concur: SapConcurBlock, sap_s4hana: SapS4HanaBlock, @@ -859,6 +877,7 @@ export const BLOCK_REGISTRY: Record = { serper: SerperBlock, servicenow: ServiceNowBlock, servicenow_v2: ServiceNowV2Block, + servicenow_v3: ServiceNowV3Block, ses: SESBlock, ses_v2: SESV2Block, sftp: SftpBlock, @@ -962,11 +981,13 @@ export const BLOCK_REGISTRY: Record = { youtube: YouTubeBlock, youtube_v2: YouTubeV2Block, zendesk: ZendeskBlock, + zendesk_v2: ZendeskV2Block, zep: ZepBlock, zep_v2: ZepV2Block, zoho_desk: ZohoDeskBlock, zoom: ZoomBlock, zoominfo: ZoomInfoBlock, + zoominfo_v2: ZoomInfoV2Block, } /** diff --git a/apps/sim/blocks/utils.test.ts b/apps/sim/blocks/utils.test.ts index 960b11834bb..c5c354a315f 100644 --- a/apps/sim/blocks/utils.test.ts +++ b/apps/sim/blocks/utils.test.ts @@ -443,6 +443,26 @@ describe('withSavedCredential', () => { expect(subBlocks.find((subBlock) => subBlock.id === 'credential')?.required).toBe(true) }) + it('shows the picker in every operation when the successor says so, over secrets shown differently', () => { + const partlyConditional: BlockConfig = { + ...legacy, + subBlocks: legacy.subBlocks.map((subBlock) => + subBlock.id === 'accessKeyId' && subBlock.mode !== 'trigger' + ? { ...subBlock, condition: { field: 'operation', value: 'read' } } + : subBlock + ), + } + + expect(() => withSavedCredential(partlyConditional, picker)).toThrow( + /show or require differently/ + ) + const { subBlocks } = withSavedCredential(partlyConditional, picker, { condition: null }) + const credential = subBlocks.find((subBlock) => subBlock.id === 'credential') + + expect(credential).not.toHaveProperty('condition') + expect(credential?.required).toBe(true) + }) + it('refuses options that would silently do nothing', () => { expect(() => withSavedCredential(legacy, picker, { diff --git a/apps/sim/blocks/utils.ts b/apps/sim/blocks/utils.ts index a929c3ceb30..03671ec002f 100644 --- a/apps/sim/blocks/utils.ts +++ b/apps/sim/blocks/utils.ts @@ -75,8 +75,12 @@ interface SavedCredentialOptions { * endpoints, keyless self-hosted servers) passes a condition or `false`. */ required?: SubBlockConfig['required'] - /** Where the picker shows. Defaults to the replaced secret's own `condition`. */ - condition?: SubBlockConfig['condition'] + /** + * Where the picker shows. Defaults to the replaced secret's own `condition`; + * `null` shows it in every operation, for a block whose replaced fields show + * differently (a key every operation sends beside one only some send). + */ + condition?: SubBlockConfig['condition'] | null } /** @@ -116,7 +120,7 @@ export function withSavedCredential( if (new Set(legacy.subBlocks.filter(isReplaced).map(inherited)).size > 1) { throw new Error(`Block "${legacy.type}": the replaced secrets show or require differently`) } - const condition = options.condition ?? secretCondition + const condition = options.condition === null ? undefined : (options.condition ?? secretCondition) const conditionFields = (value: SubBlockConfig['condition']): string[] => value && typeof value !== 'function' ? [value.field, ...conditionFields(value.and as SubBlockConfig['condition'])] @@ -476,7 +480,7 @@ export function getCohereRerankerApiKeyCondition() { /** * Whether `model` can only run with credentials that live on the block beyond an - * API key: a Vertex OAuth credential, Bedrock AWS keys, or an Azure endpoint, + * API key: a Vertex credential, Bedrock AWS keys, or an Azure endpoint, * unless the deployment supplies them server-side (the same env flags that hide * those fields). The fields render only while the block's own `model` is in * that provider family, so nothing outside the family can inherit them. @@ -521,6 +525,7 @@ export function getProviderCredentialSubBlocks(): SubBlockConfig[] { type: 'oauth-input', serviceId: 'vertex-ai', canonicalParamId: 'vertexCredential', + credentialKind: 'any', mode: 'basic', requiredScopes: getScopesForService('vertex-ai'), placeholder: 'Select Google Cloud account', @@ -630,7 +635,8 @@ export const PROVIDER_CREDENTIAL_INPUTS = { vertexLocation: { type: 'string', description: 'Google Cloud location for Vertex AI' }, vertexCredential: { type: 'string', - description: 'Google Cloud OAuth credential ID for Vertex AI', + description: + 'Google Cloud credential ID for Vertex AI (OAuth account or Google service account)', }, bedrockAccessKeyId: { type: 'string', description: 'AWS Access Key ID for Bedrock' }, bedrockSecretKey: { type: 'string', description: 'AWS Secret Access Key for Bedrock' }, diff --git a/apps/sim/lib/api/contracts/credentials.ts b/apps/sim/lib/api/contracts/credentials.ts index cc84b0e2ded..ca17bf95a48 100644 --- a/apps/sim/lib/api/contracts/credentials.ts +++ b/apps/sim/lib/api/contracts/credentials.ts @@ -12,6 +12,15 @@ import { import type { OAuthProvider } from '@/lib/oauth/types' export const atlassianProductSchema = z.enum(ATLASSIAN_PRODUCTS) + +/** + * A pasted token or password, kept exactly as typed: a password's surrounding spaces are part of + * it, so each provider's builder decides whether to trim. A blank value is refused. + */ +export const nonBlankSecretSchema = z + .string() + .min(1) + .refine((value) => value.trim().length > 0, 'Must not be blank') export type AtlassianProduct = z.output const ENV_VAR_NAME_REGEX = /^[A-Za-z0-9_]+$/ @@ -151,7 +160,7 @@ export const createCredentialFieldsSchema = z.object({ .string() .max(2 * 1024 * 1024) .optional(), - apiToken: z.string().trim().min(1).optional(), + apiToken: nonBlankSecretSchema.optional(), domain: z.string().trim().min(1).optional(), providerWorkspaceId: z.string().trim().min(1).max(256).optional(), atlassianProduct: atlassianProductSchema.optional(), @@ -287,7 +296,7 @@ export const updateCredentialByIdBodySchema = z signingSecret: z.string().trim().min(1).optional(), botToken: z.string().trim().min(1).optional(), /** Atlassian service-account secret rotation (reconnect). */ - apiToken: z.string().trim().min(1).optional(), + apiToken: nonBlankSecretSchema.optional(), domain: z.string().trim().min(1).optional(), providerWorkspaceId: z.string().trim().min(1).max(256).optional(), atlassianProduct: atlassianProductSchema.optional(), diff --git a/apps/sim/lib/api/contracts/oauth-connections.ts b/apps/sim/lib/api/contracts/oauth-connections.ts index 25af9d40e45..e166d60ec27 100644 --- a/apps/sim/lib/api/contracts/oauth-connections.ts +++ b/apps/sim/lib/api/contracts/oauth-connections.ts @@ -121,9 +121,11 @@ const oauthTokenResponseSchema = z.object({ cloudId: z.string().optional(), domain: z.string().optional(), providerWorkspaceId: z.string().optional(), + /** Datadog — the application key sent beside the API key (`accessToken`). */ + applicationKey: z.string().optional(), realmId: z.string().optional(), quickBooksEnvironment: z.enum(['sandbox', 'production']).optional(), - authStyle: z.enum(['x-api-token', 'snowflake-workload-identity']).optional(), + authStyle: z.enum(['x-api-token', 'snowflake-workload-identity', 'basic']).optional(), }) /** Token material a resolved credential yields, on the wire and in-process alike. */ diff --git a/apps/sim/lib/api/contracts/tools/crowdstrike.ts b/apps/sim/lib/api/contracts/tools/crowdstrike.ts index 788983bd260..0d88f6d5f04 100644 --- a/apps/sim/lib/api/contracts/tools/crowdstrike.ts +++ b/apps/sim/lib/api/contracts/tools/crowdstrike.ts @@ -5,10 +5,11 @@ import type { ContractJsonResponse, } from '@/lib/api/contracts/types' import { defineRouteContract } from '@/lib/api/contracts/types' -import type { - CrowdStrikeAggregateQuery, - CrowdStrikeSensorAggregateBucket, - CrowdStrikeSensorAggregateResult, +import { + CROWDSTRIKE_CLOUDS, + type CrowdStrikeAggregateQuery, + type CrowdStrikeSensorAggregateBucket, + type CrowdStrikeSensorAggregateResult, } from '@/tools/crowdstrike/types' const crowdstrikeNullableStringSchema = z.string().nullable() @@ -101,11 +102,15 @@ const crowdstrikeAggregatesResponseSchema = z.object({ }), }) -const CROWDSTRIKE_CLOUDS = ['us-1', 'us-2', 'us-3', 'eu-1', 'us-gov-1', 'us-gov-2'] as const - +/** + * Inline API client credentials, or the token a saved CrowdStrike credential + * minted for `cloud` in their place; {@link crowdstrikeQueryBodySchema} + * requires one or the other. + */ const baseRequestSchema = z.object({ - clientId: z.string().min(1, 'Client ID is required'), - clientSecret: z.string().min(1, 'Client Secret is required'), + clientId: z.string().min(1, 'Client ID is required').optional(), + clientSecret: z.string().min(1, 'Client Secret is required').optional(), + accessToken: z.string().min(1).max(16_384).optional(), cloud: z.enum(CROWDSTRIKE_CLOUDS), }) @@ -999,31 +1004,41 @@ const getCaseDetailsSchema = baseRequestSchema.extend({ caseIds: idsSchema(1000, 'case ID'), }) -export const crowdstrikeQueryBodySchema = z.discriminatedUnion('operation', [ - querySensorsSchema, - getSensorDetailsSchema, - getSensorAggregatesSchema, - queryAlertsSchema, - getAlertDetailsSchema, - updateAlertsSchema, - performHostActionSchema, - queryHostGroupsSchema, - getHostGroupDetailsSchema, - performHostGroupActionSchema, - queryIndicatorsSchema, - getIndicatorDetailsSchema, - createIndicatorsSchema, - updateIndicatorsSchema, - deleteIndicatorsSchema, - queryVulnerabilitiesSchema, - getVulnerabilityDetailsSchema, - initRtrSessionSchema, - executeRtrCommandSchema, - getRtrCommandStatusSchema, - deleteRtrSessionSchema, - queryCasesSchema, - getCaseDetailsSchema, -]) +export const crowdstrikeQueryBodySchema = z + .discriminatedUnion('operation', [ + querySensorsSchema, + getSensorDetailsSchema, + getSensorAggregatesSchema, + queryAlertsSchema, + getAlertDetailsSchema, + updateAlertsSchema, + performHostActionSchema, + queryHostGroupsSchema, + getHostGroupDetailsSchema, + performHostGroupActionSchema, + queryIndicatorsSchema, + getIndicatorDetailsSchema, + createIndicatorsSchema, + updateIndicatorsSchema, + deleteIndicatorsSchema, + queryVulnerabilitiesSchema, + getVulnerabilityDetailsSchema, + initRtrSessionSchema, + executeRtrCommandSchema, + getRtrCommandStatusSchema, + deleteRtrSessionSchema, + queryCasesSchema, + getCaseDetailsSchema, + ]) + .superRefine((body, ctx) => { + if (body.accessToken) return + if (!body.clientId) { + ctx.addIssue({ code: 'custom', path: ['clientId'], message: 'Client ID is required' }) + } + if (!body.clientSecret) { + ctx.addIssue({ code: 'custom', path: ['clientSecret'], message: 'Client Secret is required' }) + } + }) export const crowdstrikeQueryContract = defineRouteContract({ method: 'POST', diff --git a/apps/sim/lib/api/contracts/v2/credentials.ts b/apps/sim/lib/api/contracts/v2/credentials.ts index 5441721d578..7937bcd8d9a 100644 --- a/apps/sim/lib/api/contracts/v2/credentials.ts +++ b/apps/sim/lib/api/contracts/v2/credentials.ts @@ -1,6 +1,7 @@ import { z } from 'zod' import { atlassianProductSchema, + nonBlankSecretSchema, quickBooksOAuthClientConfigSchema, workloadIdentitySubjectGrantShape, workspaceCredentialRoleSchema, @@ -485,10 +486,7 @@ const v2ServiceAccountCredentialFieldsSchema = z 'Write-only provider service-account JSON configuration, including Oracle Database connection fields.' ) .meta({ writeOnly: true }), - apiToken: z - .string() - .trim() - .min(1) + apiToken: nonBlankSecretSchema .max(8192) .optional() .describe('Write-only provider API token.') @@ -760,10 +758,7 @@ const v2ServiceAccountSecretFieldsShape = { 'Write-only provider service-account JSON configuration, including Oracle Database connection fields.' ) .meta({ writeOnly: true }), - apiToken: z - .string() - .trim() - .min(1) + apiToken: nonBlankSecretSchema .max(8192) .optional() .describe('Write-only provider API token.') diff --git a/apps/sim/lib/block-metadata/names.generated.ts b/apps/sim/lib/block-metadata/names.generated.ts index 4eec2893e1f..763ae48f0a0 100644 --- a/apps/sim/lib/block-metadata/names.generated.ts +++ b/apps/sim/lib/block-metadata/names.generated.ts @@ -52,12 +52,12 @@ export const BLOCK_NAMES: Readonly> = { convex_v2: 'Convex', credential: 'Credential', credential_group: 'Connected Accounts (Legacy)', - crowdstrike: 'CrowdStrike', + crowdstrike_v2: 'CrowdStrike', crunchbase_v2: 'Crunchbase', cursor_v3: 'Cursor', dagster: 'Dagster', databricks_v2: 'Databricks', - datadog: 'Datadog', + datadog_v2: 'Datadog', datagma: 'Datagma', daytona_v2: 'Daytona', deployments: 'Deployments', @@ -98,7 +98,7 @@ export const BLOCK_NAMES: Readonly> = { github_v3: 'GitHub', gitlab_v2: 'GitLab', gmail_v2: 'Gmail', - gong: 'Gong', + gong_v2: 'Gong', google_ads: 'Google Ads', google_appsheet: 'Google AppSheet', google_bigquery: 'Google BigQuery', @@ -216,7 +216,7 @@ export const BLOCK_NAMES: Readonly> = { pipedrive: 'Pipedrive', pitchbook: 'PitchBook', plane_v2: 'Plane', - planetscale: 'PlanetScale', + planetscale_v2: 'PlanetScale', polymarket: 'Polymarket', postgresql: 'PostgreSQL', posthog: 'PostHog', @@ -245,7 +245,7 @@ export const BLOCK_NAMES: Readonly> = { router_v2: 'Router', rss: 'RSS Feed', s3_v2: 'S3', - sailpoint: 'SailPoint', + sailpoint_v2: 'SailPoint', salesforce: 'Salesforce', sap_concur: 'SAP Concur', sap_s4hana: 'SAP S4HANA', @@ -257,7 +257,7 @@ export const BLOCK_NAMES: Readonly> = { sendgrid_v2: 'SendGrid', sentry_v2: 'Sentry', serper: 'Serper', - servicenow_v2: 'ServiceNow', + servicenow_v3: 'ServiceNow', ses_v2: 'AWS SES', sftp_v2: 'SFTP', sharepoint_v2: 'SharePoint', @@ -322,10 +322,10 @@ export const BLOCK_NAMES: Readonly> = { x: 'X', youcom_v2: 'You.com', youtube_v2: 'YouTube', - zendesk: 'Zendesk', + zendesk_v2: 'Zendesk', zep_v2: 'Zep', zerobounce: 'ZeroBounce', zoho_desk: 'Zoho Desk', zoom: 'Zoom', - zoominfo: 'ZoomInfo', + zoominfo_v2: 'ZoomInfo', } diff --git a/apps/sim/lib/core/security/input-validation.test.ts b/apps/sim/lib/core/security/input-validation.test.ts index aa7967b7a0f..fca4ca9f13c 100644 --- a/apps/sim/lib/core/security/input-validation.test.ts +++ b/apps/sim/lib/core/security/input-validation.test.ts @@ -1,6 +1,8 @@ import { defaultMockEnv, envFlagsMock, resetEnvFlagsMock, resetEnvMock, setEnv } from '@sim/testing' import { afterAll, afterEach, beforeEach, describe, expect, it } from 'vitest' import { + normalizeHttpBaseUrl, + refuseUnsafeTypedUrl, validateAirtableId, validateCallbackUrl, validateEnum, @@ -663,3 +665,41 @@ describe('validateSupabaseProjectId', () => { expect(validateSupabaseProjectId(value).isValid).toBe(false) }) }) + +describe('typed base URLs', () => { + it('refuses a mistyped scheme instead of reading the scheme as a host', () => { + expect(() => normalizeHttpBaseUrl('https:/grafana.example.com')).toThrow(/scheme/) + expect(() => refuseUnsafeTypedUrl('https:/evil.com')).toThrow(/scheme/) + expect(() => refuseUnsafeTypedUrl('HTTP:evil.com')).toThrow(/scheme/) + // The URL parser drops tabs and newlines, so they must not hide a scheme from the guard. + expect(() => refuseUnsafeTypedUrl('ht\ntps:/evil.com')).toThrow(/scheme/) + expect(() => normalizeHttpBaseUrl('ht\ttps:/evil.com')).toThrow(/scheme/) + expect(refuseUnsafeTypedUrl('https://grafana.example.com')).toBe('https://grafana.example.com') + expect(normalizeHttpBaseUrl('grafana.example.com:3000/')).toBe( + 'https://grafana.example.com:3000' + ) + }) + + it.each([ + ['ftp:/evil.com', 'another scheme with one slash'], + ['ftp:evil.com', 'another scheme with no slashes'], + ['javascript:alert(1)', 'a script URL'], + ['evil.com:/path', 'a host followed by a colon but no port'], + ['//evil.com', 'a protocol-relative URL'], + ['//user:pass@evil.com', 'a protocol-relative URL with credentials'], + ['/\\evil.com', 'a slash and backslash the parser reads as an authority'], + ])('refuses %j (%s) in both helpers', (typed) => { + expect(() => refuseUnsafeTypedUrl(typed)).toThrow() + expect(() => normalizeHttpBaseUrl(typed)).toThrow() + }) + + it.each([ + ['grafana.example.com', 'https://grafana.example.com'], + ['localhost:8080/api', 'https://localhost:8080/api'], + ['10.0.0.5:8443', 'https://10.0.0.5:8443'], + ['http://grafana.example.com/', 'http://grafana.example.com'], + ])('still takes %j as an http(s) base URL', (typed, normalized) => { + expect(refuseUnsafeTypedUrl(typed)).toBe(typed) + expect(normalizeHttpBaseUrl(typed)).toBe(normalized) + }) +}) diff --git a/apps/sim/lib/core/security/input-validation.ts b/apps/sim/lib/core/security/input-validation.ts index 6b79071b5b2..42179070ba6 100644 --- a/apps/sim/lib/core/security/input-validation.ts +++ b/apps/sim/lib/core/security/input-validation.ts @@ -1408,3 +1408,52 @@ export function validateDatabaseIdentifier( } return { isValid: true, sanitized: value } } + +/** + * Drops the tabs and newlines the URL parser ignores, then refuses every typed form the parser + * would read as a host other than the one typed: a malformed `https:/host` (which would get a + * second scheme and parse as a host named `https`), any other scheme such as `ftp:/host`, and a + * leading slash or backslash, which the parser takes as a protocol-relative authority. A bare + * `host` or `host:port` and an `http(s)://` URL pass. + */ +function refuseUnsafeUrlForm(typed: string): string { + const stripped = typed.replace(/[\t\n\r]/g, '') + if (/^https?:(?!\/\/)/i.test(stripped)) throw new Error('malformed scheme') + if (/^[/\\]/.test(stripped)) throw new Error('protocol-relative URL') + const hasScheme = /^[a-z][a-z\d+.-]*:/i.test(stripped) + const isHostWithPort = /^[a-z\d.-]+:\d+(?:[/?#]|$)/i.test(stripped) + if (hasScheme && !isHostWithPort && !/^https?:\/\//i.test(stripped)) { + throw new Error('http(s) only') + } + return stripped +} + +/** + * A typed http(s) base URL in the form tools build request paths on: a bare host + * is taken as https, trailing slashes are dropped, and any other scheme, + * credentials, a query, or a fragment is refused, since each would retarget the + * request. + */ +export function normalizeHttpBaseUrl(raw: string | undefined): string { + const trimmed = (raw ?? '').trim() + if (!trimmed) throw new Error('missing URL') + const typed = refuseUnsafeUrlForm(trimmed) + const url = new URL(/^https?:\/\//i.test(typed) ? typed : `https://${typed}`) + if (url.username || url.password || url.search || url.hash) { + throw new Error('credentials, query, or fragment in URL') + } + return `${url.origin}${url.pathname.replace(/\/+$/, '')}` +} + +/** + * Refuses a typed URL that carries credentials, a non-http(s) scheme, or a + * protocol-relative authority, before a tool's own normalizer can drop the + * credentials silently or read the scheme as a host, and so verify a different + * identity or host than the user meant. + */ +export function refuseUnsafeTypedUrl(raw: string | undefined): string { + const typed = refuseUnsafeUrlForm((raw ?? '').trim()) + const authority = typed.replace(/^https?:\/\//i, '').split(/[/?#]/)[0] + if (authority.includes('@')) throw new Error('credentials in URL') + return typed +} diff --git a/apps/sim/lib/core/security/redaction.test.ts b/apps/sim/lib/core/security/redaction.test.ts index 04058e34b91..43b1fd24e6b 100644 --- a/apps/sim/lib/core/security/redaction.test.ts +++ b/apps/sim/lib/core/security/redaction.test.ts @@ -38,6 +38,9 @@ describe('isSensitiveKey', () => { 'stripe_secret_key', 'deployKey', 'convexDeployKey', + 'applicationKey', + 'application_key', + 'datadogApplicationKey', `${'a'.repeat(10000)}password`, ])('treats %s as sensitive', (key) => { expect(isSensitiveKey(key)).toBe(true) @@ -47,6 +50,7 @@ describe('isSensitiveKey', () => { 'tokenCount', 'tokenizer', 'deployKeyName', + 'applicationKeyName', 'passwordStrength', 'authMethod', 'name', @@ -389,6 +393,20 @@ describe('redactApiKeys', () => { expect(result.nestedArray[0][0].password).toBe('[REDACTED]') }) + it.concurrent("redacts the application key a legacy Datadog block's inputs carry", () => { + const result = redactApiKeys({ + apiKey: 'dd-api', + applicationKey: 'dd-app', + site: 'datadoghq.com', + }) + + expect(result).toEqual({ + apiKey: '[REDACTED]', + applicationKey: '[REDACTED]', + site: 'datadoghq.com', + }) + }) + it.concurrent('should filter UserFile objects to only expose allowed fields', () => { const obj = { processedFiles: [ diff --git a/apps/sim/lib/core/security/redaction.ts b/apps/sim/lib/core/security/redaction.ts index baa661c4234..db46e1c9208 100644 --- a/apps/sim/lib/core/security/redaction.ts +++ b/apps/sim/lib/core/security/redaction.ts @@ -35,6 +35,8 @@ const SENSITIVE_KEY_PATTERNS: RegExp[] = [ /^.*secret[_-]?key$/i, // Convex's `deployKey`. /^.*deploy[_-]?key$/i, + // Datadog's `applicationKey`, sent beside the API key and as powerful as it. + /^.*application[_-]?key$/i, /^passphrase$/i, /^authorization$/i, /^proxy[_-]?authorization$/i, diff --git a/apps/sim/lib/credentials/application/provider-catalog.ts b/apps/sim/lib/credentials/application/provider-catalog.ts index a688811e19f..4e00fb90985 100644 --- a/apps/sim/lib/credentials/application/provider-catalog.ts +++ b/apps/sim/lib/credentials/application/provider-catalog.ts @@ -235,7 +235,7 @@ function getServiceAccountDescriptor(providerId: string): ServiceAccountDescript return { name: 'Microsoft app credential', description: - 'Use Microsoft Entra ID, SharePoint, Dataverse, and Dynamics 365 as an app registration (app-only), with a client secret or a certificate.', + 'Use Microsoft Entra ID, SharePoint, Intune, Dataverse, and Dynamics 365 as an app registration (app-only), with a client secret or a certificate.', docsUrl: 'https://docs.sim.ai/integrations/microsoft-app-credentials', fields: [ { diff --git a/apps/sim/lib/credentials/atlassian-client-credentials.test.ts b/apps/sim/lib/credentials/atlassian-client-credentials.test.ts new file mode 100644 index 00000000000..85af2168488 --- /dev/null +++ b/apps/sim/lib/credentials/atlassian-client-credentials.test.ts @@ -0,0 +1,68 @@ +import { jsonResponse } from '@sim/testing/helpers/http' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { coordinated } = vi.hoisted(() => ({ coordinated: vi.fn() })) + +vi.mock('@/lib/credentials/coordinated-client-token', () => ({ + resolveCoordinatedClientToken: coordinated, +})) + +import { mintAtlassianServiceAccountToken } from '@/lib/credentials/atlassian-client-credentials' + +const mockFetch = vi.fn() + +describe('Atlassian service-account OAuth 2.0 credentials', () => { + beforeEach(() => { + mockFetch.mockReset() + vi.stubGlobal('fetch', mockFetch) + coordinated.mockReset() + coordinated.mockImplementation( + async (input: { exchange: () => Promise<{ accessToken: string; expiresAt: Date }> }) => { + const exchanged = await input.exchange() + return { + accessToken: exchanged.accessToken, + expiresInSeconds: Math.round((exchanged.expiresAt.getTime() - Date.now()) / 1000), + } + } + ) + }) + + it('exchanges the client credentials at the fixed Atlassian token endpoint', async () => { + mockFetch.mockImplementation(async (input, init) => { + const request = new Request(input, init) + expect(request.url).toBe('https://auth.atlassian.com/oauth/token') + expect(request.method).toBe('POST') + expect(request.redirect).toBe('error') + expect(request.headers.get('content-type')).toBe('application/x-www-form-urlencoded') + expect([...new URLSearchParams(await request.text())]).toEqual([ + ['grant_type', 'client_credentials'], + ['client_id', 'atl-client'], + ['client_secret', 'atl-secret'], + ]) + return jsonResponse({ access_token: 'atl-token', expires_in: 3600, token_type: 'Bearer' }) + }) + + const minted = await mintAtlassianServiceAccountToken('atl-client', 'atl-secret') + + expect(minted.accessToken).toBe('atl-token') + expect(coordinated).toHaveBeenCalledWith( + expect.objectContaining({ namespace: 'atlassian', application: ['atl-client'] }) + ) + }) + + it('never keeps a token longer than the documented 60 minutes', async () => { + mockFetch.mockResolvedValue(jsonResponse({ access_token: 'atl-token', expires_in: 86400 })) + + const minted = await mintAtlassianServiceAccountToken('atl-client', 'atl-secret') + + expect(minted.expiresInSeconds).toBeLessThanOrEqual(3600) + }) + + it('never sends a request without a client secret', async () => { + await expect(mintAtlassianServiceAccountToken('atl-client', '')).rejects.toMatchObject({ + code: 'invalid_credentials', + }) + expect(mockFetch).not.toHaveBeenCalled() + expect(coordinated).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/credentials/atlassian-client-credentials.ts b/apps/sim/lib/credentials/atlassian-client-credentials.ts new file mode 100644 index 00000000000..b31fc42103f --- /dev/null +++ b/apps/sim/lib/credentials/atlassian-client-credentials.ts @@ -0,0 +1,48 @@ +import { exchangeClientCredentialsGrant } from '@/lib/credentials/client-credentials-grant' +import { resolveCoordinatedClientToken } from '@/lib/credentials/coordinated-client-token' +import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors' + +const ATLASSIAN_TOKEN_URL = 'https://auth.atlassian.com/oauth/token' +const MINT_STEP = 'atlassian_token_mint' +/** Atlassian documents a 60-minute access token for service-account OAuth credentials. */ +const MAX_TOKEN_LIFETIME_SECONDS = 3600 + +/** + * Mints a bearer token for an Atlassian service account's OAuth 2.0 credential through the + * client-credentials grant. The token is used exactly like a scoped API token, through + * `api.atlassian.com/ex/{product}/{cloudId}`. Atlassian documents these credentials for the Jira + * and Confluence REST APIs (not Jira Service Management, which shares Jira's credentials here), + * and does not document whether a new token retires earlier ones, so every saved credential for + * one OAuth client shares a single coordinated token instead of minting per worker. + * + * @see https://support.atlassian.com/user-management/docs/create-oauth-2-0-credential-for-service-accounts/ + */ +export async function mintAtlassianServiceAccountToken( + clientId: string, + clientSecret: string, + options?: { signal?: AbortSignal } +): Promise<{ accessToken: string; expiresInSeconds: number }> { + if (!clientId || !clientSecret) { + throw new TokenServiceAccountValidationError('invalid_credentials', 400, { + step: MINT_STEP, + reason: 'client ID and client secret are required', + }) + } + return resolveCoordinatedClientToken({ + namespace: 'atlassian', + application: [clientId], + clientSecret, + step: MINT_STEP, + exchange: (signal) => + exchangeClientCredentialsGrant({ + url: ATLASSIAN_TOKEN_URL, + clientId, + clientSecret, + step: MINT_STEP, + label: 'Atlassian token response', + maxLifetimeSeconds: MAX_TOKEN_LIFETIME_SECONDS, + signal, + }), + signal: options?.signal, + }) +} diff --git a/apps/sim/lib/credentials/atlassian-service-account.ts b/apps/sim/lib/credentials/atlassian-service-account.ts index a3ac12ede1e..e5a737cf531 100644 --- a/apps/sim/lib/credentials/atlassian-service-account.ts +++ b/apps/sim/lib/credentials/atlassian-service-account.ts @@ -116,6 +116,7 @@ export async function validateAtlassianServiceAccount( `https://api.atlassian.com/ex/${product}/${cloudId}/${identityPath}`, { headers: { Authorization: `Bearer ${apiToken}`, Accept: 'application/json' }, + redirect: 'error', } ) await assertAtlassianResponseOk(myselfRes, 'myself', 'invalid_credentials', { cloudId }) diff --git a/apps/sim/lib/credentials/aws-service-account.server.test.ts b/apps/sim/lib/credentials/aws-service-account.server.test.ts index 97b4a329854..9479f69c189 100644 --- a/apps/sim/lib/credentials/aws-service-account.server.test.ts +++ b/apps/sim/lib/credentials/aws-service-account.server.test.ts @@ -26,6 +26,7 @@ import { AssumeRoleWithWebIdentityCommand, GetCallerIdentityCommand } from '@aws import { credential } from '@sim/db/schema' import { getAwsCredentialConfig, + resolveAwsSessionCredentials, verifyAndEncryptAwsCredential, } from '@/lib/credentials/aws-service-account.server' import { WorkloadIdentityNotConfiguredError } from '@/lib/credentials/workload-identity/subject' @@ -155,3 +156,42 @@ describe('loading a saved AWS credential for a run', () => { ) }) }) + +describe("resolving a run's AWS session", () => { + const owner = { workspaceId: 'ws-1' } + const options = { roleSessionName: 'sim-exec-1' } + const expiration = new Date('2030-01-01T01:00:00.000Z') + + beforeEach(() => hoisted.send.mockReset()) + + it.each([ + { + config: { authType: 'oidc_role', roleArn: ROLE_ARN, subject: SUBJECT }, + durationSeconds: 3600, + }, + { + config: { + authType: 'assume_role', + roleArn: ROLE_ARN, + accessKeyId: 'AKIAEXAMPLEKEY01', + secretAccessKey: 'stored-secret', + sessionDurationSeconds: 900, + }, + durationSeconds: 900, + }, + ] as const)( + 'reports how long a $config.authType session lasts and when it ends', + async ({ config, durationSeconds }) => { + hoisted.send.mockResolvedValue({ Credentials: { ...session, Expiration: expiration } }) + + await expect(resolveAwsSessionCredentials(config, owner, options)).resolves.toEqual({ + accessKeyId: 'ASIATEMP', + secretAccessKey: 'secret', + sessionToken: 'token', + durationSeconds, + expiration, + }) + expect(hoisted.send.mock.calls[0][0].input.DurationSeconds).toBe(durationSeconds) + } + ) +}) diff --git a/apps/sim/lib/credentials/aws-service-account.server.ts b/apps/sim/lib/credentials/aws-service-account.server.ts index 4995824060c..6bb817a7edb 100644 --- a/apps/sim/lib/credentials/aws-service-account.server.ts +++ b/apps/sim/lib/credentials/aws-service-account.server.ts @@ -34,6 +34,7 @@ import { AWS_SERVICE_ACCOUNT_PROVIDER_ID } from '@/lib/oauth/types' * sessions resolved from them stay out of block outputs, logs, and model input, * with two exceptions: an S3 presigned URL made with temporary credentials * carries the temporary access key ID and session token (never the secret), and + * stops working when that session ends, so its expiry can't outlast the session; * operations whose purpose is to issue AWS credentials (STS assume role and get * session token, IAM create access key) return the credentials they issue, as * the same operations do with inline keys. Using a credential therefore includes @@ -126,6 +127,10 @@ export interface AwsSessionCredentials { accessKeyId: string secretAccessKey: string sessionToken?: string + /** The STS session length Sim requested; long-lived access keys have none. */ + durationSeconds?: number + /** When an STS session ends; long-lived access keys have none. */ + expiration?: Date } /** STS lives in every partition; a role's partition decides the default endpoint. */ @@ -145,7 +150,10 @@ export function buildAwsRoleSessionName(label: string | undefined): string { return `sim-${cleaned || 'credential'}` } -function toSessionCredentials(credentials: Credentials | undefined): AwsSessionCredentials { +function toSessionCredentials( + credentials: Credentials | undefined, + durationSeconds: number +): AwsSessionCredentials { if (!credentials?.AccessKeyId || !credentials.SecretAccessKey || !credentials.SessionToken) { throw new AwsCredentialError('AWS STS returned no credentials') } @@ -153,6 +161,8 @@ function toSessionCredentials(credentials: Credentials | undefined): AwsSessionC accessKeyId: credentials.AccessKeyId, secretAccessKey: credentials.SecretAccessKey, sessionToken: credentials.SessionToken, + durationSeconds, + ...(credentials.Expiration ? { expiration: credentials.Expiration } : {}), } } @@ -186,7 +196,7 @@ export async function resolveAwsSessionCredentials( }), { abortSignal: options.signal } ) - return toSessionCredentials(response.Credentials) + return toSessionCredentials(response.Credentials, durationSeconds) } const webIdentityToken = await mintWorkloadIdentityToken({ subject: config.subject, @@ -212,7 +222,7 @@ export async function resolveAwsSessionCredentials( }), { abortSignal: options.signal } ) - return toSessionCredentials(response.Credentials) + return toSessionCredentials(response.Credentials, durationSeconds) } /** The auth mode a decrypted AWS secret holds, or undefined when it is not one. */ diff --git a/apps/sim/lib/credentials/client-credential-accounts/descriptors.ts b/apps/sim/lib/credentials/client-credential-accounts/descriptors.ts index a9d365612cd..4a7ad9e5ab8 100644 --- a/apps/sim/lib/credentials/client-credential-accounts/descriptors.ts +++ b/apps/sim/lib/credentials/client-credential-accounts/descriptors.ts @@ -10,6 +10,9 @@ * lives in `@/lib/credentials/client-credential-accounts/server`. */ +import { CROWDSTRIKE_CLOUDS } from '@/tools/crowdstrike/types' +import { DATADOG_SITE_LABELS, DATADOG_SITES } from '@/tools/datadog/types' + /** Discriminator stored inside every encrypted client-credential secret blob. */ export const CLIENT_CREDENTIAL_ACCOUNT_SECRET_TYPE = 'client_credential_account' as const @@ -24,6 +27,7 @@ export type ClientCredentialAccountFieldId = | 'authMethod' | 'privateKey' | 'username' + | 'apiToken' /** * The field id that selects between a descriptor's auth methods. A descriptor @@ -136,6 +140,11 @@ export const GREENHOUSE_SERVICE_ACCOUNT_PROVIDER_ID = 'greenhouse-service-accoun export const JAMA_SERVICE_ACCOUNT_PROVIDER_ID = 'jama-service-account' as const export const HELPSCOUT_SERVICE_ACCOUNT_PROVIDER_ID = 'helpscout-service-account' as const export const DOCUSIGN_SERVICE_ACCOUNT_PROVIDER_ID = 'docusign-service-account' as const +export const CROWDSTRIKE_SERVICE_ACCOUNT_PROVIDER_ID = 'crowdstrike-service-account' as const +export const DATADOG_SERVICE_ACCOUNT_PROVIDER_ID = 'datadog-service-account' as const +export const SAILPOINT_SERVICE_ACCOUNT_PROVIDER_ID = 'sailpoint-service-account' as const +export const ZOOMINFO_SERVICE_ACCOUNT_PROVIDER_ID = 'zoominfo-service-account' as const +export const ZENDESK_SERVICE_ACCOUNT_PROVIDER_ID = 'zendesk-service-account' as const /** Permissions requested when connecting a Manage Vanta application. */ export const VANTA_PERMISSION_OPTIONS = [ @@ -162,6 +171,11 @@ export type ClientCredentialAccountProviderId = | typeof HELPSCOUT_SERVICE_ACCOUNT_PROVIDER_ID | typeof JAMA_SERVICE_ACCOUNT_PROVIDER_ID | typeof DOCUSIGN_SERVICE_ACCOUNT_PROVIDER_ID + | typeof CROWDSTRIKE_SERVICE_ACCOUNT_PROVIDER_ID + | typeof DATADOG_SERVICE_ACCOUNT_PROVIDER_ID + | typeof SAILPOINT_SERVICE_ACCOUNT_PROVIDER_ID + | typeof ZOOMINFO_SERVICE_ACCOUNT_PROVIDER_ID + | typeof ZENDESK_SERVICE_ACCOUNT_PROVIDER_ID /** * Exact account-specific SuiteTalk origin accepted by NetSuite's OAuth and @@ -397,6 +411,50 @@ export function resolveZohoDeskDataCenter(rawDataCenter?: string): { return { id, ...ZOHO_DESK_DATA_CENTERS[id] } } +/** + * A SailPoint Identity Security Cloud API host: one tenant label under `api.identitynow.com`, or + * under `api.identitynowgov.com` for government tenants (the same hosts the legacy block accepts). + */ +const SAILPOINT_API_HOST_REGEX = + /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.api\.identitynow(?:gov)?\.com$/ + +const SAILPOINT_TENANT_LABEL_REGEX = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/ +const SAILPOINT_UI_HOST_REGEX = /^([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)\.identitynow\.com$/ +/** `api.identitynow.com` is SailPoint's API domain itself, so `api` never names a tenant. */ +const SAILPOINT_API_DOMAIN_AS_TENANT = 'api.api.identitynow.com' + +/** + * Normalizes a SailPoint tenant name, API host, UI host, or https URL of either + * to the tenant's API host (`{tenant}.api.identitynow.com`, or the same under + * `api.identitynowgov.com` for a government tenant) for API calls and token + * requests. Returns `undefined` + * for anything else (other domains, credentials, ports, more than one tenant + * label, or `api`, SailPoint's own API domain) rather than throwing, so the + * connect modal's format hint and the minter share one admission rule. + * @see https://developer.sailpoint.com/docs/api/authentication + */ +export function normalizeSailPointTenantHost(rawTenant: string): string | undefined { + const host = toSailPointApiHost(rawTenant.trim().toLowerCase()) + return host === SAILPOINT_API_DOMAIN_AS_TENANT ? undefined : host +} + +function toSailPointApiHost(tenant: string): string | undefined { + if (!tenant) return undefined + if (SAILPOINT_TENANT_LABEL_REGEX.test(tenant)) return `${tenant}.api.identitynow.com` + let parsed: URL + try { + parsed = new URL(tenant.includes('://') ? tenant : `https://${tenant}`) + } catch { + return undefined + } + if (parsed.protocol !== 'https:' || parsed.username || parsed.password || parsed.port) { + return undefined + } + if (SAILPOINT_API_HOST_REGEX.test(parsed.hostname)) return parsed.hostname + const uiHost = SAILPOINT_UI_HOST_REGEX.exec(parsed.hostname) + return uiHost ? `${uiHost[1]}.api.identitynow.com` : undefined +} + /** * DocuSign's two account systems. Each environment has its own authentication * service and its own eSignature REST hosts, and a token from one is never @@ -454,6 +512,40 @@ function buildDocuSignConsentUrl( return `https://${DOCUSIGN_ENVIRONMENTS[resolved].authHost}/oauth/auth?${params.toString()}` } +/** A Zendesk account subdomain: the `{subdomain}` in `{subdomain}.zendesk.com`. */ +export const ZENDESK_SUBDOMAIN_REGEX = /^[a-z0-9][a-z0-9-]*$/ + +/** + * Normalizes a pasted Zendesk subdomain, accepting the full `https://acme.zendesk.com` URL as + * well as the bare `acme`. Shared by the connect modal's format hint and the server-side minter + * so both judge the same value; the minter refuses anything that does not then match + * {@link ZENDESK_SUBDOMAIN_REGEX}. + */ +export function normalizeZendeskSubdomain(rawSubdomain: string): string { + return rawSubdomain + .trim() + .toLowerCase() + .replace(/^https?:\/\//, '') + .replace(/[/?#].*$/, '') + .replace(/\.zendesk\.com$/, '') +} + +/** Zendesk's two server-to-server sign-ins: an OAuth client's client-credentials grant, or an API token. */ +export const ZENDESK_AUTH_METHODS = { + clientCredentials: 'client_credentials', + apiToken: 'api_token', +} as const + +/** Permissions a Zendesk client-credentials token requests. */ +const ZENDESK_SCOPE_OPTIONS: ReadonlyArray = [ + { value: 'read', label: 'Read only' }, + { value: 'read write', label: 'Read and write' }, +] + +export const ZENDESK_SCOPES: ReadonlySet = new Set( + ZENDESK_SCOPE_OPTIONS.map((option) => option.value) +) + export const CLIENT_CREDENTIAL_ACCOUNT_DESCRIPTORS: Record< ClientCredentialAccountProviderId, ClientCredentialAccountDescriptor @@ -486,6 +578,129 @@ export const CLIENT_CREDENTIAL_ACCOUNT_DESCRIPTORS: Record< helpText: 'Create API credentials in your Jama profile. Your account needs REST API access and a Named Creator license.', }, + [ZOOMINFO_SERVICE_ACCOUNT_PROVIDER_ID]: { + providerId: ZOOMINFO_SERVICE_ACCOUNT_PROVIDER_ID, + serviceLabel: 'ZoomInfo', + connectNoun: 'API app', + fields: [ + { + id: 'clientId', + label: 'Client ID', + placeholder: 'Paste the client ID', + secret: false, + hint: 'Create a client credentials app in the ZoomInfo Developer Portal. Its integration user and scopes apply to every call.', + }, + { + id: 'clientSecret', + label: 'Client secret', + placeholder: 'Paste the client secret', + secret: true, + }, + ], + docsUrl: 'https://docs.sim.ai/integrations/zoominfo', + }, + [SAILPOINT_SERVICE_ACCOUNT_PROVIDER_ID]: { + providerId: SAILPOINT_SERVICE_ACCOUNT_PROVIDER_ID, + serviceLabel: 'SailPoint', + connectNoun: 'API client', + fields: [ + { + id: 'orgId', + label: 'Tenant', + placeholder: 'acme or acme.api.identitynow.com', + secret: false, + hintPattern: SAILPOINT_API_HOST_REGEX, + hintNormalize: (value) => normalizeSailPointTenantHost(value) ?? value.trim().toLowerCase(), + hintMessage: + 'Expected your tenant name or its API host, like acme, acme.api.identitynow.com, or agency.api.identitynowgov.com.', + }, + { + id: 'clientId', + label: 'Client ID', + placeholder: 'Paste the client ID', + secret: false, + hint: 'Use an API client with the client credentials grant, or a personal access token.', + }, + { + id: 'clientSecret', + label: 'Client secret', + placeholder: 'Paste the client secret', + secret: true, + }, + ], + docsUrl: 'https://docs.sim.ai/integrations/sailpoint', + helpText: + 'The tenant name is shown under Admin > Dashboard > Overview. For a vanity domain, use the tenant name.', + }, + [DATADOG_SERVICE_ACCOUNT_PROVIDER_ID]: { + providerId: DATADOG_SERVICE_ACCOUNT_PROVIDER_ID, + serviceLabel: 'Datadog', + connectNoun: 'API key pair', + fields: [ + { + id: 'dataCenter', + label: 'Site', + placeholder: 'Select a site', + secret: false, + options: DATADOG_SITES.map((site) => ({ value: site, label: DATADOG_SITE_LABELS[site] })), + hint: 'The site in the address you sign in to Datadog with.', + }, + { + id: 'clientId', + label: 'API key', + placeholder: 'Paste the API key', + secret: true, + hint: 'Create keys in Datadog under Organization Settings > API Keys and Application Keys.', + }, + { + id: 'clientSecret', + label: 'Application key', + placeholder: 'Paste the application key', + secret: true, + hint: 'Workflows act with this key and its scopes. Datadog checks it on the first request that needs it.', + }, + ], + docsUrl: 'https://docs.sim.ai/integrations/datadog', + }, + [CROWDSTRIKE_SERVICE_ACCOUNT_PROVIDER_ID]: { + providerId: CROWDSTRIKE_SERVICE_ACCOUNT_PROVIDER_ID, + serviceLabel: 'CrowdStrike', + connectNoun: 'API client', + fields: [ + { + id: 'dataCenter', + label: 'Cloud', + placeholder: 'Select a cloud', + secret: false, + // The Falcon console names each cloud by its id in upper case (US-1, US-GOV-1). + options: CROWDSTRIKE_CLOUDS.map((cloud) => ({ value: cloud, label: cloud.toUpperCase() })), + hint: 'The Falcon cloud your console signs in to.', + }, + { + id: 'clientId', + label: 'Client ID', + placeholder: 'Paste the client ID', + secret: false, + hint: 'Create the API client in Falcon under Support and resources > API clients and keys, with the scopes your workflows need.', + }, + { + id: 'clientSecret', + label: 'Client secret', + placeholder: 'Paste the client secret', + secret: true, + }, + { + id: 'orgId', + label: 'Member CID', + placeholder: 'Only for an MSSP parent CID', + secret: false, + optional: true, + }, + ], + docsUrl: 'https://docs.sim.ai/integrations/crowdstrike', + helpText: + 'Leave blank unless the API client belongs to a parent CID and every call should act on one child CID.', + }, [DOCUSIGN_SERVICE_ACCOUNT_PROVIDER_ID]: { providerId: DOCUSIGN_SERVICE_ACCOUNT_PROVIDER_ID, serviceLabel: 'DocuSign', @@ -549,6 +764,84 @@ export const CLIENT_CREDENTIAL_ACCOUNT_DESCRIPTORS: Record< consentUrl: (values, redirectUri) => buildDocuSignConsentUrl(values.clientId ?? '', values.dataCenter, redirectUri), }, + [ZENDESK_SERVICE_ACCOUNT_PROVIDER_ID]: { + providerId: ZENDESK_SERVICE_ACCOUNT_PROVIDER_ID, + serviceLabel: 'Zendesk', + connectNoun: 'API credential', + fields: [ + { + id: 'authMethod', + label: 'Authentication method', + placeholder: 'Select a method', + secret: false, + optional: true, + options: [ + { + value: ZENDESK_AUTH_METHODS.clientCredentials, + label: 'OAuth client (client credentials)', + }, + { value: ZENDESK_AUTH_METHODS.apiToken, label: 'API token (deprecated by Zendesk)' }, + ], + }, + { + id: 'orgId', + label: 'Subdomain', + placeholder: 'your-company', + secret: false, + hintPattern: ZENDESK_SUBDOMAIN_REGEX, + hintNormalize: normalizeZendeskSubdomain, + hintMessage: 'Expected the subdomain from your-company.zendesk.com, like your-company.', + }, + { + id: 'clientId', + label: 'Client identifier', + placeholder: 'Paste the OAuth client unique identifier', + secret: false, + optional: true, + requiredForAuthMethods: [ZENDESK_AUTH_METHODS.clientCredentials], + hint: 'Create a confidential OAuth client in Admin Center under Apps and integrations > APIs > OAuth Clients.', + }, + { + id: 'clientSecret', + label: 'Client secret', + placeholder: 'Paste the client secret', + secret: true, + optional: true, + requiredForAuthMethods: [ZENDESK_AUTH_METHODS.clientCredentials], + }, + { + id: 'scope', + label: 'Permissions', + placeholder: 'Select permissions', + secret: false, + optional: true, + requiredForAuthMethods: [ZENDESK_AUTH_METHODS.clientCredentials], + options: ZENDESK_SCOPE_OPTIONS, + hint: 'Every call runs as the user the OAuth client belongs to, with that user’s Zendesk role.', + }, + { + id: 'username', + label: 'Agent email', + placeholder: 'agent@your-company.com', + secret: false, + optional: true, + requiredForAuthMethods: [ZENDESK_AUTH_METHODS.apiToken], + hint: 'Every call runs as this agent or admin, with their Zendesk role.', + }, + { + id: 'apiToken', + label: 'API token', + placeholder: 'Paste the API token', + secret: true, + optional: true, + requiredForAuthMethods: [ZENDESK_AUTH_METHODS.apiToken], + hint: 'Zendesk has deprecated API tokens in favor of OAuth. Use an OAuth client unless you cannot create one.', + }, + ], + defaultAuthMethod: ZENDESK_AUTH_METHODS.clientCredentials, + docsUrl: 'https://docs.sim.ai/integrations/zendesk', + helpText: 'Sim connects only to your-company.zendesk.com; host-mapped domains are not used.', + }, [RAMP_SERVICE_ACCOUNT_PROVIDER_ID]: { providerId: RAMP_SERVICE_ACCOUNT_PROVIDER_ID, serviceLabel: 'Ramp', @@ -1031,6 +1324,16 @@ export function resolveSalesforceAuthMethod(authMethod: string | undefined): str ) } +/** Resolves a stored Zendesk auth method against the descriptor's options, for the minter. */ +export function resolveZendeskAuthMethod(authMethod: string | undefined): string { + return ( + resolveClientCredentialAuthMethod( + CLIENT_CREDENTIAL_ACCOUNT_DESCRIPTORS[ZENDESK_SERVICE_ACCOUNT_PROVIDER_ID], + authMethod + ) ?? ZENDESK_AUTH_METHODS.clientCredentials + ) +} + export function isClientCredentialAccountProviderId( value: string | null | undefined ): value is ClientCredentialAccountProviderId { diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/crowdstrike.test.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/crowdstrike.test.ts new file mode 100644 index 00000000000..8a6b2bbf57a --- /dev/null +++ b/apps/sim/lib/credentials/client-credential-accounts/minters/crowdstrike.test.ts @@ -0,0 +1,147 @@ +import { jsonResponse } from '@sim/testing/helpers/http' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { mintCrowdStrikeServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/crowdstrike' + +const FIELDS = { + clientId: 'falcon-client', + clientSecret: 'falcon-secret', + orgId: '', + dataCenter: 'eu-1', +} + +const mockFetch = vi.fn() + +describe('CrowdStrike API client token exchange', () => { + beforeEach(() => { + mockFetch.mockReset() + vi.stubGlobal('fetch', mockFetch) + }) + + it("posts the client credentials to the selected cloud's token endpoint without following redirects", async () => { + const requests: Request[] = [] + mockFetch.mockImplementation(async (input, init) => { + const request = new Request(input, init) + requests.push(request) + return jsonResponse({ access_token: 'falcon-access', expires_in: 1799 }) + }) + + const result = await mintCrowdStrikeServiceAccountToken(FIELDS) + + expect(requests).toHaveLength(1) + const [request] = requests + expect(request.url).toBe('https://api.eu-1.crowdstrike.com/oauth2/token') + expect(request.method).toBe('POST') + expect(request.redirect).toBe('manual') + expect(request.headers.get('content-type')).toBe('application/x-www-form-urlencoded') + expect(Object.fromEntries(new URLSearchParams(await request.text()))).toEqual({ + grant_type: 'client_credentials', + client_id: 'falcon-client', + client_secret: 'falcon-secret', + }) + expect(result).toMatchObject({ + accessToken: 'falcon-access', + expiresInSeconds: 1799, + domain: 'eu-1', + }) + expect(JSON.stringify(result.identity)).not.toContain('falcon-secret') + expect(JSON.stringify(result.identity)).not.toContain('falcon-access') + }) + + it('locks the token to the member CID an MSSP parent names', async () => { + let body = '' + mockFetch.mockImplementation(async (input, init) => { + body = await new Request(input, init).text() + return jsonResponse({ access_token: 'falcon-access', expires_in: 1799 }) + }) + + const result = await mintCrowdStrikeServiceAccountToken({ ...FIELDS, orgId: ' child-cid ' }) + + expect(new URLSearchParams(body).get('member_cid')).toBe('child-cid') + expect(result.identity?.auditMetadata).toMatchObject({ crowdstrikeMemberCid: 'child-cid' }) + }) + + it.each(['', 'us-4', 'api.crowdstrike.com', 'https://evil.example'])( + 'refuses the cloud %j before sending the secret anywhere', + async (dataCenter) => { + await expect( + mintCrowdStrikeServiceAccountToken({ ...FIELDS, dataCenter }) + ).rejects.toMatchObject({ code: 'invalid_credentials' }) + expect(mockFetch).not.toHaveBeenCalled() + } + ) + + it.each([ + [400, 'invalid_credentials'], + [401, 'invalid_credentials'], + [403, 'invalid_credentials'], + [429, 'provider_unavailable'], + [503, 'provider_unavailable'], + ])('maps an HTTP %i token response to %s', async (status, code) => { + mockFetch.mockResolvedValue( + jsonResponse({ errors: [{ code: status, message: 'access denied' }] }, { status }) + ) + + await expect(mintCrowdStrikeServiceAccountToken(FIELDS)).rejects.toMatchObject({ code }) + }) + + it("reports a redirect to the API client's own cloud as a wrong cloud and never follows it", async () => { + mockFetch.mockResolvedValue( + new Response(null, { + status: 308, + headers: { + Location: 'https://api.us-2.crowdstrike.com/oauth2/token', + 'X-Cs-Region': 'us-2', + }, + }) + ) + + const error = await mintCrowdStrikeServiceAccountToken(FIELDS).catch((caught) => caught) + + expect(error).toMatchObject({ + code: 'invalid_credentials', + status: 400, + logDetail: { cloud: 'eu-1', apiClientCloud: 'us-2' }, + }) + expect(JSON.stringify(error.logDetail)).not.toContain('https://') + expect(mockFetch).toHaveBeenCalledTimes(1) + }) + + it('reports any other redirect from the token endpoint as a wrong cloud without naming one', async () => { + mockFetch.mockResolvedValue( + new Response(null, { + status: 302, + headers: { Location: 'https://evil.example/oauth2/token', 'X-Cs-Region': 'evil.example' }, + }) + ) + + const error = await mintCrowdStrikeServiceAccountToken(FIELDS).catch((caught) => caught) + + expect(error).toMatchObject({ code: 'invalid_credentials', status: 400 }) + expect(JSON.stringify(error.logDetail)).not.toContain('evil.example') + expect(mockFetch).toHaveBeenCalledTimes(1) + }) + + it('treats a network failure reaching the token endpoint as a provider failure', async () => { + mockFetch.mockRejectedValue(new TypeError('fetch failed')) + + await expect(mintCrowdStrikeServiceAccountToken(FIELDS)).rejects.toMatchObject({ + code: 'provider_unavailable', + }) + }) + + it('refuses a token response without an access token', async () => { + mockFetch.mockResolvedValue(jsonResponse({ expires_in: 1799 })) + + await expect(mintCrowdStrikeServiceAccountToken(FIELDS)).rejects.toMatchObject({ + code: 'provider_unavailable', + }) + }) + + it('falls back to the documented 30-minute lifetime when expires_in is unusable', async () => { + mockFetch.mockResolvedValue(jsonResponse({ access_token: 'falcon-access', expires_in: 'soon' })) + + await expect(mintCrowdStrikeServiceAccountToken(FIELDS)).resolves.toMatchObject({ + expiresInSeconds: 1800, + }) + }) +}) diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/crowdstrike.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/crowdstrike.ts new file mode 100644 index 00000000000..6bf77c4c8fb --- /dev/null +++ b/apps/sim/lib/credentials/client-credential-accounts/minters/crowdstrike.ts @@ -0,0 +1,109 @@ +import type { + ClientCredentialAccountFields, + ClientCredentialAccountMintOptions, + ClientCredentialAccountMintResult, +} from '@/lib/credentials/client-credential-accounts/server' +import { tenantPrincipal } from '@/lib/credentials/principal' +import { + providerFailureCode, + providerFailureReason, + requireClientSecret, + TokenServiceAccountValidationError, +} from '@/lib/credentials/token-service-accounts/errors' +import { + CrowdStrikeAuthError, + CrowdStrikeTokenRedirectError, + isCrowdStrikeCloud, + requestCrowdStrikeToken, +} from '@/lib/internal/crowdstrike/client' + +const STEP = 'crowdstrike_token_mint' +const TOKEN_EXCHANGE_TIMEOUT_MS = 10_000 +const MAX_MEMBER_CID_LENGTH = 128 +const FORBIDDEN_FIELD_TEXT = /[\u0000-\u001f\u007f\s]/ + +function invalidCredentials(reason: string): TokenServiceAccountValidationError { + return new TokenServiceAccountValidationError('invalid_credentials', 400, { step: STEP, reason }) +} + +/** + * Mints a Falcon API access token with the API client's id and secret through + * the token exchange the legacy block shares. The cloud comes only from the + * Falcon cloud allowlist, and the exchange never follows a redirect, so the + * secret reaches no other host. Falcon redirects a token request sent to the + * wrong cloud, so a redirect is the user's cloud choice being wrong, not an + * outage. Tokens last 30 minutes; the credential cache re-mints before they + * lapse. + * + * Falcon exposes no identity for the API client in the token response, so the + * connect-time check is the mint itself. + * + * @see https://developer.crowdstrike.com/api-reference/collections/oauth2.md + */ +export async function mintCrowdStrikeServiceAccountToken( + fields: ClientCredentialAccountFields, + options?: ClientCredentialAccountMintOptions +): Promise { + options?.signal?.throwIfAborted() + const clientSecret = requireClientSecret(fields.clientSecret, STEP, 'CrowdStrike') + const cloud = fields.dataCenter?.trim() + if (!isCrowdStrikeCloud(cloud)) throw invalidCredentials('unknown Falcon cloud') + const memberCid = fields.orgId.trim() + if ( + memberCid && + (memberCid.length > MAX_MEMBER_CID_LENGTH || FORBIDDEN_FIELD_TEXT.test(memberCid)) + ) { + throw invalidCredentials('member CID is invalid') + } + + const timeout = AbortSignal.timeout(TOKEN_EXCHANGE_TIMEOUT_MS) + let minted: { token: string; expiresInSeconds: number } + try { + minted = await requestCrowdStrikeToken( + { clientId: fields.clientId, clientSecret, cloud, memberCid: memberCid || undefined }, + { + followRedirects: false, + signal: options?.signal ? AbortSignal.any([options.signal, timeout]) : timeout, + } + ) + } catch (error) { + options?.signal?.throwIfAborted() + if (error instanceof CrowdStrikeTokenRedirectError) { + throw new TokenServiceAccountValidationError('invalid_credentials', 400, { + step: STEP, + reason: error.region + ? 'the API client belongs to another Falcon cloud' + : 'token endpoint answered with a redirect', + cloud, + ...(error.region ? { apiClientCloud: error.region } : {}), + }) + } + if (error instanceof CrowdStrikeAuthError) { + throw new TokenServiceAccountValidationError( + providerFailureCode(error.status), + error.status, + { step: STEP, cloud } + ) + } + throw new TokenServiceAccountValidationError('provider_unavailable', 502, { + step: STEP, + reason: providerFailureReason(error), + }) + } + + return { + accessToken: minted.token, + expiresInSeconds: minted.expiresInSeconds, + domain: cloud, + identity: { + displayName: memberCid ? `CrowdStrike ${memberCid}` : `CrowdStrike ${cloud.toUpperCase()}`, + principal: memberCid ? tenantPrincipal(memberCid) : null, + auditMetadata: { + crowdstrikeClientId: fields.clientId, + crowdstrikeCloud: cloud, + ...(memberCid ? { crowdstrikeMemberCid: memberCid } : {}), + }, + storedMetadata: { cloud }, + }, + } +} diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/datadog.test.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/datadog.test.ts new file mode 100644 index 00000000000..eaf3a05e257 --- /dev/null +++ b/apps/sim/lib/credentials/client-credential-accounts/minters/datadog.test.ts @@ -0,0 +1,109 @@ +import { jsonResponse } from '@sim/testing/helpers/http' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { mintDatadogServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/datadog' + +const FIELDS = { + clientId: 'dd-api-key', + clientSecret: 'dd-application-key', + orgId: '', + dataCenter: 'datadoghq.eu', +} + +const mockFetch = vi.fn() + +describe('Datadog API and application key check', () => { + beforeEach(() => { + mockFetch.mockReset() + vi.stubGlobal('fetch', mockFetch) + }) + + it("validates the API key alone on the site's own API host, never following a redirect", async () => { + const requests: Request[] = [] + mockFetch.mockImplementation(async (input, init) => { + requests.push(new Request(input, init)) + return jsonResponse({ valid: true }) + }) + + const result = await mintDatadogServiceAccountToken(FIELDS) + + expect(requests).toHaveLength(1) + const [request] = requests + expect(request.url).toBe('https://api.datadoghq.eu/api/v1/validate') + expect(request.method).toBe('GET') + expect(request.redirect).toBe('error') + expect(request.headers.get('dd-api-key')).toBe('dd-api-key') + expect(request.headers.has('dd-application-key')).toBe(false) + expect(result).toMatchObject({ + accessToken: 'dd-api-key', + applicationKey: 'dd-application-key', + domain: 'datadoghq.eu', + }) + expect(JSON.stringify(result.identity)).not.toContain('dd-api-key') + expect(JSON.stringify(result.identity)).not.toContain('dd-application-key') + }) + + it('resolves both keys and the site at run time without calling Datadog', async () => { + const result = await mintDatadogServiceAccountToken(FIELDS, { skipIdentity: true }) + + expect(mockFetch).not.toHaveBeenCalled() + expect(result).toMatchObject({ + accessToken: 'dd-api-key', + applicationKey: 'dd-application-key', + domain: 'datadoghq.eu', + }) + }) + + it.each(['', 'datadoghq.co', 'evil.example', 'datadoghq.com@evil.example', 'DATADOGHQ.COM'])( + 'refuses the site %j before sending a key anywhere', + async (dataCenter) => { + await expect(mintDatadogServiceAccountToken({ ...FIELDS, dataCenter })).rejects.toMatchObject( + { code: 'invalid_credentials' } + ) + await expect( + mintDatadogServiceAccountToken({ ...FIELDS, dataCenter }, { skipIdentity: true }) + ).rejects.toMatchObject({ code: 'invalid_credentials' }) + expect(mockFetch).not.toHaveBeenCalled() + } + ) + + it.each([ + ['clientId', 'key\r\nX-Injected: 1'], + ['clientSecret', 'key\nX-Injected: 1'], + ['clientSecret', ''], + ] as const)('refuses an unusable %s before any request', async (field, value) => { + await expect( + mintDatadogServiceAccountToken({ ...FIELDS, [field]: value }) + ).rejects.toMatchObject({ code: 'invalid_credentials' }) + expect(mockFetch).not.toHaveBeenCalled() + }) + + it.each([ + [403, 'invalid_credentials'], + [401, 'invalid_credentials'], + [429, 'provider_unavailable'], + [500, 'provider_unavailable'], + ])('maps an HTTP %i validate response to %s', async (status, code) => { + mockFetch.mockResolvedValue(jsonResponse({ errors: ['Forbidden'] }, { status })) + + await expect(mintDatadogServiceAccountToken(FIELDS)).rejects.toMatchObject({ code }) + }) + + it('refuses an API key Datadog does not report as valid', async () => { + mockFetch.mockResolvedValue(jsonResponse({ valid: false })) + + await expect(mintDatadogServiceAccountToken(FIELDS)).rejects.toMatchObject({ + code: 'invalid_credentials', + }) + }) + + it.each([ + ['no valid field', {}], + ['a non-boolean valid field', { valid: 'true' }], + ])('reports a validate answer with %s as a provider fault, not a bad key', async (_, body) => { + mockFetch.mockResolvedValue(jsonResponse(body)) + + await expect(mintDatadogServiceAccountToken(FIELDS)).rejects.toMatchObject({ + code: 'provider_unavailable', + }) + }) +}) diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/datadog.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/datadog.ts new file mode 100644 index 00000000000..a2cd681d902 --- /dev/null +++ b/apps/sim/lib/credentials/client-credential-accounts/minters/datadog.ts @@ -0,0 +1,111 @@ +import { toRecord } from '@sim/utils/object' +import { readResponseJsonWithLimit } from '@/lib/core/utils/stream-limits' +import type { + ClientCredentialAccountFields, + ClientCredentialAccountMintOptions, + ClientCredentialAccountMintResult, +} from '@/lib/credentials/client-credential-accounts/server' +import { + assertCredentialText, + fetchProvider, + requireClientSecret, + TokenServiceAccountValidationError, + throwForProviderResponse, +} from '@/lib/credentials/token-service-accounts/errors' +import { datadogApiUrl, isDatadogSite } from '@/tools/datadog/utils' + +const STEP = 'datadog_key_check' +/** Datadog keys do not expire on their own; the TTL only bounds the local token cache. */ +const SYNTHETIC_TOKEN_TTL_SECONDS = 3600 +const MAX_KEY_BYTES = 1_024 +const MAX_VALIDATE_RESPONSE_BYTES = 16 * 1024 + +function invalidCredentials(reason: string): TokenServiceAccountValidationError { + return new TokenServiceAccountValidationError('invalid_credentials', 400, { step: STEP, reason }) +} + +/** + * Resolves a saved Datadog key pair. Datadog authenticates every request with + * two static headers, so nothing is minted: the API key travels as the + * credential's token, the application key beside it, and the site (one of + * Datadog's published regional sites) as its domain, which the tools build + * their host from. + * + * Connect-time verification calls `GET https://api.{site}/api/v1/validate` + * with the API key alone, the documented check that answers 403 for an + * invalid API key. The application key is deliberately not probed: Datadog + * publishes no reference for an application-key check, and any other read + * would refuse a correctly scoped key with a permission 403. Datadog reports a + * bad application key on the first request that needs one. + * + * @see https://docs.datadoghq.com/api/latest/authentication/#validate-api-key + */ +export async function mintDatadogServiceAccountToken( + fields: ClientCredentialAccountFields, + options?: ClientCredentialAccountMintOptions +): Promise { + options?.signal?.throwIfAborted() + const apiKey = fields.clientId.trim() + const applicationKey = requireClientSecret(fields.clientSecret, STEP, 'Datadog').trim() + assertCredentialText(apiKey, 'API key', STEP, { maxBytes: MAX_KEY_BYTES, forbidSpaces: true }) + assertCredentialText(applicationKey, 'application key', STEP, { + maxBytes: MAX_KEY_BYTES, + forbidSpaces: true, + }) + const site = fields.dataCenter?.trim() + if (!isDatadogSite(site)) throw invalidCredentials('unknown Datadog site') + + const resolved = { + accessToken: apiKey, + applicationKey, + domain: site, + expiresInSeconds: SYNTHETIC_TOKEN_TTL_SECONDS, + } + if (options?.skipIdentity) return resolved + + const response = await fetchProvider( + datadogApiUrl(site, '/api/v1/validate'), + { + method: 'GET', + redirect: 'error', + signal: options?.signal, + headers: { Accept: 'application/json', 'DD-API-KEY': apiKey }, + }, + STEP + ) + await throwForProviderResponse(response, STEP, { site }) + + let body: Record + try { + body = toRecord( + await readResponseJsonWithLimit(response, { + maxBytes: MAX_VALIDATE_RESPONSE_BYTES, + label: 'Datadog validate response', + signal: options?.signal, + }) + ) + } catch { + options?.signal?.throwIfAborted() + throw new TokenServiceAccountValidationError('provider_unavailable', 502, { + step: STEP, + reason: 'provider returned an invalid or oversized response', + }) + } + if (typeof body.valid !== 'boolean') { + throw new TokenServiceAccountValidationError('provider_unavailable', 502, { + step: STEP, + reason: 'Datadog validate response did not report whether the key is valid', + }) + } + if (!body.valid) throw invalidCredentials('Datadog did not report the API key valid') + + return { + ...resolved, + identity: { + displayName: `Datadog ${site}`, + principal: null, + auditMetadata: { datadogSite: site }, + storedMetadata: { site }, + }, + } +} diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/fivetran.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/fivetran.ts index ef4e374d21d..430f9b372cf 100644 --- a/apps/sim/lib/credentials/client-credential-accounts/minters/fivetran.ts +++ b/apps/sim/lib/credentials/client-credential-accounts/minters/fivetran.ts @@ -7,6 +7,7 @@ import type { } from '@/lib/credentials/client-credential-accounts/server' import { tenantPrincipal, userPrincipal } from '@/lib/credentials/principal' import { + assertCredentialText, fetchProvider, parseProviderJson, requireClientSecret, @@ -19,22 +20,11 @@ const STEP = 'fivetran_basic_auth' /** Fivetran keys do not expire on their own; the TTL only bounds the local token cache. */ const SYNTHETIC_TOKEN_TTL_SECONDS = 3600 const MAX_AUTH_VALUE_BYTES = 1_024 -const FORBIDDEN_CREDENTIAL_TEXT = /[\u0000-\u001f\u007f]/ function invalidCredentials(reason: string): TokenServiceAccountValidationError { return new TokenServiceAccountValidationError('invalid_credentials', 400, { step: STEP, reason }) } -function assertCredentialText(value: string, label: string): void { - if ( - !value || - FORBIDDEN_CREDENTIAL_TEXT.test(value) || - Buffer.byteLength(value, 'utf8') > MAX_AUTH_VALUE_BYTES - ) { - throw invalidCredentials(`${label} is invalid`) - } -} - /** * Builds Fivetran's HTTP Basic credential (API key as username, API secret as * password). Connect-time verification calls `GET /v1/account/info`, which @@ -47,9 +37,9 @@ export async function mintFivetranServiceAccountToken( ): Promise { const apiKey = fields.clientId.trim() const apiSecret = requireClientSecret(fields.clientSecret, STEP, 'Fivetran').trim() - assertCredentialText(apiKey, 'API key') + assertCredentialText(apiKey, 'API key', STEP, { maxBytes: MAX_AUTH_VALUE_BYTES }) if (apiKey.includes(':')) throw invalidCredentials('API key is invalid') - assertCredentialText(apiSecret, 'API secret') + assertCredentialText(apiSecret, 'API secret', STEP, { maxBytes: MAX_AUTH_VALUE_BYTES }) const accessToken = Buffer.from(`${apiKey}:${apiSecret}`, 'utf8').toString('base64') if (options?.skipIdentity) { diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/sailpoint.test.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/sailpoint.test.ts new file mode 100644 index 00000000000..186612668a7 --- /dev/null +++ b/apps/sim/lib/credentials/client-credential-accounts/minters/sailpoint.test.ts @@ -0,0 +1,155 @@ +import { jsonResponse } from '@sim/testing/helpers/http' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { mintSailPointServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/sailpoint' + +const FIELDS = { clientId: 'sp-client', clientSecret: 'sp-secret', orgId: 'acme' } + +const mockFetch = vi.fn() + +describe('SailPoint API client token exchange', () => { + beforeEach(() => { + mockFetch.mockReset() + vi.stubGlobal('fetch', mockFetch) + }) + + it("posts the client credentials to the tenant's documented token endpoint and never follows a redirect", async () => { + const requests: Request[] = [] + mockFetch.mockImplementation(async (input, init) => { + requests.push(new Request(input, init)) + return jsonResponse({ access_token: 'sp-access', expires_in: 749 }) + }) + + const result = await mintSailPointServiceAccountToken(FIELDS) + + expect(requests).toHaveLength(1) + const [request] = requests + expect(request.url).toBe('https://acme.api.identitynow.com/oauth/token') + expect(request.method).toBe('POST') + expect(request.redirect).toBe('error') + expect(Object.fromEntries(new URLSearchParams(await request.text()))).toEqual({ + grant_type: 'client_credentials', + client_id: 'sp-client', + client_secret: 'sp-secret', + }) + expect(result).toMatchObject({ + accessToken: 'sp-access', + expiresInSeconds: 749, + domain: 'acme.api.identitynow.com', + }) + expect(JSON.stringify(result.identity)).not.toContain('sp-secret') + expect(JSON.stringify(result.identity)).not.toContain('sp-access') + }) + + it.each([ + 'acme.api.identitynow.com', + 'https://acme.api.identitynow.com/', + 'https://acme.identitynow.com/ui/d/dashboard', + ' ACME ', + ])('mints for the tenant typed as %j at its API host', async (orgId) => { + mockFetch.mockResolvedValue(jsonResponse({ access_token: 'sp-access', expires_in: 749 })) + + const result = await mintSailPointServiceAccountToken({ ...FIELDS, orgId }) + + expect(mockFetch.mock.calls[0]?.[0]).toBe('https://acme.api.identitynow.com/oauth/token') + expect(result.domain).toBe('acme.api.identitynow.com') + }) + + it('mints for a government tenant at its identitynowgov.com API host', async () => { + mockFetch.mockResolvedValue(jsonResponse({ access_token: 'sp-access', expires_in: 749 })) + + const result = await mintSailPointServiceAccountToken({ + ...FIELDS, + orgId: 'https://agency.api.identitynowgov.com/', + }) + + expect(mockFetch.mock.calls[0]?.[0]).toBe('https://agency.api.identitynowgov.com/oauth/token') + expect(result.domain).toBe('agency.api.identitynowgov.com') + expect(result.identity?.displayName).toBe('SailPoint agency') + }) + + it.each([ + '', + 'evil.example', + 'acme.api.identitynow.com.evil.example', + 'acme.api.identitynow.com@evil.example', + 'https://user:pass@acme.api.identitynow.com', + 'https://acme.api.identitynow.com:8443', + 'http://acme.api.identitynow.com', + 'a.b.api.identitynow.com', + 'acme.identitynowgov.com', + 'api', + 'api.identitynow.com', + 'https://api.identitynow.com/', + 'api.api.identitynow.com', + ])('refuses the tenant %j before sending the secret anywhere', async (orgId) => { + await expect(mintSailPointServiceAccountToken({ ...FIELDS, orgId })).rejects.toMatchObject({ + code: 'invalid_credentials', + }) + expect(mockFetch).not.toHaveBeenCalled() + }) + + it.each([ + [400, 'invalid_credentials'], + [401, 'invalid_credentials'], + [403, 'invalid_credentials'], + [500, 'provider_unavailable'], + [503, 'provider_unavailable'], + ])('maps an HTTP %i token response to %s', async (status, code) => { + mockFetch.mockResolvedValue( + jsonResponse({ error: 'invalid_client', error_description: 'Bad credentials' }, { status }) + ) + + await expect(mintSailPointServiceAccountToken(FIELDS)).rejects.toMatchObject({ code }) + }) + + it('treats a network failure or refused redirect as a provider failure', async () => { + mockFetch.mockRejectedValue(new TypeError('fetch failed: unexpected redirect')) + + await expect(mintSailPointServiceAccountToken(FIELDS)).rejects.toMatchObject({ + code: 'provider_unavailable', + }) + }) + + it('reports a tenant whose host does not resolve as a wrong tenant, not an outage', async () => { + mockFetch.mockRejectedValue( + new TypeError('fetch failed', { + cause: Object.assign(new Error('getaddrinfo'), { code: 'ENOTFOUND' }), + }) + ) + + await expect(mintSailPointServiceAccountToken(FIELDS)).rejects.toMatchObject({ + code: 'site_not_found', + status: 400, + }) + }) + + it('keeps a transient DNS failure a provider failure', async () => { + mockFetch.mockRejectedValue( + new TypeError('fetch failed', { + cause: Object.assign(new Error('getaddrinfo'), { code: 'EAI_AGAIN' }), + }) + ) + + await expect(mintSailPointServiceAccountToken(FIELDS)).rejects.toMatchObject({ + code: 'provider_unavailable', + }) + }) + + it('refuses a token response without an access token', async () => { + mockFetch.mockResolvedValue(jsonResponse({ expires_in: 749 })) + + await expect(mintSailPointServiceAccountToken(FIELDS)).rejects.toMatchObject({ + code: 'provider_unavailable', + }) + }) + + it("keeps a caller's cancellation as a cancellation", async () => { + const controller = new AbortController() + const reason = new DOMException('Workflow cancelled', 'AbortError') + controller.abort(reason) + + await expect( + mintSailPointServiceAccountToken(FIELDS, { signal: controller.signal }) + ).rejects.toBe(reason) + }) +}) diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/sailpoint.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/sailpoint.ts new file mode 100644 index 00000000000..881b8f3d351 --- /dev/null +++ b/apps/sim/lib/credentials/client-credential-accounts/minters/sailpoint.ts @@ -0,0 +1,93 @@ +import { normalizeSailPointTenantHost } from '@/lib/credentials/client-credential-accounts/descriptors' +import type { + ClientCredentialAccountFields, + ClientCredentialAccountMintOptions, + ClientCredentialAccountMintResult, +} from '@/lib/credentials/client-credential-accounts/server' +import { + isHostNotFound, + providerFailureCode, + providerFailureReason, + requireClientSecret, + TokenServiceAccountValidationError, +} from '@/lib/credentials/token-service-accounts/errors' +import { requestSailPointToken, SailPointAuthError } from '@/lib/internal/sailpoint/client' + +const STEP = 'sailpoint_token_mint' +/** Bounds the whole exchange, including its 429 backoff retries. */ +const TOKEN_EXCHANGE_TIMEOUT_MS = 30_000 + +/** + * Mints a SailPoint Identity Security Cloud access token for an API client or + * personal access token through the client-credentials grant at the tenant's + * documented token endpoint, `https://{tenant}.api.identitynow.com/oauth/token` (or the same + * path on `api.identitynowgov.com` for a government tenant). + * The tenant comes only from {@link normalizeSailPointTenantHost}, and the + * exchange (shared with the legacy block's inline credentials) refuses + * redirects, so the secret reaches no other host. The token's lifetime comes + * from `expires_in`; the credential cache re-mints before it lapses. + * + * The token response names no identity, so the connect-time check is the mint. + * + * @see https://developer.sailpoint.com/docs/api/authentication + */ +export async function mintSailPointServiceAccountToken( + fields: ClientCredentialAccountFields, + options?: ClientCredentialAccountMintOptions +): Promise { + options?.signal?.throwIfAborted() + const clientSecret = requireClientSecret(fields.clientSecret, STEP, 'SailPoint') + const host = normalizeSailPointTenantHost(fields.orgId) + if (!host) { + throw new TokenServiceAccountValidationError('invalid_credentials', 400, { + step: STEP, + reason: 'tenant is not a SailPoint Identity Security Cloud API host', + }) + } + + const timeout = AbortSignal.timeout(TOKEN_EXCHANGE_TIMEOUT_MS) + let minted: { token: string; expiresInSeconds: number } + try { + minted = await requestSailPointToken( + `https://${host}/oauth/token`, + fields.clientId, + clientSecret, + options?.signal ? AbortSignal.any([options.signal, timeout]) : timeout + ) + } catch (error) { + options?.signal?.throwIfAborted() + if (error instanceof SailPointAuthError) { + throw new TokenServiceAccountValidationError( + providerFailureCode(error.status), + error.status, + { + step: STEP, + tenant: host, + } + ) + } + if (isHostNotFound(error)) { + throw new TokenServiceAccountValidationError('site_not_found', 400, { + step: STEP, + reason: 'tenant host does not resolve', + tenant: host, + }) + } + throw new TokenServiceAccountValidationError('provider_unavailable', 502, { + step: STEP, + reason: providerFailureReason(error), + }) + } + + return { + accessToken: minted.token, + expiresInSeconds: minted.expiresInSeconds, + domain: host, + identity: { + displayName: `SailPoint ${host.split('.')[0]}`, + principal: null, + auditMetadata: { sailpointTenant: host, sailpointClientId: fields.clientId }, + storedMetadata: { tenant: host }, + }, + } +} diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/zendesk.test.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/zendesk.test.ts new file mode 100644 index 00000000000..00ccdccd258 --- /dev/null +++ b/apps/sim/lib/credentials/client-credential-accounts/minters/zendesk.test.ts @@ -0,0 +1,163 @@ +import { jsonResponse } from '@sim/testing/helpers/http' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { mintZendeskServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/zendesk' + +const CLIENT = { + authMethod: 'client_credentials', + orgId: 'https://Acme.zendesk.com/', + clientId: 'sim_client', + clientSecret: 'client-secret', + scope: 'read write', +} + +const API_TOKEN = { + authMethod: 'api_token', + orgId: 'acme', + clientId: '', + username: 'agent@example.com', + apiToken: 'zendesk-api-token', +} + +const AGENT = { user: { id: 42, role: 'agent', email: 'agent@example.com', name: 'Agent' } } + +const mockFetch = vi.fn() + +function requestOf(call: number): Request { + const [input, init] = mockFetch.mock.calls[call] + return new Request(input, init) +} + +describe('Zendesk credential minting', () => { + beforeEach(() => { + mockFetch.mockReset() + vi.stubGlobal('fetch', mockFetch) + }) + + it('exchanges an OAuth client for a bearer token on the account subdomain only', async () => { + mockFetch + .mockResolvedValueOnce( + jsonResponse({ access_token: 'bearer-1', token_type: 'bearer', scope: 'read write' }) + ) + .mockResolvedValueOnce(jsonResponse(AGENT)) + + const result = await mintZendeskServiceAccountToken(CLIENT) + + const exchange = requestOf(0) + expect(exchange.url).toBe('https://acme.zendesk.com/oauth/tokens') + expect(exchange.method).toBe('POST') + expect(exchange.redirect).toBe('error') + expect(exchange.headers.get('content-type')).toBe('application/json') + expect(await exchange.json()).toEqual({ + grant_type: 'client_credentials', + client_id: 'sim_client', + client_secret: 'client-secret', + scope: 'read write', + expires_in: 3600, + }) + const me = requestOf(1) + expect(me.url).toBe('https://acme.zendesk.com/api/v2/users/me.json') + expect(me.headers.get('authorization')).toBe('Bearer bearer-1') + expect(me.redirect).toBe('error') + expect(result).toMatchObject({ + accessToken: 'bearer-1', + expiresInSeconds: 3600, + domain: 'acme', + identity: { principal: { kind: 'user', id: '42' } }, + }) + expect(result.authStyle).toBeUndefined() + expect(JSON.stringify(result.identity)).not.toContain('client-secret') + }) + + it('never keeps a token longer than the lifetime Sim requested', async () => { + mockFetch.mockResolvedValueOnce(jsonResponse({ access_token: 'bearer-1', expires_in: 172800 })) + + const result = await mintZendeskServiceAccountToken(CLIENT, { skipIdentity: true }) + + expect(result.expiresInSeconds).toBe(3600) + expect(mockFetch).toHaveBeenCalledOnce() + }) + + it('derives the documented email/token Basic credential without calling Zendesk at run time', async () => { + const result = await mintZendeskServiceAccountToken(API_TOKEN, { skipIdentity: true }) + + expect(mockFetch).not.toHaveBeenCalled() + expect(result.authStyle).toBe('basic') + expect(result.domain).toBe('acme') + expect(Buffer.from(result.accessToken, 'base64').toString('utf8')).toBe( + 'agent@example.com/token:zendesk-api-token' + ) + }) + + it('verifies an API token sign-in with the same Basic header the tools send', async () => { + mockFetch.mockResolvedValueOnce(jsonResponse(AGENT)) + + const result = await mintZendeskServiceAccountToken(API_TOKEN) + + expect(requestOf(0).headers.get('authorization')).toBe(`Basic ${result.accessToken}`) + expect(result.identity?.principal).toMatchObject({ kind: 'user', id: '42' }) + }) + + it.each([ + ['an anonymous user', { user: { id: null, role: 'end-user', name: 'Anonymous user' } }], + ['an end user', { user: { id: 7, role: 'end-user' } }], + ['no user', {}], + ])('refuses a credential that signs in as %s', async (_label, body) => { + mockFetch.mockResolvedValueOnce(jsonResponse(body)) + + await expect(mintZendeskServiceAccountToken(API_TOKEN)).rejects.toMatchObject({ + code: 'invalid_credentials', + }) + }) + + it.each([ + 'acme.zendesk.com.evil.example', + 'evil.example/acme', + 'acme.other', + '-acme', + 'localhost:8080', + '', + ])('refuses the subdomain %j before any request', async (orgId) => { + await expect(mintZendeskServiceAccountToken({ ...CLIENT, orgId })).rejects.toMatchObject({ + code: 'site_not_found', + }) + expect(mockFetch).not.toHaveBeenCalled() + }) + + it.each([ + ['no scope', { scope: undefined }], + ['an unknown scope', { scope: 'impersonate' }], + ['no client secret', { clientSecret: undefined }], + ])('never requests a token with %s', async (_label, override) => { + await expect(mintZendeskServiceAccountToken({ ...CLIENT, ...override })).rejects.toMatchObject({ + code: 'invalid_credentials', + }) + expect(mockFetch).not.toHaveBeenCalled() + }) + + it('never signs in with an API token missing its agent email', async () => { + await expect( + mintZendeskServiceAccountToken({ ...API_TOKEN, username: ' ' }) + ).rejects.toMatchObject({ code: 'invalid_credentials' }) + expect(mockFetch).not.toHaveBeenCalled() + }) + + it.each([ + [401, 'invalid_credentials'], + [400, 'invalid_credentials'], + [404, 'site_not_found'], + [429, 'provider_unavailable'], + [503, 'provider_unavailable'], + ])('maps an HTTP %i token response to %s', async (status, code) => { + mockFetch.mockResolvedValueOnce(jsonResponse({ error: 'invalid_client' }, { status })) + + await expect(mintZendeskServiceAccountToken(CLIENT)).rejects.toMatchObject({ code }) + }) + + it('refuses a token response without an access token', async () => { + mockFetch.mockResolvedValueOnce(jsonResponse({ token_type: 'bearer' })) + + await expect(mintZendeskServiceAccountToken(CLIENT)).rejects.toMatchObject({ + code: 'provider_unavailable', + }) + }) +}) diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/zendesk.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/zendesk.ts new file mode 100644 index 00000000000..c6e63f35132 --- /dev/null +++ b/apps/sim/lib/credentials/client-credential-accounts/minters/zendesk.ts @@ -0,0 +1,214 @@ +import { isRecordLike } from '@sim/utils/object' +import { readResponseJsonWithLimit } from '@/lib/core/utils/stream-limits' +import { + normalizeZendeskSubdomain, + resolveZendeskAuthMethod, + ZENDESK_AUTH_METHODS, + ZENDESK_SCOPES, + ZENDESK_SUBDOMAIN_REGEX, +} from '@/lib/credentials/client-credential-accounts/descriptors' +import type { + ClientCredentialAccountFields, + ClientCredentialAccountMintOptions, + ClientCredentialAccountMintResult, +} from '@/lib/credentials/client-credential-accounts/server' +import { userPrincipal } from '@/lib/credentials/principal' +import { + fetchProvider, + providerFailureCode, + readProviderErrorSnippet, + TokenServiceAccountValidationError, +} from '@/lib/credentials/token-service-accounts/errors' +import { zendeskApiTokenCredential } from '@/tools/zendesk/types' + +const MINT_STEP = 'zendesk_token_mint' +const IDENTITY_STEP = 'zendesk_users_me' +const MAX_RESPONSE_BYTES = 64 * 1024 +/** Lifetime Sim requests; Zendesk accepts 300 seconds to 2 days. */ +const REQUESTED_TOKEN_LIFETIME_SECONDS = 3600 +/** An API-token sign-in mints nothing, so its derived credential is simply re-derived hourly. */ +const API_TOKEN_LIFETIME_SECONDS = 3600 +const STAFF_ROLES: ReadonlySet = new Set(['agent', 'admin']) + +function refuse( + code: 'invalid_credentials' | 'site_not_found' | 'provider_unavailable', + status: number, + step: string, + reason: string +): TokenServiceAccountValidationError { + return new TokenServiceAccountValidationError(code, status, { step, reason }) +} + +async function readJson( + response: Response, + label: string, + step: string, + signal?: AbortSignal +): Promise> { + try { + const payload = await readResponseJsonWithLimit(response, { + maxBytes: MAX_RESPONSE_BYTES, + label, + signal, + }) + if (isRecordLike(payload)) return payload + } catch { + signal?.throwIfAborted() + } + throw refuse('provider_unavailable', 502, step, 'provider returned an invalid response') +} + +async function throwForZendeskResponse(response: Response, step: string): Promise { + if (response.ok) return + const body = await readProviderErrorSnippet(response) + throw new TokenServiceAccountValidationError( + response.status === 404 ? 'site_not_found' : providerFailureCode(response.status), + response.status, + { step, body } + ) +} + +/** + * Exchanges a confidential OAuth client's credentials for a bearer token. Zendesk documents the + * grant only for confidential clients, as JSON, with a required scope and an optional + * `expires_in` it caps at two days; the token acts as the user the client belongs to. + */ +async function exchangeClientCredentials( + base: string, + fields: ClientCredentialAccountFields, + signal?: AbortSignal +): Promise<{ accessToken: string; expiresInSeconds: number }> { + const scope = fields.scope?.trim() ?? '' + if (!fields.clientId || !fields.clientSecret || !ZENDESK_SCOPES.has(scope)) { + throw refuse('invalid_credentials', 400, MINT_STEP, 'client, secret, or scope is missing') + } + const response = await fetchProvider( + `${base}/oauth/tokens`, + { + method: 'POST', + headers: { Accept: 'application/json', 'Content-Type': 'application/json' }, + body: JSON.stringify({ + grant_type: 'client_credentials', + client_id: fields.clientId, + client_secret: fields.clientSecret, + scope, + expires_in: REQUESTED_TOKEN_LIFETIME_SECONDS, + }), + redirect: 'error', + cache: 'no-store', + signal, + }, + MINT_STEP, + { dnsFailureCode: 'site_not_found', dnsFailureReason: 'subdomain does not resolve' } + ) + await throwForZendeskResponse(response, MINT_STEP) + const token = await readJson(response, 'Zendesk token response', MINT_STEP, signal) + if (typeof token.access_token !== 'string' || !token.access_token.trim()) { + throw refuse('provider_unavailable', 502, MINT_STEP, 'token response has no access token') + } + // Older clients may answer without a lifetime; never trust it beyond what Sim requested. + const reported = + typeof token.expires_in === 'number' && + Number.isSafeInteger(token.expires_in) && + token.expires_in > 0 + ? token.expires_in + : REQUESTED_TOKEN_LIFETIME_SECONDS + return { + accessToken: token.access_token, + expiresInSeconds: Math.min(reported, REQUESTED_TOKEN_LIFETIME_SECONDS), + } +} + +/** + * Proves the credential signs in as a real staff member. Zendesk allows anonymous callers on + * `users/me` but does not document that response's body, so a positive integer id is required + * rather than trusting any user object; an end user cannot use the ticketing API the tools call. + */ +async function verifyStaffUser( + base: string, + authorization: string, + subdomain: string, + signal?: AbortSignal +): Promise> { + const response = await fetchProvider( + `${base}/api/v2/users/me.json`, + { + method: 'GET', + headers: { Accept: 'application/json', Authorization: authorization }, + redirect: 'error', + cache: 'no-store', + signal, + }, + IDENTITY_STEP + ) + await throwForZendeskResponse(response, IDENTITY_STEP) + const payload = await readJson(response, 'Zendesk user response', IDENTITY_STEP, signal) + const user = isRecordLike(payload.user) ? payload.user : undefined + const id = user?.id + if (typeof id !== 'number' || !Number.isSafeInteger(id) || id <= 0) { + throw refuse('invalid_credentials', 401, IDENTITY_STEP, 'credential signs in anonymously') + } + if (typeof user?.role !== 'string' || !STAFF_ROLES.has(user.role)) { + throw refuse('invalid_credentials', 403, IDENTITY_STEP, 'user is not an agent or admin') + } + const email = typeof user.email === 'string' && user.email ? user.email : undefined + const name = typeof user.name === 'string' && user.name ? user.name : undefined + return { + displayName: `Zendesk ${subdomain}${email ? ` (${email})` : ''}`, + principal: userPrincipal(String(id), email ?? name), + auditMetadata: { zendeskSubdomain: subdomain, zendeskRole: user.role }, + storedMetadata: { zendeskRole: user.role }, + } +} + +/** + * Resolves a Zendesk credential to the authorization the Zendesk tools send. The OAuth client + * branch mints a bearer token through the client-credentials grant; the API-token branch derives + * the documented `{email}/token:{api_token}` Basic credential without a network call. Both act + * only on `{subdomain}.zendesk.com`, and the connect-time check requires a real agent or admin. + * + * @see https://developer.zendesk.com/api-reference/ticketing/oauth/grant_type_tokens/ + * @see https://developer.zendesk.com/api-reference/introduction/security-and-auth/ + */ +export async function mintZendeskServiceAccountToken( + fields: ClientCredentialAccountFields, + options?: ClientCredentialAccountMintOptions +): Promise { + options?.signal?.throwIfAborted() + const subdomain = normalizeZendeskSubdomain(fields.orgId) + if (!ZENDESK_SUBDOMAIN_REGEX.test(subdomain)) { + throw refuse('site_not_found', 400, 'host_validation', 'not a Zendesk subdomain') + } + const base = `https://${subdomain}.zendesk.com` + + let minted: Pick< + ClientCredentialAccountMintResult, + 'accessToken' | 'expiresInSeconds' | 'authStyle' + > + let authorization: string + if (resolveZendeskAuthMethod(fields.authMethod) === ZENDESK_AUTH_METHODS.apiToken) { + const email = fields.username?.trim() + if (!email || !fields.apiToken) { + throw refuse('invalid_credentials', 400, MINT_STEP, 'agent email or API token is missing') + } + const basic = zendeskApiTokenCredential(email, fields.apiToken) + minted = { + accessToken: basic, + expiresInSeconds: API_TOKEN_LIFETIME_SECONDS, + authStyle: 'basic', + } + authorization = `Basic ${basic}` + } else { + const exchanged = await exchangeClientCredentials(base, fields, options?.signal) + minted = exchanged + authorization = `Bearer ${exchanged.accessToken}` + } + + return { + ...minted, + domain: subdomain, + ...(options?.skipIdentity + ? {} + : { identity: await verifyStaffUser(base, authorization, subdomain, options?.signal) }), + } +} diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/zoominfo.test.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/zoominfo.test.ts new file mode 100644 index 00000000000..446f1ec8a7e --- /dev/null +++ b/apps/sim/lib/credentials/client-credential-accounts/minters/zoominfo.test.ts @@ -0,0 +1,82 @@ +import { + inputValidationMock, + inputValidationMockFns, +} from '@sim/testing/mocks/input-validation.mock' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock) + +import { mintZoomInfoServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/zoominfo' + +const { mockSecureFetchWithValidation } = inputValidationMockFns +const FIELDS = { clientId: 'zi-client', clientSecret: 'zi-secret', orgId: '' } + +describe('ZoomInfo client credentials exchange', () => { + beforeEach(() => { + mockSecureFetchWithValidation.mockReset() + }) + + it('mints at the documented token endpoint with Basic auth, refusing redirects, and calls nothing else', async () => { + mockSecureFetchWithValidation.mockResolvedValue( + Response.json({ access_token: 'zi-access', expires_in: 1000, scope: 'api:data:company' }) + ) + + const result = await mintZoomInfoServiceAccountToken(FIELDS) + + expect(mockSecureFetchWithValidation).toHaveBeenCalledTimes(1) + const [url, options] = mockSecureFetchWithValidation.mock.calls[0] + expect(url).toBe('https://api.zoominfo.com/gtm/oauth/v1/token') + expect(options).toMatchObject({ method: 'POST', maxRedirects: 0 }) + expect(options.headers.Authorization).toBe( + `Basic ${Buffer.from('zi-client:zi-secret').toString('base64')}` + ) + expect(Object.fromEntries(new URLSearchParams(options.body))).toEqual({ + grant_type: 'client_credentials', + }) + expect(result).toMatchObject({ accessToken: 'zi-access', expiresInSeconds: 1000 }) + expect(result.identity?.storedMetadata).toMatchObject({ grantedScopes: 'api:data:company' }) + expect(JSON.stringify(result.identity)).not.toContain('zi-secret') + expect(JSON.stringify(result.identity)).not.toContain('zi-access') + }) + + it.each([ + ['clientId', 'client:with-colon'], + ['clientId', 'client\r\nX-Injected: 1'], + ['clientSecret', ''], + ] as const)('refuses an unusable %s before any request', async (field, value) => { + await expect( + mintZoomInfoServiceAccountToken({ ...FIELDS, [field]: value }) + ).rejects.toMatchObject({ code: 'invalid_credentials' }) + expect(mockSecureFetchWithValidation).not.toHaveBeenCalled() + }) + + it.each([ + [400, 'invalid_credentials'], + [401, 'invalid_credentials'], + [403, 'invalid_credentials'], + [429, 'provider_unavailable'], + [503, 'provider_unavailable'], + ])('maps an HTTP %i token response to %s', async (status, code) => { + mockSecureFetchWithValidation.mockResolvedValue( + Response.json({ error: 'invalid_client' }, { status }) + ) + + await expect(mintZoomInfoServiceAccountToken(FIELDS)).rejects.toMatchObject({ code }) + }) + + it('treats a refused redirect or network failure as a provider failure', async () => { + mockSecureFetchWithValidation.mockRejectedValue(new Error('Too many redirects (max: 0)')) + + await expect(mintZoomInfoServiceAccountToken(FIELDS)).rejects.toMatchObject({ + code: 'provider_unavailable', + }) + }) + + it('refuses a token response without an access token', async () => { + mockSecureFetchWithValidation.mockResolvedValue(Response.json({ expires_in: 1000 })) + + await expect(mintZoomInfoServiceAccountToken(FIELDS)).rejects.toMatchObject({ + code: 'provider_unavailable', + }) + }) +}) diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/zoominfo.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/zoominfo.ts new file mode 100644 index 00000000000..c7bda0f1c68 --- /dev/null +++ b/apps/sim/lib/credentials/client-credential-accounts/minters/zoominfo.ts @@ -0,0 +1,82 @@ +import type { + ClientCredentialAccountFields, + ClientCredentialAccountMintOptions, + ClientCredentialAccountMintResult, +} from '@/lib/credentials/client-credential-accounts/server' +import { + assertCredentialText, + providerFailureCode, + providerFailureReason, + requireClientSecret, + TokenServiceAccountValidationError, +} from '@/lib/credentials/token-service-accounts/errors' +import { requestZoomInfoToken, ZoomInfoTokenError } from '@/lib/internal/zoominfo/client' + +const STEP = 'zoominfo_token_mint' +const MAX_CREDENTIAL_BYTES = 1_024 + +/** + * Mints a ZoomInfo API access token for a client-credentials app through the + * same exchange the legacy block uses: `POST + * https://api.zoominfo.com/gtm/oauth/v1/token` with `grant_type=client_credentials` + * and the client id and secret in HTTP Basic auth, redirects refused. The token + * acts as the app's integration user with the app's configured scopes; its + * lifetime comes from `expires_in`. + * + * The connect-time check is the mint alone, so connecting calls no data + * endpoint that could consume credits. + * + * @see https://docs.zoominfo.com/docs/client-credentials-flow + */ +export async function mintZoomInfoServiceAccountToken( + fields: ClientCredentialAccountFields, + options?: ClientCredentialAccountMintOptions +): Promise { + options?.signal?.throwIfAborted() + const clientSecret = requireClientSecret(fields.clientSecret, STEP, 'ZoomInfo') + assertCredentialText(fields.clientId, 'client ID', STEP, { maxBytes: MAX_CREDENTIAL_BYTES }) + // A colon would end the Basic auth user and shift the rest into the password. + if (fields.clientId.includes(':')) { + throw new TokenServiceAccountValidationError('invalid_credentials', 400, { + step: STEP, + reason: 'client ID is invalid', + }) + } + assertCredentialText(clientSecret, 'client secret', STEP, { maxBytes: MAX_CREDENTIAL_BYTES }) + + let minted: Awaited> + try { + minted = await requestZoomInfoToken( + { clientId: fields.clientId, clientSecret }, + { signal: options?.signal } + ) + } catch (error) { + options?.signal?.throwIfAborted() + if (error instanceof ZoomInfoTokenError) { + throw new TokenServiceAccountValidationError( + providerFailureCode(error.tokenStatus), + error.tokenStatus, + { step: STEP } + ) + } + throw new TokenServiceAccountValidationError('provider_unavailable', 502, { + step: STEP, + reason: providerFailureReason(error), + }) + } + + const grantedScopes = minted.scope?.split(/\s+/).filter(Boolean) + return { + accessToken: minted.accessToken, + expiresInSeconds: minted.expiresInSeconds, + grantedScopes, + identity: { + displayName: 'ZoomInfo API credential', + principal: null, + auditMetadata: { zoominfoClientId: fields.clientId }, + ...(grantedScopes?.length + ? { storedMetadata: { grantedScopes: grantedScopes.join(' ') } } + : {}), + }, + } +} diff --git a/apps/sim/lib/credentials/client-credential-accounts/server.ts b/apps/sim/lib/credentials/client-credential-accounts/server.ts index ae13062cac4..c1f7d6a42bc 100644 --- a/apps/sim/lib/credentials/client-credential-accounts/server.ts +++ b/apps/sim/lib/credentials/client-credential-accounts/server.ts @@ -2,6 +2,8 @@ import { BOX_SERVICE_ACCOUNT_PROVIDER_ID, CLIENT_CREDENTIAL_ACCOUNT_SECRET_TYPE, type ClientCredentialAccountProviderId, + CROWDSTRIKE_SERVICE_ACCOUNT_PROVIDER_ID, + DATADOG_SERVICE_ACCOUNT_PROVIDER_ID, DOCUSIGN_SERVICE_ACCOUNT_PROVIDER_ID, FIVETRAN_SERVICE_ACCOUNT_PROVIDER_ID, GREENHOUSE_SERVICE_ACCOUNT_PROVIDER_ID, @@ -14,12 +16,17 @@ import { ORACLE_FUSION_SERVICE_ACCOUNT_PROVIDER_ID, partitionClientCredentialFields, RAMP_SERVICE_ACCOUNT_PROVIDER_ID, + SAILPOINT_SERVICE_ACCOUNT_PROVIDER_ID, SALESFORCE_SERVICE_ACCOUNT_PROVIDER_ID, VANTA_SERVICE_ACCOUNT_PROVIDER_ID, + ZENDESK_SERVICE_ACCOUNT_PROVIDER_ID, ZOHO_DESK_SERVICE_ACCOUNT_PROVIDER_ID, ZOOM_SERVICE_ACCOUNT_PROVIDER_ID, + ZOOMINFO_SERVICE_ACCOUNT_PROVIDER_ID, } from '@/lib/credentials/client-credential-accounts/descriptors' import { mintBoxServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/box' +import { mintCrowdStrikeServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/crowdstrike' +import { mintDatadogServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/datadog' import { mintDocuSignServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/docusign' import { mintFivetranServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/fivetran' import { mintGreenhouseServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/greenhouse' @@ -29,10 +36,13 @@ import { mintNetSuiteServiceAccountToken } from '@/lib/credentials/client-creden import { mintOracleEpmServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/oracle-epm' import { mintOracleFusionServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/oracle-fusion' import { mintRampServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/ramp' +import { mintSailPointServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/sailpoint' import { mintSalesforceServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/salesforce' import { mintVantaServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/vanta' +import { mintZendeskServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/zendesk' import { mintZohoDeskServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/zoho-desk' import { mintZoomServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/zoom' +import { mintZoomInfoServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/zoominfo' import type { ServiceAccountPrincipal } from '@/lib/credentials/principal' /** Credential fields normalized according to the provider descriptor; passwords retain exact bytes. */ @@ -48,7 +58,8 @@ export interface ClientCredentialAccountFields { /** * Provider-specific org identifier (Zoom Account ID, Box Enterprise ID, * Salesforce My Domain host, Zoho Desk organization ID, NetSuite SuiteTalk - * origin, or an Oracle EPM environment URL). + * origin, an Oracle EPM environment URL, a CrowdStrike member CID, or a + * SailPoint tenant). */ orgId: string /** @@ -71,8 +82,13 @@ export interface ClientCredentialAccountFields { * {@link clientSecret} in practice. */ privateKey?: string - /** User a key-based grant authenticates as (Salesforce JWT username, DocuSign user ID). */ + /** + * User a key-based grant authenticates as (Salesforce JWT username, DocuSign + * user ID), or the agent email a Zendesk API token signs in with. + */ username?: string + /** Long-lived API token for a provider's token sign-in branch (Zendesk API token). */ + apiToken?: string } /** Identity derived from a successful mint, used at connect time. */ @@ -114,8 +130,25 @@ export interface ClientCredentialAccountMintResult { * {@link instanceUrl} because the two reach different tool params. */ apiDomain?: string + /** + * The region, site, tenant, or account host the token is bound to (a + * CrowdStrike cloud, Datadog site, SailPoint tenant API host, or Zendesk + * subdomain), forwarded to tools as the credential's `domain`, which fills the + * tool param that picks the provider host. + */ + domain?: string + /** + * Datadog only — the application key every Datadog read sends beside the API + * key (`accessToken`). A secret, handled like the token. + */ + applicationKey?: string /** Scopes granted to the app, when the provider reports them. */ grantedScopes?: string[] + /** + * `basic` when `accessToken` is an HTTP Basic credential rather than a bearer + * token (a Zendesk API token sign-in). Absent means Bearer. + */ + authStyle?: 'basic' identity?: ClientCredentialAccountIdentity } @@ -161,6 +194,11 @@ const CLIENT_CREDENTIAL_ACCOUNT_MINTERS: Record< [GREENHOUSE_SERVICE_ACCOUNT_PROVIDER_ID]: mintGreenhouseServiceAccountToken, [HELPSCOUT_SERVICE_ACCOUNT_PROVIDER_ID]: mintHelpScoutServiceAccountToken, [DOCUSIGN_SERVICE_ACCOUNT_PROVIDER_ID]: mintDocuSignServiceAccountToken, + [CROWDSTRIKE_SERVICE_ACCOUNT_PROVIDER_ID]: mintCrowdStrikeServiceAccountToken, + [DATADOG_SERVICE_ACCOUNT_PROVIDER_ID]: mintDatadogServiceAccountToken, + [SAILPOINT_SERVICE_ACCOUNT_PROVIDER_ID]: mintSailPointServiceAccountToken, + [ZOOMINFO_SERVICE_ACCOUNT_PROVIDER_ID]: mintZoomInfoServiceAccountToken, + [ZENDESK_SERVICE_ACCOUNT_PROVIDER_ID]: mintZendeskServiceAccountToken, } export function getClientCredentialAccountMinter( @@ -192,6 +230,8 @@ export interface ClientCredentialAccountSecretBlob { authMethod?: string privateKey?: string username?: string + /** Present only on a Zendesk API-token credential. */ + apiToken?: string metadata?: Record } diff --git a/apps/sim/lib/credentials/client-credential-accounts/vanta-token.ts b/apps/sim/lib/credentials/client-credential-accounts/vanta-token.ts index d3c5687e51b..86bd767c970 100644 --- a/apps/sim/lib/credentials/client-credential-accounts/vanta-token.ts +++ b/apps/sim/lib/credentials/client-credential-accounts/vanta-token.ts @@ -6,6 +6,7 @@ import { eq, inArray, sql } from 'drizzle-orm' import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption' import { readResponseJsonWithLimit } from '@/lib/core/utils/stream-limits' import { VANTA_PERMISSION_OPTIONS } from '@/lib/credentials/client-credential-accounts/descriptors' +import { deleteExpiredClientTokenFailures } from '@/lib/credentials/coordinated-client-token' import { fetchProvider, isTransientProviderStatus, @@ -197,14 +198,7 @@ export async function resolveVantaClientToken( const secretFingerprint = digest([apiDomain, fields.clientId, clientSecret]) const failureKey = `${applicationKey}:${digest([secretFingerprint, scope])}` try { - // Release cleanup row locks before taking an application lock or calling the provider. - await db.execute(sql` - DELETE FROM ${clientCredentialToken} WHERE ${clientCredentialToken.id} IN ( - SELECT ${clientCredentialToken.id} FROM ${clientCredentialToken} - WHERE ${clientCredentialToken.accessTokenDigest} IS NULL - AND ${clientCredentialToken.expiresAt} < now() - ORDER BY ${clientCredentialToken.expiresAt} LIMIT 100 FOR UPDATE SKIP LOCKED - )`) + await deleteExpiredClientTokenFailures() const outcome = await db.transaction( async (tx) => { await tx.execute(sql`SET LOCAL lock_timeout = '15s'`) diff --git a/apps/sim/lib/credentials/client-credentials-grant.test.ts b/apps/sim/lib/credentials/client-credentials-grant.test.ts new file mode 100644 index 00000000000..9cbaf6432c1 --- /dev/null +++ b/apps/sim/lib/credentials/client-credentials-grant.test.ts @@ -0,0 +1,168 @@ +import { jsonResponse } from '@sim/testing/helpers/http' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { exchangeClientCredentialsGrant } from '@/lib/credentials/client-credentials-grant' +import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors' + +const mockFetch = vi.fn() + +const REQUEST = { + url: 'https://auth.example.com/oauth/token', + clientId: 'client-1', + clientSecret: 'secret-1', + step: 'example_token_mint', + label: 'Example token response', + maxLifetimeSeconds: 3600, +} + +describe('exchangeClientCredentialsGrant', () => { + beforeEach(() => { + mockFetch.mockReset() + vi.stubGlobal('fetch', mockFetch) + }) + + it('reports a caller abort during a refusal as the abort, not a provider failure', async () => { + const controller = new AbortController() + mockFetch.mockImplementation(async () => { + const body = new ReadableStream({ + pull() { + controller.abort() + throw new Error('body read aborted') + }, + }) + return new Response(body, { status: 401 }) + }) + + const exchange = exchangeClientCredentialsGrant({ ...REQUEST, signal: controller.signal }) + + await expect(exchange).rejects.toMatchObject({ name: 'AbortError' }) + await expect(exchange).rejects.not.toBeInstanceOf(TokenServiceAccountValidationError) + }) + + it.each([ + [ + 'a dropped connection while reading the token', + () => + new Response( + new ReadableStream({ + pull() { + throw new Error('other side closed') + }, + }), + { status: 200 } + ), + 'reading the token response failed: other side closed', + ], + [ + 'an unparsable token response', + () => new Response('{not json', { status: 200 }), + 'provider returned an invalid token response', + ], + ])('reports %s as a provider fault with its own reason', async (_case, reply, reason) => { + mockFetch.mockImplementation(async () => reply()) + + await expect(exchangeClientCredentialsGrant(REQUEST)).rejects.toMatchObject({ + code: 'provider_unavailable', + status: 502, + logDetail: { reason }, + }) + }) + + it('posts the client credentials and extra fields as a form, refusing redirects', async () => { + mockFetch.mockImplementation(async (input, init) => { + const request = new Request(input, init) + expect(request.url).toBe(REQUEST.url) + expect(request.method).toBe('POST') + expect(request.redirect).toBe('error') + expect(request.headers.get('content-type')).toBe('application/x-www-form-urlencoded') + expect([...new URLSearchParams(await request.text())]).toEqual([ + ['grant_type', 'client_credentials'], + ['client_id', 'client-1'], + ['client_secret', 'secret-1'], + ['scope', 'read'], + ]) + return jsonResponse({ access_token: 'token-1', expires_in: 1800 }) + }) + + const startedAt = Date.now() + const exchanged = await exchangeClientCredentialsGrant({ + ...REQUEST, + extraFields: { scope: 'read' }, + }) + + expect(exchanged.accessToken).toBe('token-1') + expect(exchanged.expiresAt.getTime() - startedAt).toBeGreaterThanOrEqual(1800 * 1000 - 50) + expect(exchanged.expiresAt.getTime() - startedAt).toBeLessThanOrEqual(1800 * 1000 + 50) + }) + + it.each(['grant_type', 'client_id', 'client_secret'])( + 'refuses an extra field that would replace %s, before any request', + async (field) => { + await expect( + exchangeClientCredentialsGrant({ ...REQUEST, extraFields: { [field]: 'replaced' } }) + ).rejects.toThrow(field) + expect(mockFetch).not.toHaveBeenCalled() + } + ) + + it('never keeps a token longer than the lifetime cap', async () => { + mockFetch.mockResolvedValue(jsonResponse({ access_token: 'token-1', expires_in: 9_999_999 })) + + const startedAt = Date.now() + const exchanged = await exchangeClientCredentialsGrant(REQUEST) + + expect(exchanged.expiresAt.getTime() - startedAt).toBeLessThanOrEqual(3600 * 1000 + 50) + }) + + it.each([ + [400, 'invalid_credentials'], + [401, 'invalid_credentials'], + [403, 'invalid_credentials'], + [408, 'provider_unavailable'], + [429, 'provider_unavailable'], + [500, 'provider_unavailable'], + [503, 'provider_unavailable'], + ])('maps an HTTP %i token response to %s', async (status, code) => { + mockFetch.mockResolvedValue(jsonResponse({ error: 'invalid_client' }, { status })) + + await expect(exchangeClientCredentialsGrant(REQUEST)).rejects.toMatchObject({ code, status }) + }) + + it("throws the provider's own classification of a refusal", async () => { + mockFetch.mockResolvedValue(jsonResponse({ error: 'org_mismatch' }, { status: 400 })) + const classifyRefusal = vi.fn( + (status: number, body: string) => + new TokenServiceAccountValidationError('grant_not_permitted', status, { + step: REQUEST.step, + reason: body.includes('org_mismatch') ? 'org mismatch' : 'other', + }) + ) + + await expect( + exchangeClientCredentialsGrant({ ...REQUEST, classifyRefusal }) + ).rejects.toMatchObject({ code: 'grant_not_permitted', status: 400 }) + expect(classifyRefusal).toHaveBeenCalledWith(400, expect.stringContaining('org_mismatch')) + }) + + it.each([ + ['no access token', { expires_in: 3600 }], + ['a blank access token', { access_token: ' ', expires_in: 3600 }], + ['no lifetime', { access_token: 'token-1' }], + ['a fractional lifetime', { access_token: 'token-1', expires_in: 1.5 }], + ['a non-positive lifetime', { access_token: 'token-1', expires_in: 0 }], + ])('refuses a token response with %s', async (_label, body) => { + mockFetch.mockResolvedValue(jsonResponse(body)) + + await expect(exchangeClientCredentialsGrant(REQUEST)).rejects.toMatchObject({ + code: 'provider_unavailable', + status: 502, + }) + }) + + it('refuses a token response that is not JSON', async () => { + mockFetch.mockResolvedValue(new Response('gateway', { status: 200 })) + + await expect(exchangeClientCredentialsGrant(REQUEST)).rejects.toMatchObject({ + code: 'provider_unavailable', + }) + }) +}) diff --git a/apps/sim/lib/credentials/client-credentials-grant.ts b/apps/sim/lib/credentials/client-credentials-grant.ts new file mode 100644 index 00000000000..2c6ab9df827 --- /dev/null +++ b/apps/sim/lib/credentials/client-credentials-grant.ts @@ -0,0 +1,126 @@ +import { isRecordLike } from '@sim/utils/object' +import { PayloadSizeLimitError, readResponseJsonWithLimit } from '@/lib/core/utils/stream-limits' +import { + type FetchProviderOptions, + fetchProvider, + providerFailureCode, + providerFailureReason, + readProviderErrorSnippet, + TokenServiceAccountValidationError, +} from '@/lib/credentials/token-service-accounts/errors' + +const MAX_TOKEN_RESPONSE_BYTES = 64 * 1024 +/** Form fields the grant itself sets, which an extra field may never replace. */ +const RESERVED_GRANT_FIELDS = ['grant_type', 'client_id', 'client_secret'] as const + +/** One OAuth 2.0 client-credentials token request to a fixed or validated token endpoint. */ +export interface ClientCredentialsGrantRequest { + /** The token endpoint, from a constant or a host the caller already validated. */ + url: string + clientId: string + clientSecret: string + /** + * Form fields sent after the client credentials, such as a fixed `scope`. A field named + * `grant_type`, `client_id`, or `client_secret` is refused, so the grant always sends its own. + */ + extraFields?: Record + /** The validation step named in errors and logs. */ + step: string + /** Names the provider's token response in a body-limit error. */ + label: string + /** The longest a token is kept, whatever `expires_in` the provider reports. */ + maxLifetimeSeconds: number + fetchOptions?: FetchProviderOptions + /** + * Maps a refused exchange to a provider-specific error. Without it, a refusal is + * `invalid_credentials` for a non-transient 4xx and `provider_unavailable` otherwise. + */ + classifyRefusal?: (status: number, body: string) => TokenServiceAccountValidationError + signal?: AbortSignal +} + +/** + * Exchanges client credentials for an access token: a form POST of `grant_type=client_credentials` + * with the client ID and secret in the body, redirects refused so the secret reaches no other host. + * A response without a non-blank `access_token` and a positive integer `expires_in` is + * `provider_unavailable`, and the token expires `min(expires_in, maxLifetimeSeconds)` after the + * request started. + * + * @see https://datatracker.ietf.org/doc/html/rfc6749#section-4.4 + */ +export async function exchangeClientCredentialsGrant( + request: ClientCredentialsGrantRequest +): Promise<{ accessToken: string; expiresAt: Date }> { + const { step, signal } = request + const extraFields = request.extraFields ?? {} + const replaced = RESERVED_GRANT_FIELDS.find((field) => Object.hasOwn(extraFields, field)) + if (replaced) throw new Error(`An extra field cannot replace the grant's ${replaced}`) + const startedAt = Date.now() + const response = await fetchProvider( + request.url, + { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + grant_type: 'client_credentials', + client_id: request.clientId, + client_secret: request.clientSecret, + ...extraFields, + }).toString(), + redirect: 'error', + cache: 'no-store', + signal, + }, + step, + request.fetchOptions + ) + if (!response.ok) { + const body = await readProviderErrorSnippet(response) + signal?.throwIfAborted() + throw request.classifyRefusal + ? request.classifyRefusal(response.status, body) + : new TokenServiceAccountValidationError( + providerFailureCode(response.status), + response.status, + { step, body } + ) + } + let payload: unknown + try { + payload = await readResponseJsonWithLimit(response, { + maxBytes: MAX_TOKEN_RESPONSE_BYTES, + label: request.label, + signal, + }) + } catch (error) { + signal?.throwIfAborted() + if (!(error instanceof SyntaxError || error instanceof PayloadSizeLimitError)) { + throw new TokenServiceAccountValidationError('provider_unavailable', 502, { + step, + reason: `reading the token response failed: ${providerFailureReason(error)}`, + }) + } + payload = undefined + } + if ( + !isRecordLike(payload) || + typeof payload.access_token !== 'string' || + !payload.access_token.trim() || + typeof payload.expires_in !== 'number' || + !Number.isSafeInteger(payload.expires_in) || + payload.expires_in <= 0 + ) { + throw new TokenServiceAccountValidationError('provider_unavailable', 502, { + step, + reason: 'provider returned an invalid token response', + }) + } + const lifetimeSeconds = Math.min(payload.expires_in, request.maxLifetimeSeconds) + return { + accessToken: payload.access_token, + expiresAt: new Date(startedAt + lifetimeSeconds * 1000), + } +} diff --git a/apps/sim/lib/credentials/coordinated-client-token.integration.ts b/apps/sim/lib/credentials/coordinated-client-token.integration.ts new file mode 100644 index 00000000000..845cb65a8c1 --- /dev/null +++ b/apps/sim/lib/credentials/coordinated-client-token.integration.ts @@ -0,0 +1,249 @@ +import { createHash } from 'node:crypto' +import { db } from '@sim/db' +import { clientCredentialToken } from '@sim/db/schema' +import { sleep } from '@sim/utils/helpers' +import { generateId } from '@sim/utils/id' +import { eq, like, or } from 'drizzle-orm' +import { afterAll, afterEach, describe, expect, it, vi } from 'vitest' +import { + type CoordinatedClientTokenInput, + invalidateCoordinatedClientToken, + resolveCoordinatedClientToken, +} from '@/lib/credentials/coordinated-client-token' +import { verifyAndBuildServiceAccountSecret } from '@/lib/credentials/service-account-secret' +import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors' + +const NAMESPACE = `fixture${generateId().replaceAll('-', '')}` +const ownedKeyPrefixes = new Set([`${NAMESPACE}:`]) + +function applicationKey(namespace: string, application: readonly string[]): string { + return `${namespace}:${createHash('sha256').update(JSON.stringify(application)).digest('hex')}` +} + +/** A provider that issues numbered tokens per application and counts every exchange. */ +function provider(application: string) { + let issued = 0 + const exchange = vi.fn(async () => { + await sleep(10) + issued++ + return { + accessToken: `${application}:token-${issued}`, + expiresAt: new Date(Date.now() + 3_600_000), + } + }) + return { exchange } +} + +function input( + application: string, + exchange: CoordinatedClientTokenInput['exchange'], + clientSecret = 'secret-1' +): CoordinatedClientTokenInput { + return { namespace: NAMESPACE, application: [application], clientSecret, step: 'mint', exchange } +} + +function rejecting() { + return vi.fn(async () => { + throw new TokenServiceAccountValidationError('invalid_credentials', 401, { step: 'mint' }) + }) +} + +afterEach(() => { + vi.unstubAllGlobals() +}) + +afterAll(async () => { + await db + .delete(clientCredentialToken) + .where( + or(...[...ownedKeyPrefixes].map((prefix) => like(clientCredentialToken.id, `${prefix}%`))) + ) +}) + +describe('coordinated client-credentials token against shared storage', () => { + it('exchanges once for simultaneous resolutions of one application', async () => { + const { exchange } = provider('concurrent') + + const results = await Promise.all( + Array.from({ length: 10 }, () => resolveCoordinatedClientToken(input('concurrent', exchange))) + ) + + expect(exchange).toHaveBeenCalledOnce() + expect(new Set(results.map((result) => result.accessToken))).toEqual( + new Set(['concurrent:token-1']) + ) + }) + + it('mints again for a rotated secret instead of serving the old secret its token', async () => { + const { exchange } = provider('rotated') + await resolveCoordinatedClientToken(input('rotated', exchange)) + + const rotated = await resolveCoordinatedClientToken(input('rotated', exchange, 'secret-2')) + + expect(rotated.accessToken).toBe('rotated:token-2') + expect(exchange).toHaveBeenCalledTimes(2) + }) + + it('keeps serving the good token after a wrong secret is rejected', async () => { + const { exchange } = provider('wrong-secret') + const good = await resolveCoordinatedClientToken(input('wrong-secret', exchange)) + + await expect( + resolveCoordinatedClientToken(input('wrong-secret', rejecting(), 'wrong')) + ).rejects.toMatchObject({ code: 'invalid_credentials' }) + + await expect(resolveCoordinatedClientToken(input('wrong-secret', exchange))).resolves.toEqual( + expect.objectContaining({ accessToken: good.accessToken }) + ) + expect(exchange).toHaveBeenCalledOnce() + }) + + it('replays a rejected exchange until the failure memo expires', async () => { + const exchange = rejecting() + await expect( + resolveCoordinatedClientToken(input('memo', exchange, 'revoked')) + ).rejects.toMatchObject({ code: 'invalid_credentials' }) + await expect( + resolveCoordinatedClientToken(input('memo', exchange, 'revoked')) + ).rejects.toMatchObject({ code: 'invalid_credentials' }) + expect(exchange).toHaveBeenCalledOnce() + + await db + .update(clientCredentialToken) + .set({ expiresAt: new Date(Date.now() - 1000) }) + .where(like(clientCredentialToken.id, `${applicationKey(NAMESPACE, ['memo'])}:%`)) + + await expect( + resolveCoordinatedClientToken(input('memo', exchange, 'revoked')) + ).rejects.toMatchObject({ code: 'invalid_credentials' }) + expect(exchange).toHaveBeenCalledTimes(2) + }) + + it('reports an exchange that fails outside validation as provider_unavailable and stores nothing', async () => { + const exchange = vi.fn(async () => { + throw new Error('socket hang up') + }) + + await expect(resolveCoordinatedClientToken(input('network', exchange))).rejects.toMatchObject({ + code: 'provider_unavailable', + }) + + const rows = await db + .select({ id: clientCredentialToken.id }) + .from(clientCredentialToken) + .where(like(clientCredentialToken.id, `${applicationKey(NAMESPACE, ['network'])}%`)) + expect(rows).toEqual([]) + }) + + it('mints a replacement once a provider-rejected token is invalidated', async () => { + const { exchange } = provider('revoked') + const first = await resolveCoordinatedClientToken(input('revoked', exchange)) + + await Promise.all( + Array.from({ length: 4 }, () => invalidateCoordinatedClientToken(first.accessToken)) + ) + const renewed = await Promise.all( + Array.from({ length: 4 }, () => resolveCoordinatedClientToken(input('revoked', exchange))) + ) + + expect(new Set(renewed.map((result) => result.accessToken))).toEqual( + new Set(['revoked:token-2']) + ) + expect(exchange).toHaveBeenCalledTimes(2) + }) + + it('leaves a replacement in service when a stale token is invalidated late', async () => { + const { exchange } = provider('late-invalidation') + const first = await resolveCoordinatedClientToken(input('late-invalidation', exchange)) + await invalidateCoordinatedClientToken(first.accessToken) + const replacement = await resolveCoordinatedClientToken(input('late-invalidation', exchange)) + + await invalidateCoordinatedClientToken(first.accessToken) + + await expect( + resolveCoordinatedClientToken(input('late-invalidation', exchange)) + ).resolves.toMatchObject({ accessToken: replacement.accessToken }) + expect(exchange).toHaveBeenCalledTimes(2) + }) + + it('keeps a token minted within the minimum age when a tool call reports it rejected', async () => { + const { exchange } = provider('fresh-rejection') + const first = await resolveCoordinatedClientToken(input('fresh-rejection', exchange)) + + for (let attempt = 0; attempt < 3; attempt++) { + await invalidateCoordinatedClientToken(first.accessToken, { minimumAgeMs: 60_000 }) + await expect( + resolveCoordinatedClientToken(input('fresh-rejection', exchange)) + ).resolves.toMatchObject({ accessToken: first.accessToken }) + } + expect(exchange).toHaveBeenCalledOnce() + + await db + .update(clientCredentialToken) + .set({ updatedAt: new Date(Date.now() - 120_000) }) + .where(eq(clientCredentialToken.id, applicationKey(NAMESPACE, ['fresh-rejection']))) + await invalidateCoordinatedClientToken(first.accessToken, { minimumAgeMs: 60_000 }) + + await expect( + resolveCoordinatedClientToken(input('fresh-rejection', exchange)) + ).resolves.toMatchObject({ accessToken: 'fresh-rejection:token-2' }) + }) + + it('sweeps expired failure memos', async () => { + await expect( + resolveCoordinatedClientToken(input('swept', rejecting(), 'revoked')) + ).rejects.toMatchObject({ code: 'invalid_credentials' }) + const failurePrefix = `${applicationKey(NAMESPACE, ['swept'])}:` + await db + .update(clientCredentialToken) + .set({ expiresAt: new Date(Date.now() - 1000) }) + .where(like(clientCredentialToken.id, `${failurePrefix}%`)) + + await resolveCoordinatedClientToken(input('sweeper', provider('sweeper').exchange)) + + const rows = await db + .select({ id: clientCredentialToken.id }) + .from(clientCredentialToken) + .where(like(clientCredentialToken.id, `${failurePrefix}%`)) + expect(rows).toEqual([]) + }) +}) + +describe('saving a Shopify client-credentials credential', () => { + const shop = `fixture-${generateId().slice(0, 8)}.myshopify.com` + const clientId = `client-${generateId()}` + ownedKeyPrefixes.add(applicationKey('shopify', [shop, clientId])) + + it('recovers when the shared token was revoked before it expired', async () => { + const active = new Set() + let issued = 0 + vi.stubGlobal('fetch', async (url: string | URL | Request, init?: RequestInit) => { + const target = new URL(typeof url === 'string' || url instanceof URL ? url : url.url) + if (target.host !== shop) throw new Error('Unexpected provider target') + if (target.pathname === '/admin/oauth/access_token') { + issued++ + const token = `shpca_${issued}` + active.add(token) + return Response.json({ access_token: token, scope: 'read_products', expires_in: 86399 }) + } + const token = new Headers(init?.headers).get('x-shopify-access-token') ?? '' + if (!active.has(token)) { + return Response.json({ errors: '[API] Invalid API key or access token' }, { status: 401 }) + } + return Response.json({ data: { shop: { name: 'Fixture', myshopifyDomain: shop } } }) + }) + const fields = { + authMethod: 'client_credentials', + clientId, + clientSecret: 'shopify-secret', + domain: shop, + } + await verifyAndBuildServiceAccountSecret('shopify-service-account', fields) + active.clear() + + await expect( + verifyAndBuildServiceAccountSecret('shopify-service-account', fields) + ).resolves.toMatchObject({ providerId: 'shopify-service-account' }) + expect(issued).toBe(2) + }) +}) diff --git a/apps/sim/lib/credentials/coordinated-client-token.ts b/apps/sim/lib/credentials/coordinated-client-token.ts new file mode 100644 index 00000000000..24073604f10 --- /dev/null +++ b/apps/sim/lib/credentials/coordinated-client-token.ts @@ -0,0 +1,260 @@ +import { createHash } from 'node:crypto' +import { db } from '@sim/db' +import { clientCredentialToken } from '@sim/db/schema' +import { isRecordLike } from '@sim/utils/object' +import { and, eq, inArray, lt, sql } from 'drizzle-orm' +import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption' +import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors' +import { acquireAdvisoryXactLock } from '@/lib/db/advisory-locks' + +/** A stored token is only handed out while at least this much of its lifetime remains. */ +const MIN_REMAINING_MS = 5 * 60 * 1000 +const FAILURE_TTL_MS = 30_000 + +/** The provider token a coordinated exchange produced. */ +interface CoordinatedClientTokenExchange { + accessToken: string + expiresAt: Date +} + +export interface CoordinatedClientTokenInput { + /** Provider label for the coordination key and lock, e.g. `shopify`. */ + namespace: string + /** + * Every value that names the provider application and the account it acts on (never the + * secret), so every saved credential for one application shares one token chain. + */ + application: readonly string[] + /** The secret the token is minted with; a token minted with another secret is never served. */ + clientSecret: string + /** Step name recorded on validation errors. */ + step: string + /** Performs the provider exchange. Throws `TokenServiceAccountValidationError` on refusal. */ + exchange: (signal?: AbortSignal) => Promise + signal?: AbortSignal +} + +export interface CoordinatedClientToken { + accessToken: string + expiresInSeconds: number +} + +interface StoredApplicationToken { + accessToken: string + secretFingerprint: string +} + +interface StoredFailure { + code: 'invalid_credentials' | 'grant_not_permitted' | 'site_not_found' | 'provider_unavailable' + status: number +} + +const STORED_FAILURE_CODES: ReadonlySet = new Set([ + 'invalid_credentials', + 'grant_not_permitted', + 'site_not_found', + 'provider_unavailable', +]) + +function digest(value: unknown): string { + return createHash('sha256').update(JSON.stringify(value)).digest('hex') +} + +/** Identifies a stored token by its value alone, so a provider rejection can expire it. */ +function accessTokenDigestOf(accessToken: string): string { + return digest(['coordinated_client_token', accessToken]) +} + +/** + * Deletes a bounded batch of expired failure memos (rows without a token digest). Run it outside + * the transaction that takes an application lock, so its row locks are released before any + * provider call. + */ +export async function deleteExpiredClientTokenFailures(): Promise { + await db.execute(sql` + DELETE FROM ${clientCredentialToken} WHERE ${clientCredentialToken.id} IN ( + SELECT ${clientCredentialToken.id} FROM ${clientCredentialToken} + WHERE ${clientCredentialToken.accessTokenDigest} IS NULL + AND ${clientCredentialToken.expiresAt} < now() + ORDER BY ${clientCredentialToken.expiresAt} LIMIT 100 FOR UPDATE SKIP LOCKED + )`) +} + +interface InvalidateCoordinatedClientTokenOptions { + /** + * Keeps the token when it was minted less than this long ago. For rejections that may come from + * the endpoint rather than the token, so a call that keeps failing cannot re-mint on every try. + */ + minimumAgeMs?: number +} + +/** + * Expires a stored token the provider rejected before its stated expiry (an app reinstall, a scope + * change, a revocation), so the next resolution mints a replacement. Only that exact token is + * expired: a replacement another worker already minted stays in service. + */ +export async function invalidateCoordinatedClientToken( + accessToken: string, + options: InvalidateCoordinatedClientTokenOptions = {} +): Promise { + const matchesToken = eq(clientCredentialToken.accessTokenDigest, accessTokenDigestOf(accessToken)) + await db + .update(clientCredentialToken) + .set({ expiresAt: new Date(0), updatedAt: new Date() }) + .where( + options.minimumAgeMs + ? and( + matchesToken, + lt(clientCredentialToken.updatedAt, new Date(Date.now() - options.minimumAgeMs)) + ) + : matchesToken + ) +} + +function unavailable(step: string, reason: string): TokenServiceAccountValidationError { + return new TokenServiceAccountValidationError('provider_unavailable', 503, { step, reason }) +} + +function decodeApplicationToken(value: string, step: string): StoredApplicationToken { + const parsed: unknown = JSON.parse(value) + if ( + !isRecordLike(parsed) || + typeof parsed.accessToken !== 'string' || + !parsed.accessToken || + typeof parsed.secretFingerprint !== 'string' + ) + throw unavailable(step, 'stored application token is malformed') + return { accessToken: parsed.accessToken, secretFingerprint: parsed.secretFingerprint } +} + +function decodeFailure(value: string, step: string): StoredFailure { + const parsed: unknown = JSON.parse(value) + if ( + !isRecordLike(parsed) || + typeof parsed.code !== 'string' || + !STORED_FAILURE_CODES.has(parsed.code) || + typeof parsed.status !== 'number' + ) + throw unavailable(step, 'stored token failure is malformed') + return { code: parsed.code as StoredFailure['code'], status: parsed.status } +} + +function remainingSeconds(expiresAt: Date): number { + return Math.floor((expiresAt.getTime() - Date.now()) / 1000) +} + +function isServable(expiresAt: Date): boolean { + return expiresAt.getTime() - Date.now() > MIN_REMAINING_MS +} + +/** + * Serves one client-credentials token per provider application across every saved credential, + * worker, and connect, until shortly before it expires. Used for providers that do not document + * whether minting a new token leaves the previous one valid, and for providers that ask clients + * to reuse one token rather than accumulate them. Minting happens only under a + * transaction-scoped advisory lock keyed by the application, so two workers never race two + * tokens into existence; a token minted with a different secret is never served, and a token the + * provider rejects is expired through {@link invalidateCoordinatedClientToken}. A rejected + * exchange is remembered for 30 seconds so a hot workflow on a revoked secret does not hammer the + * token endpoint. Tokens are encrypted at rest. + */ +export async function resolveCoordinatedClientToken( + input: CoordinatedClientTokenInput +): Promise { + const { namespace, step, signal } = input + signal?.throwIfAborted() + const applicationKey = `${namespace}:${digest(input.application)}` + const secretFingerprint = digest([...input.application, input.clientSecret]) + const failureKey = `${applicationKey}:${digest(secretFingerprint)}` + + const serveIfFresh = async (row: typeof clientCredentialToken.$inferSelect | undefined) => { + if (!row || !isServable(row.expiresAt)) return undefined + const stored = decodeApplicationToken((await decryptSecret(row.encryptedValue)).decrypted, step) + if (stored.secretFingerprint !== secretFingerprint) return undefined + return { accessToken: stored.accessToken, expiresInSeconds: remainingSeconds(row.expiresAt) } + } + + try { + const [current] = await db + .select() + .from(clientCredentialToken) + .where(eq(clientCredentialToken.id, applicationKey)) + .limit(1) + const fresh = await serveIfFresh(current) + if (fresh) return fresh + + await deleteExpiredClientTokenFailures() + const outcome = await db.transaction( + async (tx) => { + await tx.execute(sql`SET LOCAL lock_timeout = '15s'`) + await acquireAdvisoryXactLock(tx, `${namespace}_client_token`, applicationKey) + signal?.throwIfAborted() + const entries = await tx + .select() + .from(clientCredentialToken) + .where(inArray(clientCredentialToken.id, [applicationKey, failureKey])) + const minted = await serveIfFresh(entries.find((entry) => entry.id === applicationKey)) + if (minted) return minted + const failure = entries.find( + (entry) => entry.id === failureKey && entry.expiresAt.getTime() > Date.now() + ) + if (failure) { + return { + failure: decodeFailure((await decryptSecret(failure.encryptedValue)).decrypted, step), + } + } + signal?.throwIfAborted() + try { + const exchanged = await input.exchange(signal) + const { encrypted } = await encryptSecret( + JSON.stringify({ accessToken: exchanged.accessToken, secretFingerprint }) + ) + const values = { + encryptedValue: encrypted, + accessTokenDigest: accessTokenDigestOf(exchanged.accessToken), + expiresAt: exchanged.expiresAt, + updatedAt: new Date(), + } + await tx + .insert(clientCredentialToken) + .values({ id: applicationKey, ...values }) + .onConflictDoUpdate({ target: clientCredentialToken.id, set: values }) + await tx.delete(clientCredentialToken).where(eq(clientCredentialToken.id, failureKey)) + return { + accessToken: exchanged.accessToken, + expiresInSeconds: remainingSeconds(exchanged.expiresAt), + } + } catch (error) { + if (!(error instanceof TokenServiceAccountValidationError)) throw error + const failure: StoredFailure = { + code: STORED_FAILURE_CODES.has(error.code) + ? (error.code as StoredFailure['code']) + : 'provider_unavailable', + status: error.status, + } + const { encrypted } = await encryptSecret(JSON.stringify(failure)) + const expiresAt = new Date(Date.now() + FAILURE_TTL_MS) + await tx + .insert(clientCredentialToken) + .values({ id: failureKey, encryptedValue: encrypted, expiresAt }) + .onConflictDoUpdate({ + target: clientCredentialToken.id, + set: { encryptedValue: encrypted, expiresAt, updatedAt: new Date() }, + }) + return { failure } + } + } + ) + signal?.throwIfAborted() + if ('failure' in outcome) { + throw new TokenServiceAccountValidationError(outcome.failure.code, outcome.failure.status, { + step, + }) + } + return outcome + } catch (error) { + signal?.throwIfAborted() + if (error instanceof TokenServiceAccountValidationError) throw error + throw unavailable(step, 'token coordination or exchange failed') + } +} diff --git a/apps/sim/lib/credentials/databricks-service-account.server.test.ts b/apps/sim/lib/credentials/databricks-service-account.server.test.ts index 26b610ba9e5..420689c5606 100644 --- a/apps/sim/lib/credentials/databricks-service-account.server.test.ts +++ b/apps/sim/lib/credentials/databricks-service-account.server.test.ts @@ -157,6 +157,22 @@ describe('saving a keyless Databricks connection', () => { ).rejects.toThrow(/could not confirm/) expect(encryptionMockFns.mockEncryptSecret).not.toHaveBeenCalled() }) + + it('asks once to try again when the any-subject check cannot reach the workspace', async () => { + principalTrusting(true) + const trusted = fetch.getMockImplementation() + fetch.mockImplementation(async (url: string, init: RequestInit) => { + const body = new URLSearchParams(String(init?.body)) + if (url.endsWith('/oidc/v1/token') && body.get('subject_token') !== `token-for:${SUBJECT}`) { + throw new TypeError('fetch failed') + } + return trusted?.(url, init) + }) + + await expect( + verifyAndEncryptDatabricksCredential(JSON.stringify(config), reconnectOwner) + ).rejects.toThrow(/this credential: Could not reach the Databricks workspace\. Try again\.$/) + }) }) describe('getting a Databricks token for a run', () => { @@ -238,4 +254,39 @@ describe('getting a Databricks token for a run', () => { code: 'provider_unavailable', }) }) + + it.each([ + [ + 'the connection fails', + Object.assign(new TypeError('fetch failed'), { + cause: Object.assign(new Error('connect ECONNREFUSED 203.0.113.7:443'), { + code: 'ECONNREFUSED', + }), + }), + ], + ['the workspace answers with a redirect', new TypeError('fetch failed: unexpected redirect')], + ['the request times out', new DOMException('The operation timed out.', 'TimeoutError')], + ])( + 'reports a workspace that cannot be reached when %s as unavailable, without connection details', + async (_case, failure) => { + fetch.mockRejectedValue(failure) + + const error = await resolveDatabricksCredentialToken('cred-1').catch((caught) => caught) + + expect(error).toMatchObject({ code: 'provider_unavailable', status: 502 }) + expect(JSON.stringify(error)).not.toContain('203.0.113.7') + } + ) + + it('rethrows when the run itself is cancelled', async () => { + const controller = new AbortController() + fetch.mockImplementation(async () => { + controller.abort() + throw new DOMException('This operation was aborted', 'AbortError') + }) + + await expect( + resolveDatabricksCredentialToken('cred-1', controller.signal) + ).rejects.toMatchObject({ name: 'AbortError' }) + }) }) diff --git a/apps/sim/lib/credentials/databricks-service-account.server.ts b/apps/sim/lib/credentials/databricks-service-account.server.ts index 287dd4ff731..6281a15be41 100644 --- a/apps/sim/lib/credentials/databricks-service-account.server.ts +++ b/apps/sim/lib/credentials/databricks-service-account.server.ts @@ -140,14 +140,22 @@ async function requestWorkspaceToken( headers: Record = {} ): Promise { const timeout = AbortSignal.timeout(REQUEST_TIMEOUT_MS) - const response = await fetch(`${workspaceOrigin(host)}/oidc/v1/token`, { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...headers }, - body, - // A secret or subject token must never follow a redirect off the workspace host. - redirect: 'error', - signal: signal ? AbortSignal.any([signal, timeout]) : timeout, - }) + const url = `${workspaceOrigin(host)}/oidc/v1/token` + let response: Response + try { + response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...headers }, + body, + // A secret or subject token must never follow a redirect off the workspace host. + redirect: 'error', + signal: signal ? AbortSignal.any([signal, timeout]) : timeout, + }) + } catch { + signal?.throwIfAborted() + // A network failure, timeout, or refused redirect says nothing about the credential. + throw new DatabricksCredentialError('Could not reach the Databricks workspace', 502) + } const payload = (await readResponseJsonWithLimit(response, { maxBytes: MAX_RESPONSE_BYTES, label: 'Databricks token response', diff --git a/apps/sim/lib/credentials/microsoft-app-service-account.server.test.ts b/apps/sim/lib/credentials/microsoft-app-service-account.server.test.ts index 7dc2a6a40fe..6ddcb05a15a 100644 --- a/apps/sim/lib/credentials/microsoft-app-service-account.server.test.ts +++ b/apps/sim/lib/credentials/microsoft-app-service-account.server.test.ts @@ -12,8 +12,9 @@ import { import { queueTableRows, resetDbChainMock } from '@sim/testing' import { jsonResponse } from '@sim/testing/helpers/http' import { encryptionMock, encryptionMockFns } from '@sim/testing/mocks/encryption.mock' +import { sleep } from '@sim/utils/helpers' import { decodeProtectedHeader, jwtVerify } from 'jose' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('@/lib/core/security/encryption', () => encryptionMock) vi.mock('@/lib/credentials/workload-identity/issuer', () => ({ @@ -58,25 +59,31 @@ function derUtcTime(date: Date): Buffer { return der(0x17, Buffer.from(`${text}Z`)) } +const DAY_MS = 86_400_000 + /** * A self-signed RSA certificate (CN=sim-test-app) generated for this test run, * so no key material is committed. */ -function generateTestCertificate(): { certificate: string; privateKey: string } { +function generateTestCertificate( + validity: { notBefore: number; notAfter: number } = { + notBefore: Date.now() - DAY_MS, + notAfter: Date.now() + 365 * DAY_MS, + } +): { certificate: string; privateKey: string } { const { privateKey, publicKey } = generateKeyPairSync('rsa', { modulusLength: 2048 }) const sha256WithRsa = der(0x30, derOid('1.2.840.113549.1.1.11'), der(0x05)) const name = der( 0x30, der(0x31, der(0x30, derOid('2.5.4.3'), der(0x0c, Buffer.from('sim-test-app')))) ) - const now = Date.now() const tbs = der( 0x30, der(0xa0, der(0x02, Buffer.from([0x02]))), der(0x02, Buffer.from([0x01])), sha256WithRsa, name, - der(0x30, derUtcTime(new Date(now - 86_400_000)), derUtcTime(new Date(now + 365 * 86_400_000))), + der(0x30, derUtcTime(new Date(validity.notBefore)), derUtcTime(new Date(validity.notAfter))), name, publicKey.export({ type: 'spki', format: 'der' }) ) @@ -114,14 +121,25 @@ interface TokenRequest { */ function fakeMicrosoft( fetch: ReturnType, - options: { acceptAssertion?: (assertion: string) => boolean; tokenError?: Response } = {} + options: { + acceptAssertion?: (assertion: string) => boolean + tokenError?: Response + /** Refusals answered to the first token requests, in order, before Entra starts issuing tokens. */ + tokenErrorsFirst?: Response[] + /** Settles when Entra answers each token request; the caller drives it on its fake clock. */ + tokenDelay?: () => Promise + } = {} ) { const tokenRequests: TokenRequest[] = [] + const pendingErrors = [...(options.tokenErrorsFirst ?? [])] fetch.mockImplementation(async (url: string, init: RequestInit = {}) => { if (url.startsWith('https://login.microsoftonline.com/')) { const form = Object.fromEntries(new URLSearchParams(String(init.body))) tokenRequests.push({ url, form, redirect: init.redirect }) + await options.tokenDelay?.() if (options.tokenError) return options.tokenError.clone() + const pendingError = pendingErrors.shift() + if (pendingError) return pendingError if (form.client_assertion && options.acceptAssertion?.(form.client_assertion) === false) { return jsonResponse( { error: 'invalid_client', error_codes: [700213], error_description: 'AADSTS700213: …' }, @@ -228,6 +246,18 @@ describe('saving a Microsoft app credential', () => { expect(encryptionMockFns.mockEncryptSecret).not.toHaveBeenCalled() }) + it('refuses a certificate that is not valid yet before contacting Entra', async () => { + fakeMicrosoft(fetch) + const future = generateTestCertificate({ + notBefore: Date.now() + 2 * DAY_MS, + notAfter: Date.now() + 365 * DAY_MS, + }) + + await expect(save({ authType: 'certificate', ...future })).rejects.toThrow(/not valid until/) + expect(fetch).not.toHaveBeenCalled() + expect(encryptionMockFns.mockEncryptSecret).not.toHaveBeenCalled() + }) + describe('keyless', () => { const SUBJECT = 'sim:workspace:ws-1:credential:abcdef0123456789' const reconnectOwner = { @@ -425,6 +455,20 @@ describe('getting a Microsoft app token for a run', () => { expect(tokens.map((request) => request.form.scope)).toEqual([`${ENVIRONMENT}/.default`]) }) + it('mints a Microsoft Graph token for Intune, shared with the other Graph services', async () => { + const tokens = fakeMicrosoft(fetch) + storeCredential({ authType: 'client_secret', clientSecret: SECRET }, 'ciphertext-intune') + await expect( + resolveMicrosoftAppCredentialToken('cred-intune', 'microsoft-intune') + ).resolves.toEqual({ accessToken: 'at:https://graph.microsoft.com/.default' }) + storeCredential({ authType: 'client_secret', clientSecret: SECRET }, 'ciphertext-intune') + await resolveMicrosoftAppCredentialToken('cred-intune', 'microsoft-ad') + + expect(tokens.map((request) => request.form.scope)).toEqual([ + 'https://graph.microsoft.com/.default', + ]) + }) + it('refuses Dataverse for a credential saved without an environment', async () => { fakeMicrosoft(fetch) storeCredential({ authType: 'client_secret', clientSecret: SECRET }, 'ciphertext-graph-only') @@ -450,6 +494,130 @@ describe('getting a Microsoft app token for a run', () => { expect(tokens[1]?.form.client_secret).toBe('rotated') }) + it('asks for a reconnect once the stored certificate has expired, without contacting Entra', async () => { + fakeMicrosoft(fetch) + const expired = generateTestCertificate({ + notBefore: Date.now() - 400 * DAY_MS, + notAfter: Date.now() - DAY_MS, + }) + storeCredential({ authType: 'certificate', ...expired }, 'ciphertext-expired-certificate') + + await expect( + resolveMicrosoftAppCredentialToken('cred-expired-certificate', 'microsoft-ad') + ).rejects.toMatchObject({ code: 'invalid_credentials', status: 400 }) + expect(fetch).not.toHaveBeenCalled() + }) + + it('reports an unreachable Entra as the provider being unavailable', async () => { + fetch.mockRejectedValue(new TypeError('fetch failed')) + storeCredential({ authType: 'client_secret', clientSecret: SECRET }, 'ciphertext-unreachable') + + await expect( + resolveMicrosoftAppCredentialToken('cred-unreachable', 'microsoft-ad') + ).rejects.toMatchObject({ code: 'provider_unavailable', status: 502 }) + }) + + describe('while a new federated credential propagates', () => { + const SUBJECT = 'sim:workspace:ws-1:credential:abcdef0123456789' + const refusal = (code: number) => + jsonResponse( + { + error: 'invalid_client', + error_codes: [code], + error_description: `AADSTS${code}: No matching federated identity record found for presented assertion.`, + }, + 400 + ) + + beforeEach(() => { + vi.useFakeTimers({ toFake: ['setTimeout', 'Date'] }) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + async function settle(promise: Promise): Promise { + const settled = promise.then( + (value) => ({ value }), + (error: unknown) => ({ error }) + ) + await vi.runAllTimersAsync() + const outcome = await settled + if ('error' in outcome) throw outcome.error + return outcome.value + } + + it('retries an AADSTS70021 refusal and returns the token Entra then issues', async () => { + const tokens = fakeMicrosoft(fetch, { tokenErrorsFirst: [refusal(70021), refusal(70021)] }) + storeCredential({ authType: 'federated', subject: SUBJECT }, 'ciphertext-propagating') + + await expect( + settle(resolveMicrosoftAppCredentialToken('cred-propagating', 'microsoft-ad')) + ).resolves.toEqual({ accessToken: 'at:https://graph.microsoft.com/.default' }) + expect(tokens).toHaveLength(3) + }) + + it('does not retry AADSTS700213, the documented case-sensitive mismatch', async () => { + const tokens = fakeMicrosoft(fetch, { tokenErrorsFirst: [refusal(700213)] }) + storeCredential({ authType: 'federated', subject: SUBJECT }, 'ciphertext-mismatched') + + await expect( + settle(resolveMicrosoftAppCredentialToken('cred-mismatched', 'microsoft-ad')) + ).rejects.toMatchObject({ + code: 'invalid_credentials', + logDetail: { reason: 'AADSTS700213' }, + }) + expect(tokens).toHaveLength(1) + }) + + it('stops retrying when a slow Entra would push the run past the shared mint deadline', async () => { + const tokens = fakeMicrosoft(fetch, { + tokenError: refusal(70021), + tokenDelay: () => sleep(11_000), + }) + storeCredential({ authType: 'federated', subject: SUBJECT }, 'ciphertext-slow-refusal') + + await expect( + settle(resolveMicrosoftAppCredentialToken('cred-slow-refusal', 'microsoft-ad')) + ).rejects.toMatchObject({ code: 'invalid_credentials' }) + expect(tokens).toHaveLength(1) + }) + + it('asks for a reconnect once the retries are spent', async () => { + const tokens = fakeMicrosoft(fetch, { tokenError: refusal(70021) }) + storeCredential({ authType: 'federated', subject: SUBJECT }, 'ciphertext-never-matches') + + await expect( + settle(resolveMicrosoftAppCredentialToken('cred-never-matches', 'microsoft-ad')) + ).rejects.toMatchObject({ code: 'invalid_credentials' }) + expect(tokens).toHaveLength(3) + }) + + it('does not retry any other refusal', async () => { + const tokens = fakeMicrosoft(fetch, { tokenError: refusal(700016) }) + storeCredential({ authType: 'federated', subject: SUBJECT }, 'ciphertext-unknown-app') + + await expect( + settle(resolveMicrosoftAppCredentialToken('cred-unknown-app', 'microsoft-ad')) + ).rejects.toMatchObject({ code: 'invalid_credentials' }) + expect(tokens).toHaveLength(1) + }) + + it('does not retry the same refusal for a client secret credential', async () => { + const tokens = fakeMicrosoft(fetch, { tokenError: refusal(70021) }) + storeCredential( + { authType: 'client_secret', clientSecret: SECRET }, + 'ciphertext-secret-70021' + ) + + await expect( + settle(resolveMicrosoftAppCredentialToken('cred-secret-70021', 'microsoft-ad')) + ).rejects.toMatchObject({ code: 'invalid_credentials' }) + expect(tokens).toHaveLength(1) + }) + }) + it('reports an Entra outage as the provider being unavailable, not a bad credential', async () => { fakeMicrosoft(fetch, { tokenError: jsonResponse({ error: 'temporarily_unavailable' }, 503) }) storeCredential({ authType: 'client_secret', clientSecret: SECRET }, 'ciphertext-outage') diff --git a/apps/sim/lib/credentials/microsoft-app-service-account.server.ts b/apps/sim/lib/credentials/microsoft-app-service-account.server.ts index d98e505bfd2..f1ded209f1a 100644 --- a/apps/sim/lib/credentials/microsoft-app-service-account.server.ts +++ b/apps/sim/lib/credentials/microsoft-app-service-account.server.ts @@ -2,7 +2,9 @@ import { createHash, createPrivateKey, type KeyObject, X509Certificate } from 'n import { db } from '@sim/db' import { credential } from '@sim/db/schema' import { getErrorMessage } from '@sim/utils/errors' +import { sleep } from '@sim/utils/helpers' import { generateId } from '@sim/utils/id' +import { backoffWithJitter } from '@sim/utils/retry' import { eq } from 'drizzle-orm' import { SignJWT } from 'jose' import { LRUCache } from 'lru-cache' @@ -33,7 +35,7 @@ import { MICROSOFT_APP_SERVICE_ACCOUNT_PROVIDER_ID } from '@/lib/oauth/types' * Saved Microsoft Entra app-only credentials (OAuth 2.0 client credentials). * * One app registration serves every Microsoft service that runs without a - * signed-in user: Microsoft Graph for Entra ID and SharePoint, and the + * signed-in user: Microsoft Graph for Entra ID, SharePoint, and Intune, and the * credential's own Dataverse environment for Dataverse and Dynamics 365. The * app's application permissions and the environment's application user decide * what it can do; Sim stores no permission list of its own. @@ -59,6 +61,22 @@ const CERTIFICATE_ASSERTION_TTL_SECONDS = 300 const MIN_RSA_MODULUS_BITS = 2048 const MAX_RSA_MODULUS_BITS = 4096 const MAX_PEM_LENGTH = 16_384 +/** A certificate issued moments ago on a machine whose clock runs ahead is not refused as "not valid yet". */ +const CERTIFICATE_CLOCK_SKEW_MS = 5 * 60 * 1000 + +/** Extra token requests a run makes while a new federated credential propagates through Entra. */ +const FEDERATION_PROPAGATION_RETRIES = 2 +/** + * The refusal Microsoft documents for a federated credential that has not reached every Entra + * node yet. AADSTS700213 is left out: Microsoft documents it only as a case-sensitive mismatch. + */ +const FEDERATION_PROPAGATION_AADSTS = 70021 +/** + * A run's whole token request, retries included, settles inside this budget so it stays under the + * 30 s deadline that concurrent runs sharing the mint wait for. A retry is only started when even + * a request that runs to its full timeout would still finish in time. + */ +const RUN_TOKEN_BUDGET_MS = 25_000 /** Re-mint once less than this much of a token's lifetime remains. */ const TOKEN_REUSE_MARGIN_MS = 5 * 60 * 1000 @@ -69,8 +87,18 @@ const GUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{ const TENANT_DOMAIN_PATTERN = /^(?=.{4,253}$)[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$/ -/** The Microsoft services that may use this credential, and the resource each one calls. */ -type MicrosoftAppService = 'microsoft-ad' | 'sharepoint' | 'microsoft-dataverse' +/** + * The Microsoft services that may use this credential. Each one's `serviceAccountProviderId` in + * `oauth.ts` offers the credential in its picker; this list is what the resolver accepts. + */ +const MICROSOFT_APP_SERVICES = [ + 'microsoft-ad', + 'sharepoint', + 'microsoft-intune', + 'microsoft-dataverse', +] as const + +type MicrosoftAppService = (typeof MICROSOFT_APP_SERVICES)[number] const tenantSchema = z .string() @@ -167,7 +195,9 @@ const storedSecretSchema = z /** A configuration problem Sim can name the fix for; its message is shown to the person saving. */ export class MicrosoftAppCredentialError extends Error { /** - * @param status The HTTP status Microsoft answered with, when Microsoft itself refused. + * @param status The HTTP status of the failed exchange: Microsoft's answer, or 502 when Entra + * could not be reached or answered without a token. Absent for a problem with the stored + * configuration itself (an expired certificate, say), which only a reconnect fixes. * @param aadsts The Entra `AADSTS` error code, when Entra reported one. */ constructor( @@ -280,6 +310,12 @@ function loadCertificateMaterial( 'This certificate has expired. Upload a new one to the app registration and save it here.' ) } + const validFrom = Date.parse(certificate.validFrom) + if (validFrom > Date.now() + CERTIFICATE_CLOCK_SKEW_MS) { + throw new MicrosoftAppCredentialError( + `This certificate is not valid until ${new Date(validFrom).toISOString()}. Use a certificate that is already valid.` + ) + } return { key, thumbprintS256: createHash('sha256').update(certificate.raw).digest('base64url'), @@ -363,7 +399,7 @@ async function requestEntraToken( }) } catch { signal?.throwIfAborted() - throw new MicrosoftAppCredentialError('Could not reach Microsoft Entra. Try again.') + throw new MicrosoftAppCredentialError('Could not reach Microsoft Entra. Try again.', 502) } const payload = (await response.json().catch(() => ({}))) as { access_token?: unknown @@ -595,6 +631,36 @@ export async function verifyAndEncryptMicrosoftAppCredential( } } +/** + * Requests a run's token, retrying a federated credential's "no matching federated identity + * record" refusal: Entra can answer it for several minutes after the federated credential is + * added, even after a token was issued, and Microsoft asks callers to retry. The retries stop + * within {@link RUN_TOKEN_BUDGET_MS}. The save-time subject-pin probe never comes here, because + * it expects that very refusal. + * @see https://learn.microsoft.com/en-us/entra/workload-id/workload-identity-federation-considerations#time-for-federated-credential-changes-to-propagate + */ +async function requestRunEntraToken( + config: MicrosoftAppConfig, + resource: string, + owner: WorkloadIdentityOwner +): Promise { + const deadline = Date.now() + RUN_TOKEN_BUDGET_MS + for (let attempt = 1; ; attempt++) { + try { + return await requestEntraToken(config, resource, owner) + } catch (error) { + const propagating = + config.authType === 'federated' && + error instanceof MicrosoftAppCredentialError && + error.aadsts === FEDERATION_PROPAGATION_AADSTS + if (!propagating || attempt > FEDERATION_PROPAGATION_RETRIES) throw error + const delay = backoffWithJitter(attempt, null) + if (Date.now() + delay + REQUEST_TIMEOUT_MS > deadline) throw error + await sleep(delay) + } + } +} + interface CachedToken { accessToken: string instanceUrl?: string @@ -613,14 +679,14 @@ export interface MicrosoftAppCredentialToken { } function isMicrosoftAppService(value: string | undefined): value is MicrosoftAppService { - return value === 'microsoft-ad' || value === 'sharepoint' || value === 'microsoft-dataverse' + return (MICROSOFT_APP_SERVICES as readonly (string | undefined)[]).includes(value) } /** * Resolves an executor-authorized Microsoft app credential (the executor * already checked the caller may use it) to a token for the service the tool * belongs to. The resource is decided by the tool's service, never by caller - * params: Graph for Entra ID and SharePoint, the stored environment for + * params: Graph for Entra ID, SharePoint, and Intune, the stored environment for * Dataverse. Any other service is refused, so a Microsoft block that needs a * signed-in user can never receive an app-only token. */ @@ -683,14 +749,18 @@ export async function resolveMicrosoftAppCredentialToken( : { organizationId: row.organizationId ?? undefined } let token: EntraToken try { - token = await requestEntraToken(config, resource, owner) + token = await requestRunEntraToken(config, resource, owner) } catch (error) { if (!(error instanceof MicrosoftAppCredentialError)) throw error - // Report in the shared service-account codes: an outage is not a bad credential. + // Report in the shared service-account codes: an outage is not a bad credential, and a + // stored certificate that can no longer sign (expired, say) is one only a reconnect fixes. throw new TokenServiceAccountValidationError( - error.status === undefined ? 'provider_unavailable' : providerFailureCode(error.status), - error.status ?? 502, - { step: 'microsoft_app_token', reason: error.aadsts ? `AADSTS${error.aadsts}` : undefined } + error.status === undefined ? 'invalid_credentials' : providerFailureCode(error.status), + error.status ?? 400, + { + step: 'microsoft_app_token', + reason: error.aadsts ? `AADSTS${error.aadsts}` : error.message, + } ) } const value: CachedToken = { accessToken: token.accessToken, instanceUrl } diff --git a/apps/sim/lib/credentials/orchestration/index.test.ts b/apps/sim/lib/credentials/orchestration/index.test.ts index 96d24024500..6f2f60cdbe0 100644 --- a/apps/sim/lib/credentials/orchestration/index.test.ts +++ b/apps/sim/lib/credentials/orchestration/index.test.ts @@ -314,6 +314,66 @@ describe('performUpdateCredential — service-account secret rotation', () => { ) }) + it.each([ + [ + 'shopify-service-account', + { + type: 'token_service_account', + providerId: 'shopify-service-account', + authMethod: 'client_credentials', + }, + { domain: 'acme.myshopify.com' }, + ], + [ + 'atlassian-service-account', + { type: 'atlassian_service_account', authMethod: 'client_credentials' }, + { domain: 'acme.atlassian.net' }, + ], + ])( + 'keeps a %s credential on client credentials when a reconnect sends only a new secret', + async (providerId, storedBlob, extraFields) => { + mockCredential({ providerId, displayName: 'Fixture' }) + mockStoredBlob(storedBlob) + + await performUpdateCredential({ + credentialId: 'cred-1', + userId: 'user-1', + clientId: 'cid', + clientSecret: 'rotated-secret', + ...extraFields, + }) + + expect(mockVerifyAndBuildServiceAccountSecret).toHaveBeenCalledWith( + providerId, + expect.objectContaining({ + authMethod: 'client_credentials', + clientSecret: 'rotated-secret', + }) + ) + } + ) + + it('switches a client-credentials credential to a pasted token when the reconnect sends one', async () => { + mockCredential({ providerId: 'shopify-service-account', displayName: 'Fixture' }) + mockStoredBlob({ + type: 'token_service_account', + providerId: 'shopify-service-account', + authMethod: 'client_credentials', + }) + + await performUpdateCredential({ + credentialId: 'cred-1', + userId: 'user-1', + apiToken: 'shpat_new', + domain: 'acme.myshopify.com', + }) + + expect(mockVerifyAndBuildServiceAccountSecret).toHaveBeenCalledWith( + 'shopify-service-account', + expect.objectContaining({ apiToken: 'shpat_new', authMethod: undefined }) + ) + }) + it('carries the stored dataCenter forward for a client-credential reconnect', async () => { mockCredential({ providerId: 'zoho-desk-service-account', displayName: 'Acme Desk' }) mockIsClientCredentialAccountProviderId.mockReturnValue(true) diff --git a/apps/sim/lib/credentials/orchestration/index.ts b/apps/sim/lib/credentials/orchestration/index.ts index 497b7165009..5674a09afd1 100644 --- a/apps/sim/lib/credentials/orchestration/index.ts +++ b/apps/sim/lib/credentials/orchestration/index.ts @@ -40,9 +40,10 @@ import { import { GOOGLE_WORKLOAD_IDENTITY_SECRET_TYPE } from '@/lib/credentials/google-workload-identity' import { OciCredentialVerificationError } from '@/lib/credentials/oci-api-key-service-account.server' import { keylessFailure } from '@/lib/credentials/orchestration/credential-create' -import type { - AtlassianProduct, - ServiceAccountFieldId, +import { + type AtlassianProduct, + offersClientCredentialsMode, + type ServiceAccountFieldId, } from '@/lib/credentials/service-account-fields' import { ServiceAccountSecretError, @@ -352,7 +353,13 @@ export async function updateCredentialRecord( const isMultiGrantProvider = Boolean( getClientCredentialAccountDescriptor(providerId)?.defaultAuthMethod ) - const needsStoredAuthMethod = params.authMethod === undefined && isMultiGrantProvider + // A token provider that also offers client credentials keeps the stored mode when the + // reconnect sends client credentials without restating it; sending a token selects the + // token mode, exactly as before the client-credentials mode existed. + const needsStoredAuthMethod = + params.authMethod === undefined && + (isMultiGrantProvider || + (offersClientCredentialsMode(providerId) && params.apiToken === undefined)) const needsStoredAtlassianProduct = providerId === ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID && params.atlassianProduct === undefined diff --git a/apps/sim/lib/credentials/personal-tokens.test.ts b/apps/sim/lib/credentials/personal-tokens.test.ts index 901b17c44ea..faaf7485557 100644 --- a/apps/sim/lib/credentials/personal-tokens.test.ts +++ b/apps/sim/lib/credentials/personal-tokens.test.ts @@ -116,6 +116,15 @@ describe('personal GitLab tokens in Connected accounts', () => { ) expectLiveBinding() }) + it('verifies and stores a pasted token without its surrounding whitespace', async () => { + binding() + dbChainMockFns.returning.mockResolvedValueOnce([current]) + await createPersonalTokenCredential({ ...input, apiToken: ` ${input.apiToken}\n` }) + expect(mocks.verify).toHaveBeenCalledWith(input.apiToken, input.domain) + expect(mocks.encrypt).toHaveBeenCalledWith( + expect.objectContaining({ accessToken: input.apiToken }) + ) + }) it('reconnects the same identity slot without creating a second personal credential', async () => { binding() dbChainMockFns.returning.mockResolvedValueOnce([]).mockResolvedValueOnce([current]) diff --git a/apps/sim/lib/credentials/personal-tokens.ts b/apps/sim/lib/credentials/personal-tokens.ts index 28983bca774..63b8fd4281c 100644 --- a/apps/sim/lib/credentials/personal-tokens.ts +++ b/apps/sim/lib/credentials/personal-tokens.ts @@ -192,16 +192,17 @@ export interface CreatePersonalTokenParams { /** Stores a verified personal token in its immutable owner/provider/instance/subject slot. */ export async function createPersonalTokenCredential(input: CreatePersonalTokenParams) { - if (input.providerId !== 'gitlab' || !input.apiToken) + const apiToken = input.apiToken?.trim() + if (input.providerId !== 'gitlab' || !apiToken) throw new OrchestrationError('validation', 'A personal GitLab access token is required') - const verified = await verifyGitLabPersonalToken(input.apiToken, input.domain) + const verified = await verifyGitLabPersonalToken(apiToken, input.domain) const encryptedPersonalToken = await encryptPersonalToken({ providerId: verified.providerId, ownerUserId: input.userId, organizationId: input.accounts.organizationId, subjectId: verified.subjectId, instanceUrl: verified.instanceUrl, - accessToken: input.apiToken, + accessToken: apiToken, }) const { enrollment } = await createViewerCredentialGroupEnrollment({ userId: input.userId, @@ -347,8 +348,9 @@ export async function updatePersonalTokenCredential(input: UpdatePersonalTokenPa 'validation', 'Connect a new personal token to use another GitLab instance' ) - if (input.apiToken) { - const verified = await verifyGitLabPersonalToken(input.apiToken, current.providerTenantId) + const apiToken = input.apiToken?.trim() + if (apiToken) { + const verified = await verifyGitLabPersonalToken(apiToken, current.providerTenantId) if ( verified.subjectId !== current.providerSubjectId || verified.instanceUrl !== current.providerTenantId @@ -363,7 +365,7 @@ export async function updatePersonalTokenCredential(input: UpdatePersonalTokenPa ...resourceScopeFields(scope), subjectId: current.providerSubjectId, instanceUrl: current.providerTenantId, - accessToken: input.apiToken, + accessToken: apiToken, }) updates.grantedScopes = verified.grantedScopes updates.accessTokenExpiresAt = verified.expiresAt diff --git a/apps/sim/lib/credentials/service-account-fields.test.ts b/apps/sim/lib/credentials/service-account-fields.test.ts index 379241cbc17..b463f1fbffa 100644 --- a/apps/sim/lib/credentials/service-account-fields.test.ts +++ b/apps/sim/lib/credentials/service-account-fields.test.ts @@ -19,4 +19,58 @@ describe('required fields for a service-account create', () => { getMissingServiceAccountFields('hubspot-service-account', { serviceAccountJson: '{}' }) ).toEqual(['apiToken']) }) + + it('asks a Shopify client-credentials create for the client ID, secret, and store domain', () => { + expect( + getMissingServiceAccountFields('shopify-service-account', { + authMethod: 'client_credentials', + }) + ).toEqual(['clientId', 'clientSecret', 'domain']) + expect( + getMissingServiceAccountFields('shopify-service-account', { + authMethod: 'client_credentials', + clientId: 'client', + clientSecret: 'secret', + domain: 'acme.myshopify.com', + }) + ).toEqual([]) + }) + + it('still asks a Shopify create without an auth method for the token and store domain', () => { + expect( + getMissingServiceAccountFields('shopify-service-account', { + clientId: 'client', + clientSecret: 'secret', + }) + ).toEqual(['apiToken', 'domain']) + }) + + it('does not let client credentials stand in for a token on a provider without that mode', () => { + expect( + getMissingServiceAccountFields('hubspot-service-account', { + authMethod: 'client_credentials', + clientId: 'client', + clientSecret: 'secret', + }) + ).toEqual(['apiToken']) + }) + + it('asks an Atlassian OAuth 2.0 create for the client ID, secret, and site, not a token', () => { + expect( + getMissingServiceAccountFields('atlassian-service-account', { + authMethod: 'client_credentials', + }) + ).toEqual(['clientId', 'clientSecret', 'domain']) + expect(getMissingServiceAccountFields('atlassian-service-account', {})).toEqual([ + 'apiToken', + 'domain', + ]) + }) + + it('asks a Linear client-credentials create for only the client ID and secret', () => { + expect( + getMissingServiceAccountFields('linear-service-account', { authMethod: 'client_credentials' }) + ).toEqual(['clientId', 'clientSecret']) + expect(getMissingServiceAccountFields('linear-service-account', {})).toEqual(['apiToken']) + }) }) diff --git a/apps/sim/lib/credentials/service-account-fields.ts b/apps/sim/lib/credentials/service-account-fields.ts index 6070efddd76..60113ef6bb7 100644 --- a/apps/sim/lib/credentials/service-account-fields.ts +++ b/apps/sim/lib/credentials/service-account-fields.ts @@ -1,5 +1,9 @@ import { CLIENT_CREDENTIAL_ACCOUNT_REQUIRED_FIELDS } from '@/lib/credentials/client-credential-accounts/descriptors' -import { TOKEN_SERVICE_ACCOUNT_REQUIRED_FIELDS } from '@/lib/credentials/token-service-accounts/descriptors' +import { + CLIENT_CREDENTIALS_AUTH_METHOD, + TOKEN_SERVICE_ACCOUNT_CLIENT_CREDENTIAL_FIELDS, + TOKEN_SERVICE_ACCOUNT_REQUIRED_FIELDS, +} from '@/lib/credentials/token-service-accounts/descriptors' import { isWorkloadIdentityServiceAccountProviderId } from '@/lib/credentials/workload-identity/audiences' import { ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID, @@ -103,10 +107,39 @@ function getServiceAccountRequiredFields( : FALLBACK_SERVICE_ACCOUNT_REQUIRED_FIELDS } +/** + * Required create-body fields for providers whose primary mode is a pasted token but which also + * offer a client-credentials grant, selected by `authMethod: 'client_credentials'`. + */ +const CLIENT_CREDENTIALS_MODE_REQUIRED_FIELDS: Record = { + [ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID]: ['clientId', 'clientSecret', 'domain'], + ...TOKEN_SERVICE_ACCOUNT_CLIENT_CREDENTIAL_FIELDS, +} + +/** + * Whether a provider's primary mode is a pasted token but it also offers a client-credentials + * mode, which the secret records as `authMethod: 'client_credentials'`. + */ +export function offersClientCredentialsMode(providerId: string | null | undefined): boolean { + return Boolean(providerId && Object.hasOwn(CLIENT_CREDENTIALS_MODE_REQUIRED_FIELDS, providerId)) +} + +function getClientCredentialsModeFields( + providerId: string | null | undefined, + authMethod: unknown +): readonly ServiceAccountFieldId[] | undefined { + return authMethod === CLIENT_CREDENTIALS_AUTH_METHOD && + providerId && + offersClientCredentialsMode(providerId) + ? CLIENT_CREDENTIALS_MODE_REQUIRED_FIELDS[providerId] + : undefined +} + /** * The required create-body fields a submission is missing: none when it - * completes the provider's keyless alternative, otherwise the provider's own - * required fields that are empty. + * completes the provider's keyless alternative, the client-credentials fields + * when it selects that mode, otherwise the provider's own required fields that + * are empty. */ export function getMissingServiceAccountFields( providerId: string | null | undefined, @@ -114,5 +147,8 @@ export function getMissingServiceAccountFields( ): ServiceAccountFieldId[] { const keyless = getKeylessServiceAccountFields(providerId) if (keyless?.every((field) => provided[field])) return [] - return getServiceAccountRequiredFields(providerId).filter((field) => !provided[field]) + const required = + getClientCredentialsModeFields(providerId, provided.authMethod) ?? + getServiceAccountRequiredFields(providerId) + return required.filter((field) => !provided[field]) } diff --git a/apps/sim/lib/credentials/service-account-secret.test.ts b/apps/sim/lib/credentials/service-account-secret.test.ts index 4cdd2f13815..cedf046e89d 100644 --- a/apps/sim/lib/credentials/service-account-secret.test.ts +++ b/apps/sim/lib/credentials/service-account-secret.test.ts @@ -1,4 +1,8 @@ import { encryptionMock, encryptionMockFns } from '@sim/testing/mocks/encryption.mock' +import { + inputValidationMock, + inputValidationMockFns, +} from '@sim/testing/mocks/input-validation.mock' import { beforeEach, describe, expect, it, vi } from 'vitest' const { @@ -16,9 +20,34 @@ const { })) vi.mock('@/lib/core/security/encryption', () => encryptionMock) +vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock) +/** One stored token per application, served until it is invalidated, as the coordinated store does. */ +const storedCoordinatedTokens = vi.hoisted(() => new Map()) +vi.mock('@/lib/credentials/coordinated-client-token', () => ({ + resolveCoordinatedClientToken: async (input: { + namespace: string + application: readonly string[] + exchange: () => Promise<{ accessToken: string; expiresAt: Date }> + }) => { + const key = `${input.namespace}:${input.application.join(':')}` + const stored = storedCoordinatedTokens.get(key) + if (stored) return { accessToken: stored, expiresInSeconds: 86_000 } + const exchanged = await input.exchange() + storedCoordinatedTokens.set(key, exchanged.accessToken) + return { accessToken: exchanged.accessToken, expiresInSeconds: 86_000 } + }, + invalidateCoordinatedClientToken: async (accessToken: string) => { + for (const [key, stored] of storedCoordinatedTokens) { + if (stored === accessToken) storedCoordinatedTokens.delete(key) + } + }, +})) const mockEncryptSecret = encryptionMockFns.mockEncryptSecret vi.mock('@/lib/webhooks/providers/slack', () => ({ fetchSlackTeamId: mockFetchSlackTeamId })) -vi.mock('@/lib/credentials/atlassian-service-account', () => ({ +vi.mock('@/lib/credentials/atlassian-service-account', async (importOriginal) => ({ + AtlassianValidationError: ( + await importOriginal() + ).AtlassianValidationError, validateAtlassianServiceAccount: mockValidateAtlassian, normalizeAtlassianDomain: mockNormalizeDomain, })) @@ -51,11 +80,13 @@ vi.mock('@/lib/credentials/client-credential-accounts/server', () => ({ providerId === 'box-service-account' || providerId === 'netsuite-service-account' || providerId === 'oracle-epm-service-account' || - providerId === 'oracle-fusion-service-account' + providerId === 'oracle-fusion-service-account' || + providerId === 'zendesk-service-account' ? mockClientCredentialMinter : undefined, })) +import { AtlassianValidationError } from '@/lib/credentials/atlassian-service-account' import { ServiceAccountSecretError, verifyAndBuildServiceAccountSecret, @@ -65,6 +96,10 @@ import { SLACK_CUSTOM_BOT_PROVIDER_ID, } from '@/lib/oauth/types' +beforeEach(() => { + storedCoordinatedTokens.clear() +}) + describe('verifyAndBuildServiceAccountSecret', () => { beforeEach(() => { mockEncryptSecret.mockImplementation(async (value: string) => ({ encrypted: value })) @@ -87,6 +122,20 @@ describe('verifyAndBuildServiceAccountSecret', () => { }) }) + it.each([ + ['an expiring bot token', 'xoxe.xoxb-1-abc'], + ['a refresh token', 'xoxe-1-abc'], + ])('refuses %s from token rotation, which Sim cannot refresh', async (_case, botToken) => { + mockFetchSlackTeamId.mockResolvedValue({ teamId: 'T1', userId: 'U_BOT', teamName: 'Acme' }) + + await expect( + verifyAndBuildServiceAccountSecret(SLACK_CUSTOM_BOT_PROVIDER_ID, { + signingSecret: 'sec', + botToken, + }) + ).rejects.toThrow('token rotation') + }) + it('verifies an Atlassian token and encrypts the blob', async () => { mockValidateAtlassian.mockResolvedValue({ accountId: 'acc-1', @@ -95,10 +144,11 @@ describe('verifyAndBuildServiceAccountSecret', () => { emailAddress: 'bot@acme.com', }) const result = await verifyAndBuildServiceAccountSecret(ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID, { - apiToken: 'tok', + apiToken: ' tok\n', domain: 'Acme.atlassian.net', atlassianProduct: 'confluence', }) + expect(mockValidateAtlassian).toHaveBeenCalledWith('tok', 'acme.atlassian.net', 'confluence') const blob = JSON.parse(result.encryptedServiceAccountKey) expect(blob).toMatchObject({ apiToken: 'tok', @@ -108,6 +158,106 @@ describe('verifyAndBuildServiceAccountSecret', () => { }) }) + it('stores an Atlassian OAuth 2.0 credential, not its minted token, after proving it', async () => { + const fetchMock = vi.fn() + vi.stubGlobal('fetch', fetchMock) + fetchMock.mockResolvedValue(Response.json({ access_token: 'atl-minted', expires_in: 3600 })) + mockValidateAtlassian.mockResolvedValue({ + accountId: 'svc-1', + displayName: 'Workflow bot', + cloudId: 'cloud-1', + }) + + const result = await verifyAndBuildServiceAccountSecret(ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID, { + authMethod: 'client_credentials', + clientId: 'atl-client', + clientSecret: 'atl-secret', + domain: 'Acme.atlassian.net', + atlassianProduct: 'confluence', + }) + + expect(mockValidateAtlassian).toHaveBeenCalledWith( + 'atl-minted', + 'acme.atlassian.net', + 'confluence' + ) + const blob = JSON.parse(result.encryptedServiceAccountKey) + expect(blob).toMatchObject({ + type: 'atlassian_service_account', + authMethod: 'client_credentials', + clientId: 'atl-client', + clientSecret: 'atl-secret', + domain: 'acme.atlassian.net', + cloudId: 'cloud-1', + atlassianProduct: 'confluence', + atlassianAccountId: 'svc-1', + }) + expect(blob.apiToken).toBeUndefined() + expect(result.encryptedServiceAccountKey).not.toContain('atl-minted') + }) + + it('replaces a shared Atlassian token the site rejects, once', async () => { + const fetchMock = vi.fn() + vi.stubGlobal('fetch', fetchMock) + fetchMock.mockImplementation(async () => + Response.json({ access_token: `atl-${fetchMock.mock.calls.length}`, expires_in: 3600 }) + ) + mockValidateAtlassian.mockImplementation(async (token: string) => { + if (token !== 'atl-2') throw new AtlassianValidationError('invalid_credentials', 401) + return { accountId: 'svc-1', displayName: 'Workflow bot', cloudId: 'cloud-1' } + }) + const fields = { + authMethod: 'client_credentials', + clientId: 'atl-client', + clientSecret: 'atl-secret', + domain: 'acme.atlassian.net', + } + + await expect( + verifyAndBuildServiceAccountSecret(ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID, fields) + ).resolves.toMatchObject({ displayName: 'Workflow bot' }) + + mockValidateAtlassian.mockRejectedValue( + new AtlassianValidationError('invalid_credentials', 401) + ) + await expect( + verifyAndBuildServiceAccountSecret(ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID, fields) + ).rejects.toMatchObject({ code: 'invalid_credentials' }) + expect(fetchMock).toHaveBeenCalledTimes(3) + }) + + it('does not mint again when the Atlassian site is unavailable', async () => { + const fetchMock = vi.fn() + vi.stubGlobal('fetch', fetchMock) + fetchMock.mockResolvedValue(Response.json({ access_token: 'atl-minted', expires_in: 3600 })) + mockValidateAtlassian.mockRejectedValue( + new AtlassianValidationError('atlassian_unavailable', 503) + ) + + await expect( + verifyAndBuildServiceAccountSecret(ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID, { + authMethod: 'client_credentials', + clientId: 'atl-client', + clientSecret: 'atl-secret', + domain: 'acme.atlassian.net', + }) + ).rejects.toMatchObject({ code: 'atlassian_unavailable' }) + expect(fetchMock).toHaveBeenCalledOnce() + }) + + it('requires the Atlassian client ID, secret, and site without minting', async () => { + const fetchMock = vi.fn() + vi.stubGlobal('fetch', fetchMock) + await expect( + verifyAndBuildServiceAccountSecret(ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID, { + authMethod: 'client_credentials', + clientId: 'atl-client', + domain: 'acme.atlassian.net', + }) + ).rejects.toBeInstanceOf(ServiceAccountSecretError) + expect(fetchMock).not.toHaveBeenCalled() + }) + it('accepts a legacy Google create with an empty providerId', async () => { const json = JSON.stringify({ type: 'service_account', @@ -266,6 +416,53 @@ describe('verifyAndBuildServiceAccountSecret', () => { }) }) + it('stores only the Zendesk sign-in the selected method uses', async () => { + mockClientCredentialMinter.mockResolvedValue({ + accessToken: 'encoded', + expiresInSeconds: 3600, + authStyle: 'basic', + domain: 'acme', + identity: { + displayName: 'Zendesk acme', + principal: { kind: 'user', id: '42' }, + auditMetadata: {}, + }, + }) + const submitted = { + orgId: 'acme', + clientId: 'leftover-client', + clientSecret: 'leftover-secret', + scope: 'read', + username: ' agent@example.com ', + apiToken: ' zendesk-api-token ', + } + + const apiToken = await verifyAndBuildServiceAccountSecret('zendesk-service-account', { + ...submitted, + authMethod: 'api_token', + }) + const tokenBlob = JSON.parse(apiToken.encryptedServiceAccountKey) + expect(tokenBlob).toMatchObject({ + authMethod: 'api_token', + orgId: 'acme', + username: 'agent@example.com', + apiToken: 'zendesk-api-token', + }) + expect(tokenBlob.clientSecret).toBeUndefined() + expect(tokenBlob.scope).toBeUndefined() + + const client = await verifyAndBuildServiceAccountSecret('zendesk-service-account', submitted) + const clientBlob = JSON.parse(client.encryptedServiceAccountKey) + expect(clientBlob).toMatchObject({ + authMethod: 'client_credentials', + clientId: 'leftover-client', + clientSecret: 'leftover-secret', + scope: 'read', + }) + expect(clientBlob.apiToken).toBeUndefined() + expect(clientBlob.username).toBeUndefined() + }) + it('threads NetSuite certificate material into the minter and encrypted blob', async () => { mockClientCredentialMinter.mockResolvedValue({ accessToken: 'minted', @@ -490,3 +687,147 @@ describe('verifyAndBuildServiceAccountSecret', () => { } }) }) + +describe('a token provider in client-credentials mode', () => { + const fetchMock = vi.fn() + + beforeEach(() => { + fetchMock.mockReset() + vi.stubGlobal('fetch', fetchMock) + mockEncryptSecret.mockImplementation(async (value: string) => ({ encrypted: value })) + }) + + function answerShopify() { + fetchMock.mockImplementation(async (input, init) => { + const request = new Request(input, init) + if (request.url.endsWith('/admin/oauth/access_token')) { + return Response.json({ access_token: 'minted-token', expires_in: 86399 }) + } + expect(request.headers.get('x-shopify-access-token')).toBe('minted-token') + return Response.json({ + data: { shop: { name: 'Acme', myshopifyDomain: 'acme.myshopify.com' } }, + }) + }) + } + + it('stores the client credentials, never a token, after proving the minted token', async () => { + answerShopify() + + const result = await verifyAndBuildServiceAccountSecret('shopify-service-account', { + authMethod: 'client_credentials', + clientId: 'client-1', + clientSecret: 'secret-1', + domain: 'Acme.myshopify.com', + }) + + const blob = JSON.parse(result.encryptedServiceAccountKey) + expect(blob).toMatchObject({ + type: 'token_service_account', + providerId: 'shopify-service-account', + authMethod: 'client_credentials', + clientId: 'client-1', + clientSecret: 'secret-1', + domain: 'acme.myshopify.com', + }) + expect(blob.apiToken).toBeUndefined() + expect(result.encryptedServiceAccountKey).not.toContain('minted-token') + expect(result.principal).toMatchObject({ kind: 'tenant', id: 'acme.myshopify.com' }) + }) + + it('keeps a pasted Shopify token on the token path when no auth method is sent', async () => { + fetchMock.mockResolvedValue( + Response.json({ data: { shop: { name: 'Acme', myshopifyDomain: 'acme.myshopify.com' } } }) + ) + + const result = await verifyAndBuildServiceAccountSecret('shopify-service-account', { + apiToken: ' shpat_pasted\n', + domain: 'acme.myshopify.com', + clientId: 'ignored', + clientSecret: 'ignored', + }) + + const blob = JSON.parse(result.encryptedServiceAccountKey) + expect(blob.apiToken).toBe('shpat_pasted') + expect(blob.authMethod).toBeUndefined() + expect(blob.clientSecret).toBeUndefined() + expect(fetchMock).toHaveBeenCalledOnce() + }) + + it('requires the client ID, secret, and store domain without calling Shopify', async () => { + await expect( + verifyAndBuildServiceAccountSecret('shopify-service-account', { + authMethod: 'client_credentials', + clientId: 'client-1', + domain: 'acme.myshopify.com', + }) + ).rejects.toBeInstanceOf(ServiceAccountSecretError) + await expect( + verifyAndBuildServiceAccountSecret('shopify-service-account', { + authMethod: 'client_credentials', + clientId: 'client-1', + clientSecret: 'secret-1', + }) + ).rejects.toBeInstanceOf(ServiceAccountSecretError) + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('keeps the token path for a provider without a client-credentials mode', async () => { + await expect( + verifyAndBuildServiceAccountSecret('hubspot-service-account', { + authMethod: 'client_credentials', + clientId: 'client-1', + clientSecret: 'secret-1', + }) + ).rejects.toThrow('apiToken is required') + expect(fetchMock).not.toHaveBeenCalled() + }) +}) + +describe('a ServiceNow login', () => { + const secureFetch = inputValidationMockFns.mockSecureFetchWithPinnedIP + + beforeEach(() => { + mockEncryptSecret.mockImplementation(async (value: string) => ({ encrypted: value })) + inputValidationMockFns.mockValidateUrlWithDNS.mockReset().mockResolvedValue({ + isValid: true, + resolvedIP: '203.0.113.7', + originalHostname: 'acme.service-now.com', + }) + secureFetch.mockReset().mockResolvedValue( + new Response(JSON.stringify({ result: [{ sys_id: 'abc' }] }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ) + }) + + it('verifies and stores a password with surrounding spaces exactly as typed', async () => { + const result = await verifyAndBuildServiceAccountSecret('servicenow-service-account', { + apiToken: ' pass word ', + providerWorkspaceId: 'integration.user', + domain: 'acme.service-now.com', + }) + + expect(secureFetch).toHaveBeenCalledWith( + expect.any(String), + '203.0.113.7', + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: `Basic ${Buffer.from('integration.user: pass word ').toString('base64')}`, + }), + }) + ) + expect(JSON.parse(result.encryptedServiceAccountKey).apiToken).toBe(' pass word ') + }) + + it('refuses a blank password without calling ServiceNow', async () => { + await expect( + verifyAndBuildServiceAccountSecret('servicenow-service-account', { + apiToken: ' ', + providerWorkspaceId: 'integration.user', + domain: 'acme.service-now.com', + }) + ).rejects.toBeInstanceOf(ServiceAccountSecretError) + expect(secureFetch).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/credentials/service-account-secret.ts b/apps/sim/lib/credentials/service-account-secret.ts index dcd62f7ea20..0bb97d88280 100644 --- a/apps/sim/lib/credentials/service-account-secret.ts +++ b/apps/sim/lib/credentials/service-account-secret.ts @@ -2,7 +2,9 @@ import { getErrorMessage } from '@sim/utils/errors' import { serviceAccountJsonSchema } from '@/lib/api/contracts/credentials' import { getValidationErrorMessage } from '@/lib/api/server' import { encryptSecret } from '@/lib/core/security/encryption' +import { mintAtlassianServiceAccountToken } from '@/lib/credentials/atlassian-client-credentials' import { + AtlassianValidationError, normalizeAtlassianDomain, validateAtlassianServiceAccount, } from '@/lib/credentials/atlassian-service-account' @@ -23,6 +25,7 @@ import { type ClientCredentialAccountSecretBlob, getClientCredentialAccountMinter, } from '@/lib/credentials/client-credential-accounts/server' +import { invalidateCoordinatedClientToken } from '@/lib/credentials/coordinated-client-token' import { DatabricksCredentialError, verifyAndEncryptDatabricksCredential, @@ -48,15 +51,21 @@ import { SnowflakeCredentialError, verifyAndEncryptSnowflakeKeylessCredential, } from '@/lib/credentials/snowflake-service-account.server' +import { getTokenClientCredentialMinter } from '@/lib/credentials/token-service-accounts/client-credentials' import { + CLIENT_CREDENTIALS_AUTH_METHOD, getTokenServiceAccountDescriptor, isTokenServiceAccountProviderId, SNOWFLAKE_SERVICE_ACCOUNT_PROVIDER_ID, TOKEN_SERVICE_ACCOUNT_SECRET_TYPE, + type TokenServiceAccountDescriptor, } from '@/lib/credentials/token-service-accounts/descriptors' +import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors' import { getTokenServiceAccountValidator, + type TokenServiceAccountClientCredentialBlob, type TokenServiceAccountSecretBlob, + type TokenServiceAccountValidator, } from '@/lib/credentials/token-service-accounts/server' import { KeylessCredentialRefusedError, @@ -143,7 +152,11 @@ export class ServiceAccountSecretError extends Error { async function buildAtlassianServiceAccountSecret( fields: ServiceAccountSecretFields ): Promise { - const { apiToken, domain } = fields + if (fields.authMethod?.trim() === CLIENT_CREDENTIALS_AUTH_METHOD) { + return buildAtlassianClientCredentialSecret(fields) + } + const apiToken = fields.apiToken?.trim() + const { domain } = fields if (!apiToken || !domain) { throw new ServiceAccountSecretError( 'apiToken and domain are required for Atlassian service account credentials' @@ -182,6 +195,83 @@ async function buildAtlassianServiceAccountSecret( } } +/** + * Proves a client-credentials token with the provider's own identity check. Minted tokens are + * shared across every credential for one application, so the token served can have been revoked + * before its stated expiry (an app reinstall, a scope change). When the provider rejects it, that + * token is expired and one freshly minted token is proven instead, so saving or reconnecting the + * credential always recovers from a revoked token. + */ +async function proveMintedToken( + mint: () => Promise<{ accessToken: string }>, + prove: (accessToken: string) => Promise +): Promise { + const { accessToken } = await mint() + try { + return await prove(accessToken) + } catch (error) { + const rejected = + (error instanceof TokenServiceAccountValidationError || + error instanceof AtlassianValidationError) && + error.code === 'invalid_credentials' + if (!rejected) throw error + await invalidateCoordinatedClientToken(accessToken) + return prove((await mint()).accessToken) + } +} + +/** + * Builds an Atlassian service-account secret for an OAuth 2.0 credential: mints a token with the + * client ID and secret, proves it against the product's identity endpoint exactly as a scoped + * API token is proven, and stores the client credentials so execution re-mints. + */ +async function buildAtlassianClientCredentialSecret( + fields: ServiceAccountSecretFields +): Promise { + const clientId = fields.clientId?.trim() + const clientSecret = fields.clientSecret?.trim() + const domain = fields.domain?.trim() + if (!clientId || !clientSecret || !domain) { + throw new ServiceAccountSecretError( + 'clientId, clientSecret, and domain are required for Atlassian OAuth 2.0 credentials' + ) + } + const product = fields.atlassianProduct ?? 'jira' + const normalizedDomain = normalizeAtlassianDomain(domain) + const validation = await proveMintedToken( + () => mintAtlassianServiceAccountToken(clientId, clientSecret), + (accessToken) => validateAtlassianServiceAccount(accessToken, normalizedDomain, product) + ) + const principal: ServiceAccountPrincipal = { + kind: 'user', + id: validation.accountId, + ...(validation.emailAddress ? { label: validation.emailAddress } : {}), + } + const blob = JSON.stringify({ + type: ATLASSIAN_SERVICE_ACCOUNT_SECRET_TYPE, + authMethod: CLIENT_CREDENTIALS_AUTH_METHOD, + clientId, + clientSecret, + domain: normalizedDomain, + cloudId: validation.cloudId, + atlassianProduct: product, + atlassianAccountId: validation.accountId, + metadata: serviceAccountPrincipalMetadata(principal), + }) + const { encrypted } = await encryptSecret(blob) + return { + providerId: ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID, + encryptedServiceAccountKey: encrypted, + displayName: validation.displayName, + auditMetadata: { + atlassianDomain: normalizedDomain, + atlassianCloudId: validation.cloudId, + ...serviceAccountPrincipalMetadata(principal), + }, + principal, + } +} + /** * Builds a custom Slack bot secret. The workspace/team identity is derived via * `auth.test` and never trusted from the client. @@ -195,6 +285,14 @@ async function buildSlackCustomBotSecret( 'signingSecret and botToken are required for a custom Slack bot credential' ) } + // Rotation-enabled apps issue `xoxe.`-prefixed access tokens that expire in 12 hours and + // `xoxe-` refresh tokens; refreshing needs the app's client ID and secret, which Sim + // never holds, so such a token would pass auth.test today and fail every run tomorrow. + if (/^\s*xoxe[.-]/.test(botToken)) { + throw new ServiceAccountSecretError( + 'This Slack app has token rotation turned on, so its tokens expire within 12 hours and Sim cannot refresh them. Use the Bot User OAuth Token (xoxb-) of a Slack app with token rotation off.' + ) + } let teamId: string let botUserId: string | undefined let teamName: string | undefined @@ -471,12 +569,25 @@ async function buildTokenServiceAccountSecret( `No validator registered for service-account provider ${providerId}` ) } - const apiToken = fields.apiToken?.trim() + if ( + descriptor.clientCredentials && + fields.authMethod?.trim() === CLIENT_CREDENTIALS_AUTH_METHOD + ) { + return buildTokenClientCredentialSecret(descriptor, validator, fields) + } + const apiToken = descriptor.fields.find((field) => field.id === 'apiToken')?.preserveWhitespace + ? fields.apiToken + : fields.apiToken?.trim() const domain = fields.domain?.trim() const requiresDomain = descriptor.fields.some((field) => field.id === 'domain') const providerWorkspaceId = fields.providerWorkspaceId?.trim() const requiresWorkspace = descriptor.fields.some((field) => field.id === 'providerWorkspaceId') - if (!apiToken || (requiresDomain && !domain) || (requiresWorkspace && !providerWorkspaceId)) { + if ( + !apiToken || + !apiToken.trim() || + (requiresDomain && !domain) || + (requiresWorkspace && !providerWorkspaceId) + ) { const required = descriptor.fields.map((field) => field.id).join(' and ') throw new ServiceAccountSecretError( `${required} ${descriptor.fields.length > 1 ? 'are' : 'is'} required for ${descriptor.serviceLabel} service account credentials` @@ -504,6 +615,61 @@ async function buildTokenServiceAccountSecret( } } +/** + * Builds a token provider's client-credentials secret: mints a token with the submitted client + * ID and secret, proves it with the provider's own token check (the same request the pasted-token + * mode verifies with), and stores the client credentials instead of a token so execution re-mints. + */ +async function buildTokenClientCredentialSecret( + descriptor: TokenServiceAccountDescriptor, + validator: TokenServiceAccountValidator, + fields: ServiceAccountSecretFields +): Promise { + const { providerId } = descriptor + const mint = getTokenClientCredentialMinter(providerId) + if (!mint) { + throw new ServiceAccountSecretError( + `No client-credentials grant registered for service-account provider ${providerId}` + ) + } + const clientId = fields.clientId?.trim() + const clientSecret = fields.clientSecret?.trim() + const domain = fields.domain?.trim() + const requiresDomain = descriptor.fields.some((field) => field.id === 'domain') + if (!clientId || !clientSecret || (requiresDomain && !domain)) { + throw new ServiceAccountSecretError( + `clientId, clientSecret${requiresDomain ? ', and domain are' : ' are'} required for ${descriptor.serviceLabel} client credentials` + ) + } + const validation = await proveMintedToken( + () => mint({ clientId, clientSecret, ...(requiresDomain ? { domain } : {}) }), + (accessToken) => + validator({ + apiToken: accessToken, + ...(requiresDomain ? { domain } : {}), + authMethod: CLIENT_CREDENTIALS_AUTH_METHOD, + }) + ) + const principalMetadata = serviceAccountPrincipalMetadata(validation.principal) + const blob: TokenServiceAccountClientCredentialBlob = { + type: TOKEN_SERVICE_ACCOUNT_SECRET_TYPE, + providerId, + authMethod: CLIENT_CREDENTIALS_AUTH_METHOD, + clientId, + clientSecret, + ...(requiresDomain ? { domain: validation.normalizedDomain ?? domain } : {}), + metadata: { ...validation.storedMetadata, ...principalMetadata }, + } + const { encrypted } = await encryptSecret(JSON.stringify(blob)) + return { + providerId, + encryptedServiceAccountKey: encrypted, + displayName: validation.displayName, + auditMetadata: { ...validation.auditMetadata, ...principalMetadata }, + principal: validation.principal, + } +} + /** * Builds a Snowflake secret. A JSON configuration is the keyless mode (no * stored secret); otherwise the pasted programmatic access token goes through @@ -583,6 +749,7 @@ async function buildClientCredentialAccountSecret( : undefined, privateKey: usesField('privateKey') ? fields.privateKey?.trim() || undefined : undefined, username: usesField('username') ? fields.username?.trim() || undefined : undefined, + apiToken: usesField('apiToken') ? fields.apiToken?.trim() || undefined : undefined, } // Requirements are per-auth-method, not per-provider: Salesforce's JWT diff --git a/apps/sim/lib/credentials/token-service-accounts/client-credentials.ts b/apps/sim/lib/credentials/token-service-accounts/client-credentials.ts new file mode 100644 index 00000000000..b4ed87ae696 --- /dev/null +++ b/apps/sim/lib/credentials/token-service-accounts/client-credentials.ts @@ -0,0 +1,48 @@ +import { + isTokenServiceAccountProviderId, + LINEAR_SERVICE_ACCOUNT_PROVIDER_ID, + SHOPIFY_SERVICE_ACCOUNT_PROVIDER_ID, + type TokenServiceAccountProviderId, +} from '@/lib/credentials/token-service-accounts/descriptors' +import { mintLinearClientCredentialsToken } from '@/lib/credentials/token-service-accounts/grants/linear' +import { mintShopifyClientCredentialsToken } from '@/lib/credentials/token-service-accounts/grants/shopify' + +/** What a token provider's client-credentials grant needs to mint an access token. */ +export interface TokenClientCredentialFields { + clientId: string + clientSecret: string + /** The normalized account host, for providers whose token endpoint is per account (Shopify). */ + domain?: string +} + +export interface TokenClientCredentialMint { + accessToken: string + expiresInSeconds: number +} + +type TokenClientCredentialMinter = ( + fields: TokenClientCredentialFields, + options?: { signal?: AbortSignal } +) => Promise + +/** + * Client-credentials grants offered beside the pasted token, for token providers whose + * descriptor declares `clientCredentials`. The pasted-token mode is unchanged; a credential uses + * the grant only when it was saved with `authMethod: 'client_credentials'`. Every minter keeps + * one token per provider application across all workers itself (see + * `resolveCoordinatedClientToken`), so its tokens are never cached per instance. + */ +const TOKEN_CLIENT_CREDENTIAL_MINTERS: Partial< + Record +> = { + [SHOPIFY_SERVICE_ACCOUNT_PROVIDER_ID]: mintShopifyClientCredentialsToken, + [LINEAR_SERVICE_ACCOUNT_PROVIDER_ID]: mintLinearClientCredentialsToken, +} + +export function getTokenClientCredentialMinter( + providerId: string +): TokenClientCredentialMinter | undefined { + return isTokenServiceAccountProviderId(providerId) + ? TOKEN_CLIENT_CREDENTIAL_MINTERS[providerId] + : undefined +} diff --git a/apps/sim/lib/credentials/token-service-accounts/descriptors.ts b/apps/sim/lib/credentials/token-service-accounts/descriptors.ts index ebd5a56b5d7..0113b9495d5 100644 --- a/apps/sim/lib/credentials/token-service-accounts/descriptors.ts +++ b/apps/sim/lib/credentials/token-service-accounts/descriptors.ts @@ -24,6 +24,8 @@ export interface TokenServiceAccountField { placeholder: string /** Rendered with SecretInput and never echoed back. */ secret: boolean + /** Preserve exact password bytes instead of normalizing surrounding whitespace. */ + preserveWhitespace?: boolean /** * The value is a URL whose scheme and path matter (a self-hosted instance or * deployment URL), so the connect form sends it as typed and the provider's @@ -67,8 +69,38 @@ export interface TokenServiceAccountDescriptor { * token route surfaces this so tool header builders can switch schemes. */ authStyle?: 'bearer' | 'x-api-token' + /** + * A client-credentials grant the provider offers beside the pasted token. A credential saved + * with `authMethod: 'client_credentials'` stores the client ID and secret (and `domain`, when + * the descriptor has one) and mints a short-lived token at execution time; a credential saved + * without it keeps the pasted-token mode unchanged. + */ + clientCredentials?: TokenServiceAccountClientCredentials +} + +/** Copy for a token provider's client-credentials mode in the connect modal. */ +interface TokenServiceAccountClientCredentials { + /** Authentication dropdown label for the client-credentials mode. */ + label: string + /** Authentication dropdown label for the existing pasted-token mode. */ + tokenLabel: string + /** Guidance shown under the client ID field. */ + helpText: string + /** Replaces the generic `invalid_credentials` copy for this mode. */ + invalidCredentialsHelp: string + /** Copy for `grant_not_permitted`, when the provider can refuse the grant for one account. */ + notPermittedHelp?: string + /** + * Preselects this mode for a new connection, for providers whose current setup no longer + * issues a pasted token. A reconnect preselects nothing; the admin restates the mode while + * retyping the secret. + */ + preferred?: boolean } +/** `authMethod` value selecting the client-credentials mode of a provider whose default is a pasted token (a token provider, or the Atlassian service account). */ +export const CLIENT_CREDENTIALS_AUTH_METHOD = 'client_credentials' as const + export const HUBSPOT_SERVICE_ACCOUNT_PROVIDER_ID = 'hubspot-service-account' as const export const AIRTABLE_SERVICE_ACCOUNT_PROVIDER_ID = 'airtable-service-account' as const export const NOTION_SERVICE_ACCOUNT_PROVIDER_ID = 'notion-service-account' as const @@ -92,6 +124,9 @@ export const CODA_SERVICE_ACCOUNT_PROVIDER_ID = 'coda-service-account' as const export const GAUGE_SERVICE_ACCOUNT_PROVIDER_ID = 'gauge-service-account' as const export const HIGGSFIELD_SERVICE_ACCOUNT_PROVIDER_ID = 'higgsfield-service-account' as const export const THRIVE_SERVICE_ACCOUNT_PROVIDER_ID = 'thrive-service-account' as const +export const PLANETSCALE_SERVICE_ACCOUNT_PROVIDER_ID = 'planetscale-service-account' as const +export const GONG_SERVICE_ACCOUNT_PROVIDER_ID = 'gong-service-account' as const +export const SERVICENOW_SERVICE_ACCOUNT_PROVIDER_ID = 'servicenow-service-account' as const export const SUPABASE_SERVICE_ACCOUNT_PROVIDER_ID = 'supabase-service-account' as const export const INFISICAL_SERVICE_ACCOUNT_PROVIDER_ID = 'infisical-service-account' as const export const OKTA_SERVICE_ACCOUNT_PROVIDER_ID = 'okta-service-account' as const @@ -99,6 +134,14 @@ export const DEVIN_SERVICE_ACCOUNT_PROVIDER_ID = 'devin-service-account' as cons export const EMAILBISON_SERVICE_ACCOUNT_PROVIDER_ID = 'emailbison-service-account' as const export const UPSTASH_SERVICE_ACCOUNT_PROVIDER_ID = 'upstash-service-account' as const export const ALGOLIA_SERVICE_ACCOUNT_PROVIDER_ID = 'algolia-service-account' as const +/** + * The Twilio credential's `apiToken` holds either the account auth token or an API key pair + * (`SKxxx:secret`), deliberately in one field: the pair is exactly the Basic username and password + * Twilio takes for a key, so every surface (saved credential, inline block field, API) carries it + * with no extra field. Build request auth only with `twilioAuthorization`; a consumer that needs + * the account auth token itself (webhook signature HMAC) must refuse a value `isTwilioApiKeyPair` + * matches, since an API key secret cannot sign. + */ export const TWILIO_SERVICE_ACCOUNT_PROVIDER_ID = 'twilio-service-account' as const export const AZURE_DEVOPS_SERVICE_ACCOUNT_PROVIDER_ID = 'azure-devops-service-account' as const export const PLANE_SERVICE_ACCOUNT_PROVIDER_ID = 'plane-service-account' as const @@ -221,6 +264,9 @@ export type TokenServiceAccountProviderId = | typeof GAUGE_SERVICE_ACCOUNT_PROVIDER_ID | typeof HIGGSFIELD_SERVICE_ACCOUNT_PROVIDER_ID | typeof THRIVE_SERVICE_ACCOUNT_PROVIDER_ID + | typeof PLANETSCALE_SERVICE_ACCOUNT_PROVIDER_ID + | typeof GONG_SERVICE_ACCOUNT_PROVIDER_ID + | typeof SERVICENOW_SERVICE_ACCOUNT_PROVIDER_ID | typeof SUPABASE_SERVICE_ACCOUNT_PROVIDER_ID | typeof INFISICAL_SERVICE_ACCOUNT_PROVIDER_ID | typeof OKTA_SERVICE_ACCOUNT_PROVIDER_ID @@ -327,21 +373,21 @@ export const TOKEN_SERVICE_ACCOUNT_DESCRIPTORS: Record< [HUBSPOT_SERVICE_ACCOUNT_PROVIDER_ID]: { providerId: HUBSPOT_SERVICE_ACCOUNT_PROVIDER_ID, serviceLabel: 'HubSpot', - tokenNoun: 'private app access token', - connectNoun: 'private app token', + tokenNoun: 'service key or private app access token', + connectNoun: 'service key', fields: [ { id: 'apiToken', - label: 'Private app access token', + label: 'Service key or private app token', placeholder: 'pat-na1-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx', secret: true, hintPattern: /^pat-/i, - hintMessage: 'HubSpot private app tokens usually start with pat-.', + hintMessage: 'HubSpot service keys and private app tokens usually start with pat-.', }, ], docsUrl: 'https://docs.sim.ai/integrations/hubspot-service-account', helpText: - 'Tokens are tied to the super admin who created the private app; if that user is removed from the portal, some calls may start failing.', + 'Create a service key in HubSpot under Development > Keys > Service keys (or Settings > Integrations > Service keys, depending on your account); new legacy private apps can no longer be created, but existing private app tokens keep working. If the key rotates automatically, paste the new key here after each rotation.', }, [AIRTABLE_SERVICE_ACCOUNT_PROVIDER_ID]: { providerId: AIRTABLE_SERVICE_ACCOUNT_PROVIDER_ID, @@ -463,6 +509,14 @@ export const TOKEN_SERVICE_ACCOUNT_DESCRIPTORS: Record< }, ], docsUrl: 'https://docs.sim.ai/integrations/linear-service-account', + clientCredentials: { + label: 'OAuth app (client credentials)', + tokenLabel: 'Personal API key (lin_api_)', + helpText: + 'Turn on client credentials tokens for the OAuth app in Linear first, and use an OAuth app only Sim uses: requesting a token replaces an existing authorization of this app in the workspace, a client-credentials request for it with other scopes revokes Sim’s tokens, and rotating the client secret invalidates its tokens until you reconnect here.', + invalidCredentialsHelp: + 'Linear rejected these client credentials. Check the client ID and secret, and that client credentials tokens are turned on for the OAuth app.', + }, }, [MONDAY_SERVICE_ACCOUNT_PROVIDER_ID]: { providerId: MONDAY_SERVICE_ACCOUNT_PROVIDER_ID, @@ -506,9 +560,20 @@ export const TOKEN_SERVICE_ACCOUNT_DESCRIPTORS: Record< ], docsUrl: 'https://docs.sim.ai/integrations/shopify-service-account', helpText: - 'The token is revealed once, is bound to a single store, and does not expire. Dev Dashboard apps issue tokens through OAuth rather than a UI reveal.', + 'Only custom apps created in the Shopify admin reveal this token. For an app created in the Dev Dashboard, choose Client credentials instead.', invalidCredentialsHelp: 'Shopify rejected this token. Make sure you copied the Admin API access token (starts with shpat_) — not the API key or API secret key — for an app installed on this exact store domain, and that it has not since been revoked or regenerated.', + clientCredentials: { + preferred: true, + label: 'Client credentials (Dev Dashboard app)', + tokenLabel: 'Admin API access token (shpat_)', + helpText: + 'Use the client ID and secret from the Dev Dashboard app. The app must be installed on this store, and the app and store must belong to the same Shopify organization.', + invalidCredentialsHelp: + 'Shopify rejected these client credentials. Check that the client ID and secret belong to a Dev Dashboard app that is installed on this exact store domain.', + notPermittedHelp: + 'Shopify does not allow client credentials for this store. Client credentials only work when the app and the store belong to the same Shopify organization, so check that this store is part of the organization that owns the app.', + }, }, [WEBFLOW_SERVICE_ACCOUNT_PROVIDER_ID]: { providerId: WEBFLOW_SERVICE_ACCOUNT_PROVIDER_ID, @@ -526,6 +591,8 @@ export const TOKEN_SERVICE_ACCOUNT_DESCRIPTORS: Record< docsUrl: 'https://docs.sim.ai/integrations/webflow-service-account', helpText: 'Site tokens expire after 365 days without API activity, and each token grants access to a single site.', + invalidCredentialsHelp: + 'Webflow rejected this token, or it reaches no site. Generate a site API token in the site settings with at least the sites:read scope.', }, [TRELLO_SERVICE_ACCOUNT_PROVIDER_ID]: { providerId: TRELLO_SERVICE_ACCOUNT_PROVIDER_ID, @@ -1866,7 +1933,7 @@ export const TOKEN_SERVICE_ACCOUNT_DESCRIPTORS: Record< { id: 'apiToken', label: 'Access token', - placeholder: 'Paste a workspace, project, or repository access token', + placeholder: 'Paste an access token or a Bitbucket API token', secret: true, }, { @@ -1881,9 +1948,9 @@ export const TOKEN_SERVICE_ACCOUNT_DESCRIPTORS: Record< ], docsUrl: 'https://docs.sim.ai/integrations/bitbucket-service-account', helpText: - 'Use a Bitbucket Cloud workspace, project, or repository access token with at least the repository scope. Enter any repository the token can read, as workspace/repository; Sim reads it once to verify the token.', + 'Use a Bitbucket Cloud workspace, project, or repository access token with at least the repository scope, or an Atlassian API token created for Bitbucket with at least repository read (app passwords no longer work). Enter any repository the token can read, as workspace/repository; Sim reads it once to verify the token.', invalidCredentialsHelp: - 'Bitbucket rejected this token. Check that it has not expired or been revoked, has the repository scope, and can read the repository you entered.', + 'Bitbucket rejected this token. Check that it has not expired or been revoked, has repository read access, and can read the repository you entered. App passwords no longer work; use an access token or an API token created for Bitbucket.', }, [GRAFANA_SERVICE_ACCOUNT_PROVIDER_ID]: { providerId: GRAFANA_SERVICE_ACCOUNT_PROVIDER_ID, @@ -2136,9 +2203,11 @@ export const TOKEN_SERVICE_ACCOUNT_DESCRIPTORS: Record< fields: [ { id: 'apiToken', - label: 'Auth token', - placeholder: 'Paste your Twilio auth token', + label: 'Auth token or API key', + placeholder: 'Paste your auth token, or SKxxx:secret for an API key', secret: true, + hintPattern: /^(?!SK)|^SK[0-9a-fA-F]{32}:\S+$/, + hintMessage: 'Enter an API key as its SID and secret joined by a colon: SKxxx:secret.', }, { id: 'providerWorkspaceId', @@ -2151,9 +2220,9 @@ export const TOKEN_SERVICE_ACCOUNT_DESCRIPTORS: Record< ], docsUrl: 'https://docs.sim.ai/integrations/twilio_sms', helpText: - 'Copy the Account SID and Auth Token from Account Info on your Twilio Console dashboard.', + 'Copy the Account SID and Auth Token from Account Info on your Twilio Console dashboard. To use an API key instead, enter its SID and secret joined by a colon (SKxxx:secret); a Standard or Main key works, and a restricted key needs read access to phone numbers.', invalidCredentialsHelp: - 'Twilio rejected this Account SID and auth token. Check that the token belongs to this account and has not been rotated.', + 'Twilio rejected this Account SID and auth token or API key. Check that the token or key belongs to this account and has not been rotated or deleted, and that an API key is entered as SKxxx:secret.', }, [ALGOLIA_SERVICE_ACCOUNT_PROVIDER_ID]: { providerId: ALGOLIA_SERVICE_ACCOUNT_PROVIDER_ID, @@ -2300,7 +2369,7 @@ export const TOKEN_SERVICE_ACCOUNT_DESCRIPTORS: Record< ], docsUrl: 'https://docs.sim.ai/integrations/infisical', helpText: - 'Use https://us.infisical.com for US Cloud, https://eu.infisical.com for EU Cloud, or your self-hosted URL. Universal Auth tokens expire with their TTL; a Token Auth token with a long TTL keeps runs working.', + 'Use https://us.infisical.com for US Cloud, https://eu.infisical.com for EU Cloud, or your self-hosted URL. Sim does not renew the token, so it stops working when its Access Token TTL runs out: set a long Access Token TTL and Max TTL on the identity, and leave Access Token Period unset, since a periodic token expires unless it is renewed.', invalidCredentialsHelp: 'Paste a machine identity access token, not a client secret or service token, and make sure the instance URL matches the region the identity lives in.', }, @@ -2340,8 +2409,110 @@ export const TOKEN_SERVICE_ACCOUNT_DESCRIPTORS: Record< invalidCredentialsHelp: 'Thrive rejected this API key. Check that the tenant ID matches the key and that the region host is the one your tenant uses.', }, + [PLANETSCALE_SERVICE_ACCOUNT_PROVIDER_ID]: { + providerId: PLANETSCALE_SERVICE_ACCOUNT_PROVIDER_ID, + serviceLabel: 'PlanetScale', + tokenNoun: 'service token', + connectNoun: 'service token', + fields: [ + { + id: 'apiToken', + label: 'Service token', + placeholder: 'Paste your PlanetScale service token', + secret: true, + }, + { + id: 'providerWorkspaceId', + label: 'Service token ID', + placeholder: 'Paste the service token ID', + secret: false, + }, + ], + docsUrl: 'https://docs.sim.ai/integrations/planetscale', + helpText: + "Create a service token on your PlanetScale organization's Service tokens page, copy its ID and token, and grant it access to the organization and databases your workflows use.", + invalidCredentialsHelp: + 'PlanetScale rejected this service token. Check that the token ID and token belong together and that the token has not been deleted or expired.', + }, + [GONG_SERVICE_ACCOUNT_PROVIDER_ID]: { + providerId: GONG_SERVICE_ACCOUNT_PROVIDER_ID, + serviceLabel: 'Gong', + tokenNoun: 'access key secret', + connectNoun: 'API key', + fields: [ + { + id: 'apiToken', + label: 'Access key secret', + placeholder: 'Paste your Gong access key secret', + secret: true, + }, + { + id: 'providerWorkspaceId', + label: 'Access key', + placeholder: 'Paste your Gong access key', + secret: false, + }, + ], + docsUrl: 'https://docs.sim.ai/integrations/gong', + helpText: + 'A Gong technical administrator creates the access key and secret on the API page of the Gong company settings. Sim calls https://api.gong.io; an account whose API page shows a different base URL is not supported yet.', + invalidCredentialsHelp: + 'Gong rejected this access key and secret. Check that they were created together, have not been revoked, and that your Gong plan includes API access. If the API page in Gong shows a base URL other than https://api.gong.io, that account is not supported yet.', + }, + [SERVICENOW_SERVICE_ACCOUNT_PROVIDER_ID]: { + providerId: SERVICENOW_SERVICE_ACCOUNT_PROVIDER_ID, + serviceLabel: 'ServiceNow', + tokenNoun: 'password', + connectNoun: 'login', + fields: [ + { + id: 'apiToken', + label: 'Password', + placeholder: 'Enter the ServiceNow user password', + secret: true, + preserveWhitespace: true, + }, + { + id: 'providerWorkspaceId', + label: 'Username', + placeholder: 'Enter the ServiceNow username', + secret: false, + }, + { + id: 'domain', + label: 'Instance URL', + placeholder: 'yourcompany.service-now.com', + secret: false, + }, + ], + docsUrl: 'https://docs.sim.ai/integrations/servicenow', + helpText: + "Use a dedicated integration user with the roles your workflows need and without multi-factor authentication. Enter your instance's https URL, like acme.service-now.com or a custom instance URL. The instance must be reachable from the internet.", + invalidCredentialsHelp: + 'ServiceNow rejected this username and password. Check the instance URL, and that the user is active, not locked out, and not required to use multi-factor authentication.', + }, } +/** + * Required contract fields per token provider in client-credentials mode: the client ID and + * secret replace the token, and every other descriptor field (a store domain) stays required. + */ +export const TOKEN_SERVICE_ACCOUNT_CLIENT_CREDENTIAL_FIELDS: Record< + string, + ReadonlyArray<'clientId' | 'clientSecret' | Exclude> +> = Object.fromEntries( + Object.values(TOKEN_SERVICE_ACCOUNT_DESCRIPTORS) + .filter((descriptor) => descriptor.clientCredentials) + .map((descriptor) => [ + descriptor.providerId, + [ + 'clientId', + 'clientSecret', + ...descriptor.fields.flatMap((field) => (field.id === 'apiToken' ? [] : [field.id])), + ], + ]) +) + /** * Required contract fields per token service-account provider, consumed by the * `createCredentialBodySchema` superRefine so validation errors name the exact @@ -2379,14 +2550,23 @@ export function getTokenServiceAccountDescriptor( export function getTokenServiceAccountErrorMessage( descriptor: TokenServiceAccountDescriptor, code: string | undefined, - fallbackMessage = "We couldn't add this credential. Try again in a moment." + fallbackMessage = "We couldn't add this credential. Try again in a moment.", + authMethod?: string ): string { + const clientCredentials = + authMethod === CLIENT_CREDENTIALS_AUTH_METHOD ? descriptor.clientCredentials : undefined switch (code) { case 'invalid_credentials': return ( + clientCredentials?.invalidCredentialsHelp ?? descriptor.invalidCredentialsHelp ?? `We couldn't authenticate with that ${descriptor.tokenNoun}. Double-check it in ${descriptor.serviceLabel} and try again.` ) + case 'grant_not_permitted': + return ( + clientCredentials?.notPermittedHelp ?? + `${descriptor.serviceLabel} does not allow these client credentials for this account.` + ) case 'site_not_found': return "We couldn't find an account at that domain. Check the spelling and try again." case 'host_not_allowed': diff --git a/apps/sim/lib/credentials/token-service-accounts/errors.ts b/apps/sim/lib/credentials/token-service-accounts/errors.ts index 79e4d985f33..bf2209dfa75 100644 --- a/apps/sim/lib/credentials/token-service-accounts/errors.ts +++ b/apps/sim/lib/credentials/token-service-accounts/errors.ts @@ -26,6 +26,12 @@ export type TokenServiceAccountValidationCode = * asked for a Google Workspace service or to impersonate a Google Workspace user (domain-wide delegation). */ | 'unsupported_use' + /** + * The provider refused this grant for the account the credential names, although the client + * itself may be valid: Shopify answers `shop_not_permitted` when the app and the store are not + * in the same Shopify organization. + */ + | 'grant_not_permitted' export class TokenServiceAccountValidationError extends Error { constructor( @@ -63,6 +69,29 @@ export function requireClientSecret( return clientSecret } +const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/ +const CONTROL_CHARACTERS_OR_SPACE = /[\u0000- \u007f]/ + +/** + * Refuses a credential value that is empty, longer than `maxBytes` in UTF-8, or holds a control + * character (and, with `forbidSpaces`, a space), any of which would corrupt or be cut from the + * header the value is sent in, as `invalid_credentials` naming the field. + */ +export function assertCredentialText( + value: string, + label: string, + step: string, + options: { maxBytes: number; forbidSpaces?: boolean } +): void { + const forbidden = options.forbidSpaces ? CONTROL_CHARACTERS_OR_SPACE : CONTROL_CHARACTERS + if (!value || forbidden.test(value) || Buffer.byteLength(value, 'utf8') > options.maxBytes) { + throw new TokenServiceAccountValidationError('invalid_credentials', 400, { + step, + reason: `${label} is invalid`, + }) + } +} + /** * Short, stable description of a failed best-effort provider call, for callers * that degrade instead of throwing. `TokenServiceAccountValidationError`'s @@ -111,13 +140,6 @@ export interface FetchProviderOptions { dnsFailureReason?: string } -/** - * Fetches a provider verification endpoint, mapping network-level failures - * (DNS, TLS, connection reset) to `provider_unavailable` so they never escape - * as raw undici errors — whose `cause` can carry connection details — and are - * never blamed on the pasted token. DNS-resolution failures can optionally be - * mapped to a different code via {@link FetchProviderOptions}. - */ const PROVIDER_FETCH_TIMEOUT_MS = 10_000 /** @@ -130,6 +152,18 @@ export function isHostNotFound(error: unknown): boolean { return failure?.code === 'ENOTFOUND' || failure?.cause?.code === 'ENOTFOUND' } +/** + * Fetches a provider verification endpoint, mapping network-level failures + * (DNS, TLS, connection reset) to `provider_unavailable` so they never escape + * as raw undici errors — whose `cause` can carry connection details — and are + * never blamed on the pasted token. DNS-resolution failures can optionally be + * mapped to a different code via {@link FetchProviderOptions}. + * + * Redirects are refused (`redirect: 'error'`) unless `init` names another mode: + * every caller sends a secret, and fetch replays custom headers and 307/308 + * bodies to whatever host a redirect names. A refused redirect is a network + * failure, so it surfaces as `provider_unavailable`. + */ export async function fetchProvider( url: string, init: RequestInit, @@ -139,7 +173,7 @@ export async function fetchProvider( try { const timeout = AbortSignal.timeout(PROVIDER_FETCH_TIMEOUT_MS) const signal = init.signal ? AbortSignal.any([init.signal, timeout]) : timeout - return await fetch(url, { ...init, signal }) + return await fetch(url, { ...init, redirect: init.redirect ?? 'error', signal }) } catch (error) { init.signal?.throwIfAborted() // Only ENOTFOUND proves the host doesn't exist; EAI_AGAIN is a transient @@ -184,6 +218,14 @@ export interface ProviderResponse { body: ReadableStream | null } +/** Whether a 403 is the vendor's own JSON answer; an HTML one is a firewall or CDN page. */ +export function isVendorJsonForbidden(response: ProviderResponse): boolean { + return ( + response.status === 403 && + (response.headers.get('content-type') ?? '').includes('application/json') + ) +} + /** * Reads a bounded snippet of a provider error body for server logs. Never * throws — an unreadable body logs as an empty string. diff --git a/apps/sim/lib/credentials/token-service-accounts/grants/linear.test.ts b/apps/sim/lib/credentials/token-service-accounts/grants/linear.test.ts new file mode 100644 index 00000000000..9a797558cb7 --- /dev/null +++ b/apps/sim/lib/credentials/token-service-accounts/grants/linear.test.ts @@ -0,0 +1,84 @@ +import { jsonResponse } from '@sim/testing/helpers/http' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { coordinated } = vi.hoisted(() => ({ coordinated: vi.fn() })) + +vi.mock('@/lib/credentials/coordinated-client-token', () => ({ + resolveCoordinatedClientToken: coordinated, +})) + +import { mintLinearClientCredentialsToken } from '@/lib/credentials/token-service-accounts/grants/linear' + +const FIELDS = { clientId: 'linear-client', clientSecret: 'linear-secret' } +const mockFetch = vi.fn() + +describe('Linear client credentials grant', () => { + beforeEach(() => { + mockFetch.mockReset() + vi.stubGlobal('fetch', mockFetch) + coordinated.mockReset() + coordinated.mockImplementation( + async (input: { exchange: () => Promise<{ accessToken: string; expiresAt: Date }> }) => { + const exchanged = await input.exchange() + return { + accessToken: exchanged.accessToken, + expiresInSeconds: Math.floor((exchanged.expiresAt.getTime() - Date.now()) / 1000), + } + } + ) + }) + + it('requests an app token with the one fixed scope set every credential shares', async () => { + mockFetch.mockImplementation(async (input, init) => { + const request = new Request(input, init) + expect(request.url).toBe('https://api.linear.app/oauth/token') + expect(request.method).toBe('POST') + expect(request.redirect).toBe('error') + expect(request.headers.get('content-type')).toBe('application/x-www-form-urlencoded') + expect([...new URLSearchParams(await request.text())]).toEqual([ + ['grant_type', 'client_credentials'], + ['client_id', 'linear-client'], + ['client_secret', 'linear-secret'], + ['scope', 'read,write'], + ]) + return jsonResponse({ + access_token: 'lin_oauth_1', + token_type: 'Bearer', + expires_in: 2_591_999, + scope: ['read', 'write'], + }) + }) + + const minted = await mintLinearClientCredentialsToken(FIELDS) + + expect(minted.accessToken).toBe('lin_oauth_1') + }) + + it('shares one token per app across credentials and replaces it daily', async () => { + mockFetch.mockResolvedValue( + jsonResponse({ access_token: 'lin_oauth_1', expires_in: 2_591_999 }) + ) + + const minted = await mintLinearClientCredentialsToken({ + ...FIELDS, + clientId: ' linear-client ', + }) + + expect(minted.expiresInSeconds).toBeGreaterThan(86_000) + expect(minted.expiresInSeconds).toBeLessThanOrEqual(86_400) + expect(coordinated).toHaveBeenCalledWith( + expect.objectContaining({ + namespace: 'linear', + application: ['linear-client'], + clientSecret: 'linear-secret', + }) + ) + }) + + it('never sends a request without a client secret', async () => { + await expect( + mintLinearClientCredentialsToken({ ...FIELDS, clientSecret: '' }) + ).rejects.toMatchObject({ code: 'invalid_credentials' }) + expect(mockFetch).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/credentials/token-service-accounts/grants/linear.ts b/apps/sim/lib/credentials/token-service-accounts/grants/linear.ts new file mode 100644 index 00000000000..07011c6c39c --- /dev/null +++ b/apps/sim/lib/credentials/token-service-accounts/grants/linear.ts @@ -0,0 +1,64 @@ +import { exchangeClientCredentialsGrant } from '@/lib/credentials/client-credentials-grant' +import { resolveCoordinatedClientToken } from '@/lib/credentials/coordinated-client-token' +import type { + TokenClientCredentialFields, + TokenClientCredentialMint, +} from '@/lib/credentials/token-service-accounts/client-credentials' +import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors' + +const LINEAR_TOKEN_URL = 'https://api.linear.app/oauth/token' +const STEP = 'linear_token_mint' +/** + * Every Sim credential requests exactly these scopes: Linear replaces an app's existing + * client-credentials tokens in a workspace when a request asks for different scopes, so two + * credentials for one app must never disagree. They match the scopes Linear's tools use. + */ +const LINEAR_CLIENT_CREDENTIAL_SCOPE = 'read,write' +/** + * Linear documents a 30-day lifetime for client-credentials tokens, but a token can also be + * revoked early (cleanup, another scope request) without every API answer saying so with a 401, so + * the shared token is replaced daily. Same-scope tokens coexist, so the replacement leaves the + * previous token valid for calls already using it. + */ +const SERVED_TOKEN_LIFETIME_SECONDS = 24 * 60 * 60 + +/** + * Mints an app-actor token for a Linear OAuth app with client credentials turned on. Linear asks + * clients to reuse one token and to request a new one after a 401, and cleans up an app's surplus + * tokens past 1,000 per workspace, so every saved credential for one app shares a single + * coordinated token, replaced daily. A token Linear rejects early with a 401 is expired by the + * tool-call path and replaced on the next call; a rotated secret never matches the stored token. + * + * @see https://linear.app/developers/oauth-2-0-authentication + */ +export async function mintLinearClientCredentialsToken( + fields: TokenClientCredentialFields, + options?: { signal?: AbortSignal } +): Promise { + options?.signal?.throwIfAborted() + const clientId = fields.clientId.trim() + if (!clientId || !fields.clientSecret) { + throw new TokenServiceAccountValidationError('invalid_credentials', 400, { + step: STEP, + reason: 'client ID and client secret are required', + }) + } + return resolveCoordinatedClientToken({ + namespace: 'linear', + application: [clientId], + clientSecret: fields.clientSecret, + step: STEP, + exchange: (signal) => + exchangeClientCredentialsGrant({ + url: LINEAR_TOKEN_URL, + clientId, + clientSecret: fields.clientSecret, + extraFields: { scope: LINEAR_CLIENT_CREDENTIAL_SCOPE }, + step: STEP, + label: 'Linear token response', + maxLifetimeSeconds: SERVED_TOKEN_LIFETIME_SECONDS, + signal, + }), + signal: options?.signal, + }) +} diff --git a/apps/sim/lib/credentials/token-service-accounts/grants/shopify.test.ts b/apps/sim/lib/credentials/token-service-accounts/grants/shopify.test.ts new file mode 100644 index 00000000000..fd6258b031e --- /dev/null +++ b/apps/sim/lib/credentials/token-service-accounts/grants/shopify.test.ts @@ -0,0 +1,121 @@ +import { jsonResponse } from '@sim/testing/helpers/http' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { coordinated } = vi.hoisted(() => ({ coordinated: vi.fn() })) + +vi.mock('@/lib/credentials/coordinated-client-token', () => ({ + resolveCoordinatedClientToken: coordinated, +})) + +import { mintShopifyClientCredentialsToken } from '@/lib/credentials/token-service-accounts/grants/shopify' + +const FIELDS = { + clientId: 'shopify-client', + clientSecret: 'shopify-secret', + domain: 'https://Acme-Store.myshopify.com/', +} + +const mockFetch = vi.fn() + +describe('Shopify client credentials grant', () => { + beforeEach(() => { + mockFetch.mockReset() + vi.stubGlobal('fetch', mockFetch) + coordinated.mockReset() + coordinated.mockImplementation( + async (input: { exchange: () => Promise<{ accessToken: string; expiresAt: Date }> }) => { + const exchanged = await input.exchange() + return { + accessToken: exchanged.accessToken, + expiresInSeconds: Math.floor((exchanged.expiresAt.getTime() - Date.now()) / 1000), + } + } + ) + }) + + it('exchanges the client credentials at the store token endpoint without a scope', async () => { + mockFetch.mockImplementation(async (input, init) => { + const request = new Request(input, init) + expect(request.url).toBe('https://acme-store.myshopify.com/admin/oauth/access_token') + expect(request.method).toBe('POST') + expect(request.redirect).toBe('error') + expect(request.headers.get('content-type')).toBe('application/x-www-form-urlencoded') + expect([...new URLSearchParams(await request.text())]).toEqual([ + ['grant_type', 'client_credentials'], + ['client_id', 'shopify-client'], + ['client_secret', 'shopify-secret'], + ]) + return jsonResponse({ access_token: 'shop-token', scope: 'read_products', expires_in: 86399 }) + }) + + const minted = await mintShopifyClientCredentialsToken(FIELDS) + + expect(minted.accessToken).toBe('shop-token') + expect(minted.expiresInSeconds).toBeGreaterThan(86_000) + expect(coordinated).toHaveBeenCalledWith( + expect.objectContaining({ + namespace: 'shopify', + application: ['acme-store.myshopify.com', 'shopify-client'], + }) + ) + }) + + it.each(['localhost', 'acme.myshopify.com.evil.example', '169.254.169.254', 'acme.example.com'])( + 'refuses %s before any request', + async (domain) => { + await expect(mintShopifyClientCredentialsToken({ ...FIELDS, domain })).rejects.toMatchObject({ + code: 'site_not_found', + status: 400, + }) + expect(mockFetch).not.toHaveBeenCalled() + expect(coordinated).not.toHaveBeenCalled() + } + ) + + it('reports shop_not_permitted as a refused grant rather than a bad secret', async () => { + mockFetch.mockResolvedValue( + jsonResponse( + { + error: 'shop_not_permitted', + error_description: 'Client credentials cannot be performed on this shop.', + }, + { status: 400 } + ) + ) + + await expect(mintShopifyClientCredentialsToken(FIELDS)).rejects.toMatchObject({ + code: 'grant_not_permitted', + }) + }) + + it('reports a store Shopify cannot find as a wrong domain, not a bad secret', async () => { + mockFetch.mockResolvedValue(jsonResponse({ error: 'not_found' }, { status: 404 })) + + await expect(mintShopifyClientCredentialsToken(FIELDS)).rejects.toMatchObject({ + code: 'site_not_found', + }) + }) + + it('reports any other refusal by its status', async () => { + mockFetch.mockResolvedValue(jsonResponse({ error: 'invalid_client' }, { status: 401 })) + + await expect(mintShopifyClientCredentialsToken(FIELDS)).rejects.toMatchObject({ + code: 'invalid_credentials', + }) + }) + + it('never keeps a token longer than the documented 24 hours', async () => { + mockFetch.mockResolvedValue(jsonResponse({ access_token: 'shop-token', expires_in: 9_999_999 })) + + const minted = await mintShopifyClientCredentialsToken(FIELDS) + + expect(minted.expiresInSeconds).toBeLessThanOrEqual(86_400) + }) + + it('never sends a request without a client secret', async () => { + await expect( + mintShopifyClientCredentialsToken({ ...FIELDS, clientSecret: '' }) + ).rejects.toMatchObject({ code: 'invalid_credentials' }) + expect(mockFetch).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/credentials/token-service-accounts/grants/shopify.ts b/apps/sim/lib/credentials/token-service-accounts/grants/shopify.ts new file mode 100644 index 00000000000..440cdb0e4da --- /dev/null +++ b/apps/sim/lib/credentials/token-service-accounts/grants/shopify.ts @@ -0,0 +1,90 @@ +import { exchangeClientCredentialsGrant } from '@/lib/credentials/client-credentials-grant' +import { resolveCoordinatedClientToken } from '@/lib/credentials/coordinated-client-token' +import type { + TokenClientCredentialFields, + TokenClientCredentialMint, +} from '@/lib/credentials/token-service-accounts/client-credentials' +import { + providerFailureCode, + TokenServiceAccountValidationError, +} from '@/lib/credentials/token-service-accounts/errors' +import { + normalizeShopifyDomain, + SHOPIFY_HOST_REGEX, +} from '@/lib/credentials/token-service-accounts/validators/shopify' + +const STEP = 'shopify_token_mint' +/** Shopify documents a fixed 24-hour lifetime (`expires_in` is always 86399). */ +const MAX_TOKEN_LIFETIME_SECONDS = 86_400 + +/** + * Shopify answers `shop_not_permitted` when the app and store are in different organizations, + * which no secret fixes, and 404 for a store domain that does not exist. + */ +function classifyShopifyRefusal(shop: string, status: number, body: string) { + if (/shop_not_permitted/i.test(body)) { + return new TokenServiceAccountValidationError('grant_not_permitted', status, { + step: STEP, + domain: shop, + reason: 'app and store are not in the same Shopify organization', + }) + } + return new TokenServiceAccountValidationError( + status === 404 ? 'site_not_found' : providerFailureCode(status), + status, + { step: STEP, domain: shop, reason: 'provider rejected token exchange' } + ) +} + +/** + * Mints an Admin API token for a Dev Dashboard app installed on a store in the same Shopify + * organization, through the client-credentials grant. Shopify documents a 24-hour lifetime but + * not whether a new token retires earlier ones, so every saved credential for one app and store + * shares a single coordinated token instead of minting per worker. + * + * @see https://shopify.dev/docs/apps/build/authentication-authorization/access-tokens/client-credentials-grant + */ +export async function mintShopifyClientCredentialsToken( + fields: TokenClientCredentialFields, + options?: { signal?: AbortSignal } +): Promise { + options?.signal?.throwIfAborted() + const shop = normalizeShopifyDomain(fields.domain ?? '') + if (!SHOPIFY_HOST_REGEX.test(shop)) { + throw new TokenServiceAccountValidationError('site_not_found', 400, { + step: 'host_validation', + domain: shop, + reason: 'host is not a Shopify store domain (expected *.myshopify.com)', + }) + } + const clientId = fields.clientId.trim() + if (!clientId || !fields.clientSecret) { + throw new TokenServiceAccountValidationError('invalid_credentials', 400, { + step: STEP, + reason: 'client ID and client secret are required', + }) + } + return resolveCoordinatedClientToken({ + namespace: 'shopify', + application: [shop, clientId], + clientSecret: fields.clientSecret, + step: STEP, + exchange: (signal) => + exchangeClientCredentialsGrant({ + // Shopify ignores `scope` on this grant; the token carries every scope the store granted. + url: `https://${shop}/admin/oauth/access_token`, + clientId, + clientSecret: fields.clientSecret, + step: STEP, + label: 'Shopify token response', + maxLifetimeSeconds: MAX_TOKEN_LIFETIME_SECONDS, + fetchOptions: { + dnsFailureCode: 'site_not_found', + dnsFailureReason: 'store domain does not resolve', + }, + classifyRefusal: (status, body) => classifyShopifyRefusal(shop, status, body), + signal, + }), + signal: options?.signal, + }) +} diff --git a/apps/sim/lib/credentials/token-service-accounts/server.ts b/apps/sim/lib/credentials/token-service-accounts/server.ts index e935ef4331c..af49d437e10 100644 --- a/apps/sim/lib/credentials/token-service-accounts/server.ts +++ b/apps/sim/lib/credentials/token-service-accounts/server.ts @@ -1,3 +1,4 @@ +import { isRecordLike } from '@sim/utils/object' import type { ServiceAccountPrincipal } from '@/lib/credentials/principal' import { AFFINITY_SERVICE_ACCOUNT_PROVIDER_ID, @@ -24,6 +25,7 @@ import { CLAUDE_PLATFORM_SERVICE_ACCOUNT_PROVIDER_ID, CLERK_SERVICE_ACCOUNT_PROVIDER_ID, CLICKUP_SERVICE_ACCOUNT_PROVIDER_ID, + CLIENT_CREDENTIALS_AUTH_METHOD, CLOUDFLARE_SERVICE_ACCOUNT_PROVIDER_ID, CODA_SERVICE_ACCOUNT_PROVIDER_ID, CONVEX_SERVICE_ACCOUNT_PROVIDER_ID, @@ -46,6 +48,7 @@ import { GAUGE_SERVICE_ACCOUNT_PROVIDER_ID, GITHUB_SERVICE_ACCOUNT_PROVIDER_ID, GITLAB_SERVICE_ACCOUNT_PROVIDER_ID, + GONG_SERVICE_ACCOUNT_PROVIDER_ID, GRAFANA_SERVICE_ACCOUNT_PROVIDER_ID, GRAIN_SERVICE_ACCOUNT_PROVIDER_ID, GRANOLA_SERVICE_ACCOUNT_PROVIDER_ID, @@ -81,6 +84,7 @@ import { PERSONA_SERVICE_ACCOUNT_PROVIDER_ID, PIPEDRIVE_SERVICE_ACCOUNT_PROVIDER_ID, PLANE_SERVICE_ACCOUNT_PROVIDER_ID, + PLANETSCALE_SERVICE_ACCOUNT_PROVIDER_ID, PROFOUND_SERVICE_ACCOUNT_PROVIDER_ID, PULSE_SERVICE_ACCOUNT_PROVIDER_ID, QUARTR_SERVICE_ACCOUNT_PROVIDER_ID, @@ -93,6 +97,7 @@ import { ROOTLY_SERVICE_ACCOUNT_PROVIDER_ID, SENDGRID_SERVICE_ACCOUNT_PROVIDER_ID, SENTRY_SERVICE_ACCOUNT_PROVIDER_ID, + SERVICENOW_SERVICE_ACCOUNT_PROVIDER_ID, SHOPIFY_SERVICE_ACCOUNT_PROVIDER_ID, SIMILARWEB_SERVICE_ACCOUNT_PROVIDER_ID, SIXTYFOUR_SERVICE_ACCOUNT_PROVIDER_ID, @@ -179,6 +184,7 @@ import { validateGammaServiceAccount, validateGitHubServiceAccount, validateGitLabServiceAccount, + validateGongServiceAccount, validateGrafanaServiceAccount, validateGrainServiceAccount, validateGranolaServiceAccount, @@ -206,6 +212,7 @@ import { validatePagerDutyServiceAccount, validatePersonaServiceAccount, validatePlaneServiceAccount, + validatePlanetScaleServiceAccount, validateProfoundServiceAccount, validatePulseServiceAccount, validateQuartrServiceAccount, @@ -218,6 +225,7 @@ import { validateRootlyServiceAccount, validateSendGridServiceAccount, validateSentryServiceAccount, + validateServiceNowServiceAccount, validateSimilarwebServiceAccount, validateSixtyfourServiceAccount, validateSmartleadServiceAccount, @@ -250,6 +258,12 @@ export interface TokenServiceAccountFields { apiToken: string domain?: string providerWorkspaceId?: string + /** + * `client_credentials` when `apiToken` was just minted from the provider's client-credentials + * grant rather than pasted, for checks whose identity differs between the two (a Linear app + * actor). Absent for a pasted token. + */ + authMethod?: typeof CLIENT_CREDENTIALS_AUTH_METHOD } /** Result of a successful provider verification. */ @@ -385,6 +399,7 @@ const TOKEN_SERVICE_ACCOUNT_VALIDATORS: Record< [FATHOM_SERVICE_ACCOUNT_PROVIDER_ID]: validateFathomServiceAccount, [GITHUB_SERVICE_ACCOUNT_PROVIDER_ID]: validateGitHubServiceAccount, [GITLAB_SERVICE_ACCOUNT_PROVIDER_ID]: validateGitLabServiceAccount, + [GONG_SERVICE_ACCOUNT_PROVIDER_ID]: validateGongServiceAccount, [GRAFANA_SERVICE_ACCOUNT_PROVIDER_ID]: validateGrafanaServiceAccount, [GRAIN_SERVICE_ACCOUNT_PROVIDER_ID]: validateGrainServiceAccount, [GRANOLA_SERVICE_ACCOUNT_PROVIDER_ID]: validateGranolaServiceAccount, @@ -398,9 +413,11 @@ const TOKEN_SERVICE_ACCOUNT_VALIDATORS: Record< [OKTA_SERVICE_ACCOUNT_PROVIDER_ID]: validateOktaServiceAccount, [PAGERDUTY_SERVICE_ACCOUNT_PROVIDER_ID]: validatePagerDutyServiceAccount, [PLANE_SERVICE_ACCOUNT_PROVIDER_ID]: validatePlaneServiceAccount, + [PLANETSCALE_SERVICE_ACCOUNT_PROVIDER_ID]: validatePlanetScaleServiceAccount, [RESEND_SERVICE_ACCOUNT_PROVIDER_ID]: validateResendServiceAccount, [ROOTLY_SERVICE_ACCOUNT_PROVIDER_ID]: validateRootlyServiceAccount, [SENTRY_SERVICE_ACCOUNT_PROVIDER_ID]: validateSentryServiceAccount, + [SERVICENOW_SERVICE_ACCOUNT_PROVIDER_ID]: validateServiceNowServiceAccount, [STRIPE_SERVICE_ACCOUNT_PROVIDER_ID]: validateStripeServiceAccount, [SUPABASE_SERVICE_ACCOUNT_PROVIDER_ID]: validateSupabaseServiceAccount, [TELEGRAM_SERVICE_ACCOUNT_PROVIDER_ID]: validateTelegramServiceAccount, @@ -435,17 +452,102 @@ export interface TokenServiceAccountSecretBlob { metadata?: Record } +/** + * Secret blob for a token provider's client-credentials mode: the client ID and secret it mints + * with replace the pasted token. Only providers declaring `clientCredentials` write it. + */ +export interface TokenServiceAccountClientCredentialBlob { + type: typeof TOKEN_SERVICE_ACCOUNT_SECRET_TYPE + providerId: string + authMethod: typeof CLIENT_CREDENTIALS_AUTH_METHOD + clientId: string + clientSecret: string + domain?: string + metadata?: Record +} + +/** + * Parses a pasted-token blob. A client-credentials blob has no token and is refused, so callers + * that only understand pasted tokens (Snowflake) fail closed on one. + */ export function parseTokenServiceAccountSecretBlob( decrypted: string, expectedProviderId: string ): TokenServiceAccountSecretBlob { - const parsed = JSON.parse(decrypted) as TokenServiceAccountSecretBlob + const parsed = parseTokenServiceAccountSecret(decrypted, expectedProviderId) + if ('authMethod' in parsed) { + throw new Error('Stored token service-account secret is malformed') + } + return parsed +} + +/** The credential a stored secret blob holds: a pasted token, or a client-credentials pair. */ +export type StoredSecretMode = + | { apiToken: string } + | { authMethod: typeof CLIENT_CREDENTIALS_AUTH_METHOD; clientId: string; clientSecret: string } + +/** + * Reads which credential a parsed secret blob was saved with: no `authMethod` means a pasted + * token, and the client-credentials method means a client ID and secret to mint with. Returns + * `undefined` for a blob missing the fields its mode needs, or naming another method. + */ +export function parseStoredSecretMode( + parsed: Record +): StoredSecretMode | undefined { + if (parsed.authMethod === undefined) { + return typeof parsed.apiToken === 'string' && parsed.apiToken + ? { apiToken: parsed.apiToken } + : undefined + } if ( + parsed.authMethod !== CLIENT_CREDENTIALS_AUTH_METHOD || + typeof parsed.clientId !== 'string' || + !parsed.clientId || + typeof parsed.clientSecret !== 'string' || + !parsed.clientSecret + ) { + return undefined + } + return { + authMethod: CLIENT_CREDENTIALS_AUTH_METHOD, + clientId: parsed.clientId, + clientSecret: parsed.clientSecret, + } +} + +/** Parses either mode of a token provider's secret blob, checking it belongs to the provider. */ +export function parseTokenServiceAccountSecret( + decrypted: string, + expectedProviderId: string +): TokenServiceAccountSecretBlob | TokenServiceAccountClientCredentialBlob { + const malformed = new Error('Stored token service-account secret is malformed') + const parsed: unknown = JSON.parse(decrypted) + if ( + !isRecordLike(parsed) || parsed.type !== TOKEN_SERVICE_ACCOUNT_SECRET_TYPE || - parsed.providerId !== expectedProviderId || - !parsed.apiToken + parsed.providerId !== expectedProviderId ) { - throw new Error('Stored token service-account secret is malformed') + throw malformed + } + const mode = parseStoredSecretMode(parsed) + if (!mode) throw malformed + const optional = { + ...(typeof parsed.domain === 'string' ? { domain: parsed.domain } : {}), + ...(isRecordLike(parsed.metadata) + ? { metadata: parsed.metadata as Record } + : {}), + } + const base: Pick = { + type: TOKEN_SERVICE_ACCOUNT_SECRET_TYPE, + providerId: expectedProviderId, + } + if ('authMethod' in mode) return { ...base, ...mode, ...optional } + return { + ...base, + ...mode, + ...(typeof parsed.providerWorkspaceId === 'string' + ? { providerWorkspaceId: parsed.providerWorkspaceId } + : {}), + ...optional, } - return parsed } diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/attio.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/attio.test.ts index e5d4b7fc24c..af8d963a7eb 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/attio.test.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/attio.test.ts @@ -42,6 +42,7 @@ describe('validateAttioServiceAccount', () => { Authorization: 'Bearer attio-token', Accept: 'application/json', }, + redirect: 'error', signal: expect.any(AbortSignal), }) }) diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/http.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/http.test.ts index 6fad7b593e9..cfc9e4a3c13 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/http.test.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/http.test.ts @@ -8,11 +8,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock) import { EgressRefusedError } from '@/lib/core/network/transport.server' -import { - normalizeHttpBaseUrl, - refuseUnsafeTypedUrl, - tokenCheckValidator, -} from '@/lib/credentials/token-service-accounts/validators/http' +import { tokenCheckValidator } from '@/lib/credentials/token-service-accounts/validators/http' const validate = tokenCheckValidator({ serviceLabel: 'Example', @@ -175,19 +171,6 @@ describe('tokenCheckValidator with a typed host', () => { ) }) - it('refuses a mistyped scheme instead of reading the scheme as a host', () => { - expect(() => normalizeHttpBaseUrl('https:/grafana.example.com')).toThrow(/scheme/) - expect(() => refuseUnsafeTypedUrl('https:/evil.com')).toThrow(/scheme/) - expect(() => refuseUnsafeTypedUrl('HTTP:evil.com')).toThrow(/scheme/) - // The URL parser drops tabs and newlines, so they must not hide a scheme from the guard. - expect(() => refuseUnsafeTypedUrl('ht\ntps:/evil.com')).toThrow(/scheme/) - expect(() => normalizeHttpBaseUrl('ht\ttps:/evil.com')).toThrow(/scheme/) - expect(refuseUnsafeTypedUrl('https://grafana.example.com')).toBe('https://grafana.example.com') - expect(normalizeHttpBaseUrl('grafana.example.com:3000/')).toBe( - 'https://grafana.example.com:3000' - ) - }) - it('refuses a host its normalizer rejects without sending the token anywhere', async () => { await expect( validateHost({ apiToken: 'good', domain: 'evil.example.com@tools.example.com' }) diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/http.ts b/apps/sim/lib/credentials/token-service-accounts/validators/http.ts index e31c2be069e..e9aabc970b7 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/http.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/http.ts @@ -9,6 +9,7 @@ import { readResponseJsonWithLimit } from '@/lib/core/utils/stream-limits' import { fetchProvider, isHostNotFound, + isVendorJsonForbidden, type ProviderResponse, providerFailureCode, TokenServiceAccountValidationError, @@ -99,47 +100,6 @@ export function readJsonBody(response: ProviderResponse, serviceLabel: string): }) } -/** - * Drops the tabs and newlines the URL parser ignores, then refuses `https:/host`, which - * would otherwise get a second scheme and parse as a host named `https`. - */ -function refuseMalformedScheme(typed: string): string { - const stripped = typed.replace(/[\t\n\r]/g, '') - if (/^https?:(?!\/\/)/i.test(stripped)) throw new Error('malformed scheme') - return stripped -} - -/** - * A typed http(s) base URL in the form tools build request paths on: a bare host - * is taken as https, trailing slashes are dropped, and any other scheme, - * credentials, a query, or a fragment is refused, since each would retarget the - * request. - */ -export function normalizeHttpBaseUrl(raw: string | undefined): string { - const trimmed = (raw ?? '').trim() - if (!trimmed) throw new Error('missing URL') - const typed = refuseMalformedScheme(trimmed) - const url = new URL(/^[a-z][a-z\d+.-]*:\/\//i.test(typed) ? typed : `https://${typed}`) - if (url.protocol !== 'https:' && url.protocol !== 'http:') throw new Error('http(s) only') - if (url.username || url.password || url.search || url.hash) { - throw new Error('credentials, query, or fragment in URL') - } - return `${url.origin}${url.pathname.replace(/\/+$/, '')}` -} - -/** - * Refuses a typed URL that carries credentials or a non-http(s) scheme, before a - * tool's own normalizer can drop the credentials silently or read the scheme as - * a host, and so verify a different identity or host than the user meant. - */ -export function refuseUnsafeTypedUrl(raw: string | undefined): string { - const typed = refuseMalformedScheme((raw ?? '').trim()) - if (/^(?!https?:\/\/)[a-z][a-z\d+.-]*:\/\//i.test(typed)) throw new Error('http(s) only') - const authority = typed.replace(/^[a-z][a-z\d+.-]*:\/\//i, '').split(/[/?#]/)[0] - if (authority.includes('@')) throw new Error('credentials in URL') - return typed -} - /** A domain that must be one of a fixed set (a region or regional host), compared case-insensitively. */ export function allowedDomain(allowed: ReadonlySet) { return ({ domain }: TokenServiceAccountFields): string => { @@ -258,11 +218,7 @@ export function tokenCheckValidator(check: TokenCheck): TokenServiceAccountValid ? undefined : { dnsFailureCode: 'site_not_found' } ) - // A JSON 403 is the vendor's own answer; an HTML one is a firewall or CDN page. - const scopedForbidden = - check.forbiddenMeansScoped && - response.status === 403 && - (response.headers.get('content-type') ?? '').includes('application/json') + const scopedForbidden = check.forbiddenMeansScoped && isVendorJsonForbidden(response) if ((check.rejectsKey || check.provesKey) && !response.ok) { const unreadable = Symbol('unreadable') const errorBody = await readJsonBody(response, check.serviceLabel).catch(() => unreadable) diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/hubspot.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/hubspot.test.ts index 53183f36ce8..85d9242d575 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/hubspot.test.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/hubspot.test.ts @@ -4,6 +4,7 @@ import { validateHubspotServiceAccount } from '@/lib/credentials/token-service-a const TOKEN_INFO_URL = 'https://api.hubapi.com/oauth/v2/private-apps/get/access-token-info' const ACCOUNT_INFO_URL = 'https://api.hubapi.com/account-info/v3/details' +const CONTACTS_URL = 'https://api.hubapi.com/crm/v3/objects/contacts?limit=1' const FIELDS = { apiToken: 'pat-na1-aaaa-bbbb' } @@ -40,6 +41,7 @@ function expectFallbackCall(): void { describe('validateHubspotServiceAccount', () => { beforeEach(() => { + mockFetch.mockReset() vi.stubGlobal('fetch', mockFetch) }) @@ -60,12 +62,15 @@ describe('validateHubspotServiceAccount', () => { expectFallbackCall() }) - it('throws invalid_credentials when primary 404 and fallback returns 401', async () => { + it('throws invalid_credentials when primary 404 and both fallbacks return 401', async () => { mockFetch .mockResolvedValueOnce(htmlResponse(404, '404 Not Found')) .mockResolvedValueOnce( jsonResponse({ status: 'error', category: 'INVALID_AUTHENTICATION' }, 401) ) + .mockResolvedValueOnce( + jsonResponse({ status: 'error', category: 'INVALID_AUTHENTICATION' }, 401) + ) await expect(validateHubspotServiceAccount(FIELDS)).rejects.toMatchObject({ name: 'TokenServiceAccountValidationError', @@ -82,22 +87,146 @@ describe('validateHubspotServiceAccount', () => { const result = await validateHubspotServiceAccount(FIELDS) expect(result).toEqual({ - displayName: 'HubSpot private app', + displayName: 'HubSpot', principal: null, auditMetadata: {}, }) }) - it('throws invalid_credentials on primary 401 without calling the fallback', async () => { - mockFetch.mockResolvedValueOnce( + it('verifies through account-info a key the token-info route answers with a 401', async () => { + mockFetch + .mockResolvedValueOnce( + jsonResponse({ status: 'error', category: 'INVALID_AUTHENTICATION' }, 401) + ) + .mockResolvedValueOnce(jsonResponse({ portalId: 456, uiDomain: 'app.hubspot.com' })) + + await expect(validateHubspotServiceAccount(FIELDS)).resolves.toMatchObject({ + displayName: 'HubSpot portal 456', + principal: { kind: 'tenant', id: '456' }, + }) + expectFallbackCall() + }) + + it('rejects a token token-info answers with a 401 when it is not a pat- key', async () => { + mockFetch + .mockResolvedValueOnce( + jsonResponse({ status: 'error', category: 'INVALID_AUTHENTICATION' }, 401) + ) + .mockResolvedValueOnce(jsonResponse({ portalId: 456, uiDomain: 'app.hubspot.com' })) + + await expect( + validateHubspotServiceAccount({ apiToken: 'oauth-access-token' }) + ).rejects.toMatchObject({ code: 'invalid_credentials', status: 401 }) + expect(mockFetch).toHaveBeenCalledTimes(1) + }) + + it('verifies through account-info a key token-info answers without a portal', async () => { + mockFetch + .mockResolvedValueOnce(jsonResponse({})) + .mockResolvedValueOnce(jsonResponse({ portalId: 789, uiDomain: 'app.hubspot.com' })) + + await expect(validateHubspotServiceAccount(FIELDS)).resolves.toMatchObject({ + displayName: 'HubSpot portal 789', + principal: { kind: 'tenant', id: '789' }, + }) + expectFallbackCall() + }) + + it('throws invalid_credentials when every route answers 401', async () => { + const unauthorized = () => jsonResponse({ status: 'error', category: 'INVALID_AUTHENTICATION' }, 401) - ) + mockFetch + .mockResolvedValueOnce(unauthorized()) + .mockResolvedValueOnce(unauthorized()) + .mockResolvedValueOnce(unauthorized()) await expect(validateHubspotServiceAccount(FIELDS)).rejects.toMatchObject({ name: 'TokenServiceAccountValidationError', code: 'invalid_credentials', status: 401, }) + const [url, init] = mockFetch.mock.calls[2] + expect(url).toBe(CONTACTS_URL) + expect(init.headers.Authorization).toBe('Bearer pat-na1-aaaa-bbbb') + }) + + it.each([ + ['reads contacts', () => jsonResponse({ results: [] })], + [ + 'lacks the contacts scope', + () => jsonResponse({ status: 'error', category: 'MISSING_SCOPES' }, 403), + ], + ])( + 'accepts a service key that account-info refuses but that %s', + async (_case, contactsAnswer) => { + mockFetch + .mockResolvedValueOnce(htmlResponse(404, '404 Not Found')) + .mockResolvedValueOnce( + jsonResponse({ status: 'error', category: 'INVALID_AUTHENTICATION' }, 401) + ) + .mockResolvedValueOnce(contactsAnswer()) + + await expect(validateHubspotServiceAccount(FIELDS)).resolves.toEqual({ + displayName: 'HubSpot', + principal: null, + auditMetadata: {}, + }) + expect(mockFetch.mock.calls[2][0]).toBe(CONTACTS_URL) + } + ) + + it('does not take a firewall 403 page from the contacts read as a live key', async () => { + mockFetch + .mockResolvedValueOnce(htmlResponse(404, '404 Not Found')) + .mockResolvedValueOnce( + jsonResponse({ status: 'error', category: 'INVALID_AUTHENTICATION' }, 401) + ) + .mockResolvedValueOnce( + new Response('Access denied', { + status: 403, + headers: { 'Content-Type': 'text/html' }, + }) + ) + + await expect(validateHubspotServiceAccount(FIELDS)).rejects.toMatchObject({ + code: 'invalid_credentials', + }) + }) + + it('reports an outage when the contacts read is unavailable', async () => { + mockFetch + .mockResolvedValueOnce(htmlResponse(404, '404 Not Found')) + .mockResolvedValueOnce( + jsonResponse({ status: 'error', category: 'INVALID_AUTHENTICATION' }, 401) + ) + .mockResolvedValueOnce(jsonResponse({ status: 'error' }, 503)) + + await expect(validateHubspotServiceAccount(FIELDS)).rejects.toMatchObject({ + code: 'provider_unavailable', + }) + }) + + it('does not take a firewall 403 page from account-info as a live token', async () => { + mockFetch + .mockResolvedValueOnce(htmlResponse(404, '404 Not Found')) + .mockResolvedValueOnce( + new Response('Access denied', { + status: 403, + headers: { 'Content-Type': 'text/html' }, + }) + ) + + await expect(validateHubspotServiceAccount(FIELDS)).rejects.toMatchObject({ + code: 'invalid_credentials', + }) + }) + + it('reports an outage without the fallback when token-info is unavailable', async () => { + mockFetch.mockResolvedValueOnce(jsonResponse({ status: 'error' }, 503)) + + await expect(validateHubspotServiceAccount(FIELDS)).rejects.toMatchObject({ + code: 'provider_unavailable', + }) expect(mockFetch).toHaveBeenCalledTimes(1) }) }) diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/hubspot.ts b/apps/sim/lib/credentials/token-service-accounts/validators/hubspot.ts index c22452e5b55..6fef19627d9 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/hubspot.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/hubspot.ts @@ -1,9 +1,9 @@ import { tenantPrincipal, userPrincipal } from '@/lib/credentials/principal' import { fetchProvider, + isVendorJsonForbidden, parseProviderJson, - readProviderErrorSnippet, - TokenServiceAccountValidationError, + providerFailureCode, throwForProviderResponse, } from '@/lib/credentials/token-service-accounts/errors' import type { @@ -13,6 +13,7 @@ import type { const TOKEN_INFO_URL = 'https://api.hubapi.com/oauth/v2/private-apps/get/access-token-info' const ACCOUNT_INFO_URL = 'https://api.hubapi.com/account-info/v3/details' +const CONTACTS_READ_URL = 'https://api.hubapi.com/crm/v3/objects/contacts?limit=1' interface HubspotTokenInfo { hubId?: number @@ -26,12 +27,32 @@ interface HubspotAccountInfo { } /** - * Fallback verification via the Account Information API. Live probing shows - * the documented access-token-info route returns a bare 404 for invalid - * tokens, which is ambiguous (invalid token vs. missing route), while this - * regular API route answers with a proper JSON 401 for bad tokens. A 200 - * proves the token is live; 401 means it was rejected; 403 means the token - * authenticated but the app lacks account-info access — still a live token. + * Last word on a key account-info refused: a one-contact read, the request + * HubSpot's service-key guide shows a key making. HubSpot answers a credential + * it does not accept with a 401 and one that lacks the object's scope with a + * 403, so a 200 or a JSON 403 proves the key is live. The route reports no + * portal, so the key is accepted without a principal. + */ +async function verifyViaContactsRead( + accessToken: string +): Promise { + const res = await fetchProvider( + CONTACTS_READ_URL, + { headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/json' } }, + 'contacts_read' + ) + if (!isVendorJsonForbidden(res)) await throwForProviderResponse(res, 'contacts_read') + await res.body?.cancel().catch(() => undefined) + return { displayName: 'HubSpot', principal: null, auditMetadata: {} } +} + +/** + * Fallback verification via the Account Information API, a regular REST route + * that reports the portal. A 200 proves the token is live; a JSON 403 means the + * token authenticated but lacks account-info access, still a live token. An + * HTML 403 is a firewall page, not HubSpot. HubSpot does not document how this + * route answers a service key, so its 401 is settled by + * {@link verifyViaContactsRead} rather than taken as a rejection. */ async function verifyViaAccountInfo( accessToken: string @@ -41,11 +62,16 @@ async function verifyViaAccountInfo( { headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/json' } }, 'account_info' ) - if (res.status === 403) { - // The token is live but the app cannot read account info, so neither the - // portal nor the creating user is knowable on this path. + if (res.status === 401) { + await res.body?.cancel().catch(() => undefined) + return verifyViaContactsRead(accessToken) + } + if (isVendorJsonForbidden(res)) { + // The token is live but cannot read account info, so neither the portal + // nor the creating user is knowable on this path. + await res.body?.cancel().catch(() => undefined) return { - displayName: 'HubSpot private app', + displayName: 'HubSpot', principal: null, auditMetadata: {}, } @@ -55,7 +81,7 @@ async function verifyViaAccountInfo( const info = await parseProviderJson(res, 'account_info') const hubId = typeof info?.portalId === 'number' ? String(info.portalId) : undefined return { - displayName: hubId ? `HubSpot portal ${hubId}` : 'HubSpot private app', + displayName: hubId ? `HubSpot portal ${hubId}` : 'HubSpot', // This route never reports the private app's creating user, so the portal // is the finest identity available here. principal: hubId ? tenantPrincipal(hubId) : null, @@ -63,21 +89,27 @@ async function verifyViaAccountInfo( } } +/** The documented prefix of HubSpot service keys and private app tokens. */ +const HUBSPOT_KEY_PREFIX = /^pat-/ + /** - * Validates a HubSpot private app access token. + * Validates a HubSpot service key or private app access token. * * Primary path: the documented access-token-info endpoint, sending both the * JSON body (`tokenKey`) and the `Authorization: Bearer` header — the header * is optional for NA (`pat-na1`) tokens but required for EU (`pat-eu1`) * tokens, so one code path covers both regions. * - * Live probing shows that endpoint returns a bare 404 (HTML) when the token - * is not recognized, so a 404 falls back to the Account Information API, - * which distinguishes a rejected token (JSON 401) from a live one (200/403). + * That endpoint is documented only for private app tokens, and live probing + * shows it returns a bare 404 (HTML) for a token it does not recognize. So a + * 400, 403 or 404, a 401 for a `pat-` key (which may be a service key), or a + * 200 without a portal falls back to the Account Information API, and a key + * that route refuses is settled by a contacts read, the route HubSpot documents + * for service keys. A token-info 401 for any other token is a rejection. * - * The display name is always `HubSpot portal {hubId}` — the account-info - * `uiDomain` is the shared regional host (e.g. `app.hubspot.com`), not a - * portal-specific name, so it is not used. + * The display name is `HubSpot portal {hubId}` when the portal is known — the + * account-info `uiDomain` is the shared regional host (e.g. `app.hubspot.com`), + * not a portal-specific name, so it is not used. */ export async function validateHubspotServiceAccount( fields: TokenServiceAccountFields @@ -96,23 +128,22 @@ export async function validateHubspotServiceAccount( }, 'access_token_info' ) - if (res.status === 404 || res.status === 400 || res.status === 403) { - // Ambiguous: HubSpot 404s unrecognized tokens on this route (and 400s - // malformed ones); a 403 here is unexpected since the route needs no - // scopes, so it also defers to the fallback rather than blaming the - // token. Resolve via a regular API route with JSON errors. - await readProviderErrorSnippet(res) + const inconclusive = + !res.ok && + providerFailureCode(res.status) === 'invalid_credentials' && + (res.status !== 401 || HUBSPOT_KEY_PREFIX.test(accessToken)) + if (inconclusive) { + // This route 404s tokens it does not recognize and covers only private + // apps, so a service key may be refused here yet work on every REST route. + // Resolve via a regular API route with JSON errors. + await res.body?.cancel().catch(() => undefined) return verifyViaAccountInfo(accessToken) } await throwForProviderResponse(res, 'access_token_info') const tokenInfo = await parseProviderJson(res, 'access_token_info') - if (typeof tokenInfo?.hubId !== 'number') { - throw new TokenServiceAccountValidationError('provider_unavailable', 502, { - step: 'access_token_info', - reason: 'missing hubId in response', - }) - } + // A 200 without a portal is not a private app's answer; let the REST route decide. + if (typeof tokenInfo?.hubId !== 'number') return verifyViaAccountInfo(accessToken) const hubId = String(tokenInfo.hubId) diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/linear.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/linear.test.ts index 23841e28ca0..27797b17f3e 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/linear.test.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/linear.test.ts @@ -103,4 +103,36 @@ describe('validateLinearServiceAccount', () => { status: 400, }) }) + + it('identifies a client-credentials app token by its workspace when Linear returns no viewer', async () => { + mockFetch.mockResolvedValueOnce( + jsonResponse( + { + data: { viewer: null, organization: { id: 'org-1', name: 'Acme' } }, + errors: [{ message: 'Forbidden', extensions: { code: 'FORBIDDEN' } }], + }, + 200 + ) + ) + + const result = await validateLinearServiceAccount({ + apiToken: 'lin_oauth_minted', + authMethod: 'client_credentials', + }) + + const [, init] = mockFetch.mock.calls[0] as [string, RequestInit] + expect((init.headers as Record).Authorization).toBe('Bearer lin_oauth_minted') + expect(result.principal).toEqual({ kind: 'tenant', id: 'org-1', label: 'Acme' }) + expect(result.storedMetadata).toEqual({ organizationId: 'org-1' }) + }) + + it('still refuses a pasted key that answers with no viewer', async () => { + mockFetch.mockResolvedValueOnce( + jsonResponse({ data: { viewer: null, organization: { id: 'org-1', name: 'Acme' } } }, 200) + ) + + await expect(validateLinearServiceAccount({ apiToken: 'lin_api_abc' })).rejects.toMatchObject({ + code: 'provider_unavailable', + }) + }) }) diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/linear.ts b/apps/sim/lib/credentials/token-service-accounts/validators/linear.ts index 46c98aef7b0..8eb355dbdab 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/linear.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/linear.ts @@ -1,4 +1,5 @@ -import { userPrincipal } from '@/lib/credentials/principal' +import { tenantPrincipal, userPrincipal } from '@/lib/credentials/principal' +import { CLIENT_CREDENTIALS_AUTH_METHOD } from '@/lib/credentials/token-service-accounts/descriptors' import { fetchProvider, parseProviderJson, @@ -81,6 +82,7 @@ export async function validateLinearServiceAccount( 'Content-Type': 'application/json', }, body: JSON.stringify({ query: VIEWER_QUERY }), + redirect: 'error', }, 'viewer' ) @@ -112,6 +114,19 @@ export async function validateLinearServiceAccount( } const payload = await parseProviderJson(res, 'viewer') + const organization = payload.data?.organization + // A client-credentials token acts as the app, not a person. Linear does not document whether an + // app-actor token has a viewer, so the workspace it was minted for is the identity when none + // comes back. + const appActor = fields.authMethod === CLIENT_CREDENTIALS_AUTH_METHOD + if (appActor && !payload.data?.viewer?.id && organization?.id) { + return { + displayName: organization.name || 'Linear workspace', + principal: tenantPrincipal(organization.id, organization.name), + auditMetadata: { linearOrganizationId: organization.id }, + storedMetadata: { organizationId: organization.id }, + } + } if (hasCredentialError(payload.errors)) { throw new TokenServiceAccountValidationError('invalid_credentials', res.status, { step: 'viewer', @@ -127,7 +142,6 @@ export async function validateLinearServiceAccount( }) } - const organization = payload.data?.organization const storedMetadata: Record = {} const auditMetadata: Record = {} if (organization?.id) { diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/monday.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/monday.test.ts index 762b925f07f..ee2ceb9935f 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/monday.test.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/monday.test.ts @@ -32,6 +32,7 @@ describe('validateMondayServiceAccount', () => { body: JSON.stringify({ query: 'query { me { id name email } account { id name slug } }', }), + redirect: 'error', signal: expect.any(AbortSignal), }) }) diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/redirects.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/redirects.test.ts new file mode 100644 index 00000000000..01e8ccb3fcb --- /dev/null +++ b/apps/sim/lib/credentials/token-service-accounts/validators/redirects.test.ts @@ -0,0 +1,56 @@ +import { jsonResponse } from '@sim/testing/helpers/http' +import { describe, expect, it, vi } from 'vitest' +import { mintBoxServiceAccountToken } from '@/lib/credentials/client-credential-accounts/minters/box' +import type { + TokenServiceAccountFields, + TokenServiceAccountValidationResult, +} from '@/lib/credentials/token-service-accounts/server' +import { validateClaudePlatformServiceAccount } from '@/lib/credentials/token-service-accounts/validators/claude-platform' +import { validateShopifyServiceAccount } from '@/lib/credentials/token-service-accounts/validators/shopify' + +/** + * Fetch keeps custom headers such as `x-api-key` on a cross-origin redirect (it + * strips only `Authorization`), so a check that followed one would hand the key to + * whatever host the redirect names. This fetch answers the way a redirect would: + * refused when the check asks for that, otherwise served by the redirect target, + * which here accepts anything. + */ +function redirectingFetch(targetAnswer: unknown) { + return vi.fn(async (_url: string, init?: RequestInit) => { + if (init?.redirect === 'error') throw new TypeError('fetch failed: unexpected redirect') + return jsonResponse(targetAnswer) + }) +} + +describe('saved key checks that send the key in a custom header', () => { + it.each< + [ + string, + (fields: TokenServiceAccountFields) => Promise, + TokenServiceAccountFields, + unknown, + ] + >([ + ['Claude Platform', validateClaudePlatformServiceAccount, { apiToken: 'sk-ant-key' }, {}], + [ + 'Shopify', + validateShopifyServiceAccount, + { apiToken: 'shpat_key', domain: 'acme.myshopify.com' }, + { data: { shop: { name: 'Elsewhere', myshopifyDomain: 'acme.myshopify.com' } } }, + ], + ])('%s refuses to follow a redirect with the key', async (_vendor, validate, fields, answer) => { + vi.stubGlobal('fetch', redirectingFetch(answer)) + + await expect(validate(fields)).rejects.toMatchObject({ code: 'provider_unavailable' }) + }) +}) + +describe('client-credential mints that send the secret in the request body', () => { + it('Box refuses to replay its client secret to a redirect target', async () => { + vi.stubGlobal('fetch', redirectingFetch({ access_token: 'elsewhere', expires_in: 3600 })) + + await expect( + mintBoxServiceAccountToken({ clientId: 'box-cid', clientSecret: 'box-secret', orgId: '1' }) + ).rejects.toMatchObject({ code: 'provider_unavailable' }) + }) +}) diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/shopify.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/shopify.test.ts index 86cfae024e1..a407d957317 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/shopify.test.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/shopify.test.ts @@ -37,6 +37,7 @@ describe('validateShopifyServiceAccount', () => { 'X-Shopify-Access-Token': 'shpat_abc', }, body: JSON.stringify({ query: '{ shop { name myshopifyDomain } }' }), + redirect: 'error', signal: expect.any(AbortSignal), } ) diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/shopify.ts b/apps/sim/lib/credentials/token-service-accounts/validators/shopify.ts index 54ff330897a..795ffb36082 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/shopify.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/shopify.ts @@ -16,7 +16,7 @@ import { SHOPIFY_API_VERSION } from '@/tools/shopify/constants' * host — exactly one label before `myshopify.com`, so lookalike hosts such as * `sub.myshopify.com.evil.com` are rejected before any outbound fetch. */ -const SHOPIFY_HOST_REGEX = /^[a-z0-9][a-z0-9-]*\.myshopify\.com$/ +export const SHOPIFY_HOST_REGEX = /^[a-z0-9][a-z0-9-]*\.myshopify\.com$/ /** * Every selected field must stay scope-free. `hasShopifyAuthError` treats an @@ -57,7 +57,7 @@ function hasShopifyAuthError(errors: unknown): boolean { } /** Strips the protocol and trailing slashes and lowercases the store domain. */ -function normalizeShopifyDomain(rawDomain: string): string { +export function normalizeShopifyDomain(rawDomain: string): string { return rawDomain .trim() .replace(/^https?:\/\//i, '') @@ -95,6 +95,7 @@ export async function validateShopifyServiceAccount( 'X-Shopify-Access-Token': fields.apiToken, }, body: JSON.stringify({ query: SHOP_QUERY }), + redirect: 'error', }, 'shop_query', { dnsFailureCode: 'site_not_found', dnsFailureReason: 'store domain does not resolve' } diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/token-checks.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/token-checks.test.ts index 0e5ccc3b1bc..363f4e73db1 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/token-checks.test.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/token-checks.test.ts @@ -8,6 +8,7 @@ import { describe, expect, it, vi } from 'vitest' vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock) import { + validateAgentMailServiceAccount, validateAlgoliaServiceAccount, validateAshbyServiceAccount, validateAzureDevOpsServiceAccount, @@ -18,6 +19,7 @@ import { validateElevenLabsServiceAccount, validateEmailBisonServiceAccount, validateFlintServiceAccount, + validateGongServiceAccount, validateGrafanaServiceAccount, validateInfisicalServiceAccount, validateInstantlyServiceAccount, @@ -28,7 +30,9 @@ import { validateNewRelicServiceAccount, validateOktaServiceAccount, validatePlaneServiceAccount, + validatePlanetScaleServiceAccount, validateResendServiceAccount, + validateServiceNowServiceAccount, validateSupabaseServiceAccount, validateThriveServiceAccount, validateTinybirdServiceAccount, @@ -166,6 +170,105 @@ describe('saved key verification for vendors with scoped keys', () => { }) }) +describe('AgentMail saved API key verification', () => { + it.each([ + ['lacks a permission the inbox listing needs', 'missing_permission'], + ['is scoped to a pod or inbox, not the organization', 'forbidden'], + ])('accepts a valid key that %s', async (_case, code) => { + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockResolvedValue( + jsonResponse({ name: 'ForbiddenError', code, message: 'Forbidden' }, 403) + ) + ) + + await expect(validateAgentMailServiceAccount({ apiToken: 'am_scoped' })).resolves.toMatchObject( + { displayName: 'AgentMail' } + ) + }) + + it.each([ + ['the gateway refuses with a bare Forbidden', { message: 'Forbidden' }, 403], + ['the key is unknown', { code: 'unknown_api_key', message: 'Unauthorized' }, 401], + ['the key has expired', { code: 'api_key_expired', message: 'Unauthorized' }, 401], + ['the account is suspended', { code: 'account_suspended', message: 'Forbidden' }, 403], + [ + 'an API gateway denies the request', + { Message: 'User is not authorized to access this resource with an explicit deny' }, + 403, + ], + ])('rejects the key when %s', async (_case, body, status) => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse(body, status))) + + await expect(validateAgentMailServiceAccount({ apiToken: 'am_bad' })).rejects.toMatchObject({ + code: 'invalid_credentials', + }) + }) + + it('reports an outage, not a bad key, for an uncoded gateway error', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue(jsonResponse({ message: 'Internal server error' }, 502)) + ) + + await expect(validateAgentMailServiceAccount({ apiToken: 'am_any' })).rejects.toMatchObject({ + code: 'provider_unavailable', + }) + }) +}) + +describe('New Relic saved User key verification', () => { + it('reports a NerdGraph error answer without a user as an outage, not a bad key', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + jsonResponse({ + data: { actor: null }, + errors: [ + { + message: 'Internal Server Error', + extensions: { errorClass: 'INTERNAL_SERVER_ERROR' }, + }, + ], + }) + ) + ) + + await expect( + validateNewRelicServiceAccount({ apiToken: 'NRAK-valid', domain: 'us' }) + ).rejects.toMatchObject({ code: 'provider_unavailable' }) + }) + + it('rejects a key NerdGraph names as bad in its errors array', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + jsonResponse({ + data: { actor: null }, + errors: [{ message: 'Invalid API key', extensions: { error_code: 'BAD_API_KEY' } }], + }) + ) + ) + + await expect( + validateNewRelicServiceAccount({ apiToken: 'NRAK-bad', domain: 'us' }) + ).rejects.toMatchObject({ code: 'invalid_credentials' }) + }) + + it('still rejects a NerdGraph answer that resolves no user and reports no error', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue(jsonResponse({ data: { actor: { user: null } } })) + ) + + await expect( + validateNewRelicServiceAccount({ apiToken: 'NRAK-unknown', domain: 'us' }) + ).rejects.toMatchObject({ code: 'invalid_credentials' }) + }) +}) + describe('Ashby saved API key verification', () => { it('accepts a valid key that lacks the read permission for this endpoint', async () => { vi.stubGlobal( @@ -336,16 +439,44 @@ describe('saved credentials whose connection names a host, region, or account', { domain: 'evil.example.com', providerWorkspaceId: 't1' }, ], ['Twilio', validateTwilioServiceAccount, { providerWorkspaceId: 'AC123/../Messages' }], + ['PlanetScale', validatePlanetScaleServiceAccount, { providerWorkspaceId: 'id:extra' }], + ['Gong', validateGongServiceAccount, { providerWorkspaceId: 'key:extra' }], + [ + 'ServiceNow', + validateServiceNowServiceAccount, + { domain: 'http://acme.service-now.com', providerWorkspaceId: 'admin' }, + ], + [ + 'ServiceNow', + validateServiceNowServiceAccount, + { domain: 'https://user:pass@acme.service-now.com', providerWorkspaceId: 'admin' }, + ], + [ + 'ServiceNow', + validateServiceNowServiceAccount, + { domain: 'acme.service-now.com', providerWorkspaceId: 'ad:min' }, + ], + ['PlanetScale', validatePlanetScaleServiceAccount, { providerWorkspaceId: 'id with space' }], ])( '%s refuses a typed value that could steer the request, before sending the key', async (_, validate, typed) => { - const fetch = vi.fn() + // Both transports would answer, so only the refusal itself can keep the key unsent. + const fetch = vi.fn().mockResolvedValue(jsonResponse({}, 401)) vi.stubGlobal('fetch', fetch) + inputValidationMockFns.mockValidateUrlWithDNS.mockReset().mockResolvedValue({ + isValid: true, + resolvedIP: '203.0.113.7', + originalHostname: 'instance.example.com', + }) + inputValidationMockFns.mockSecureFetchWithPinnedIP + .mockReset() + .mockResolvedValue(jsonResponse({}, 401)) await expect(validate({ apiToken: 'secret', ...typed })).rejects.toMatchObject({ code: 'invalid_credentials', }) expect(fetch).not.toHaveBeenCalled() + expect(inputValidationMockFns.mockSecureFetchWithPinnedIP).not.toHaveBeenCalled() } ) @@ -449,3 +580,274 @@ describe('saved credentials whose vendor answers a restricted token in its error else await expect(result).resolves.toMatchObject({ principal: null }) }) }) + +describe('PlanetScale saved service token verification', () => { + it('lists organizations with the token ID and token sent as the tools send them', async () => { + const fetch = vi.fn().mockResolvedValue(jsonResponse({ type: 'list', data: [] })) + vi.stubGlobal('fetch', fetch) + + await expect( + validatePlanetScaleServiceAccount({ apiToken: 'tok-secret', providerWorkspaceId: ' tok-id ' }) + ).resolves.toMatchObject({ + displayName: 'PlanetScale (tok-id)', + normalizedWorkspaceId: 'tok-id', + }) + expect(fetch).toHaveBeenCalledWith( + 'https://api.planetscale.com/v1/organizations', + expect.objectContaining({ + method: 'GET', + redirect: 'error', + headers: expect.objectContaining({ Authorization: 'tok-id:tok-secret' }), + }) + ) + }) + + it('rejects a token PlanetScale answers with 401', async () => { + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockResolvedValue(jsonResponse({ code: 'unauthorized', message: 'Unauthorized' }, 401)) + ) + + await expect( + validatePlanetScaleServiceAccount({ apiToken: 'bad', providerWorkspaceId: 'tok-id' }) + ).rejects.toMatchObject({ code: 'invalid_credentials' }) + }) + + it('rejects a JSON 403, which PlanetScale does not document as a valid token', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue(jsonResponse({ code: 'forbidden', message: 'Forbidden' }, 403)) + ) + + await expect( + validatePlanetScaleServiceAccount({ apiToken: 'any', providerWorkspaceId: 'tok-id' }) + ).rejects.toMatchObject({ code: 'invalid_credentials' }) + }) + + it('does not take a proxy 403 page as proof of the token', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + new Response('Forbidden', { + status: 403, + headers: { 'Content-Type': 'text/html' }, + }) + ) + ) + + await expect( + validatePlanetScaleServiceAccount({ apiToken: 'any', providerWorkspaceId: 'tok-id' }) + ).rejects.toMatchObject({ code: 'invalid_credentials' }) + }) + + it('refuses a token the tools could not send, before any request', async () => { + const fetch = vi.fn() + vi.stubGlobal('fetch', fetch) + + await expect( + validatePlanetScaleServiceAccount({ apiToken: 'tok:extra', providerWorkspaceId: 'tok-id' }) + ).rejects.toMatchObject({ code: 'invalid_credentials' }) + expect(fetch).not.toHaveBeenCalled() + }) +}) + +describe('Gong saved access key verification', () => { + it('lists workspaces on the tools host with the Basic key and secret the tools send', async () => { + const fetch = vi.fn().mockResolvedValue(jsonResponse({ requestId: 'r', workspaces: [] })) + vi.stubGlobal('fetch', fetch) + + await expect( + validateGongServiceAccount({ apiToken: 'key-secret', providerWorkspaceId: ' KEY123 ' }) + ).resolves.toMatchObject({ displayName: 'Gong (KEY123)', normalizedWorkspaceId: 'KEY123' }) + expect(fetch).toHaveBeenCalledWith( + 'https://api.gong.io/v2/workspaces', + expect.objectContaining({ + method: 'GET', + redirect: 'error', + headers: expect.objectContaining({ Authorization: `Basic ${btoa('KEY123:key-secret')}` }), + }) + ) + }) + + it.each([401, 403])('rejects a key pair Gong answers with %i', async (status) => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue(jsonResponse({ requestId: 'r', errors: ['Access denied'] }, status)) + ) + + await expect( + validateGongServiceAccount({ apiToken: 'bad', providerWorkspaceId: 'KEY123' }) + ).rejects.toMatchObject({ code: 'invalid_credentials' }) + }) + + it('refuses a secret the tools could not encode, before any request', async () => { + const fetch = vi.fn() + vi.stubGlobal('fetch', fetch) + + await expect( + validateGongServiceAccount({ apiToken: 'sécret-€', providerWorkspaceId: 'KEY123' }) + ).rejects.toMatchObject({ code: 'invalid_credentials' }) + expect(fetch).not.toHaveBeenCalled() + }) +}) + +describe('ServiceNow saved username and password verification', () => { + const secureFetch = inputValidationMockFns.mockSecureFetchWithPinnedIP + + function reply(response: Response) { + inputValidationMockFns.mockValidateUrlWithDNS.mockReset().mockResolvedValue({ + isValid: true, + resolvedIP: '203.0.113.7', + originalHostname: 'acme.service-now.com', + }) + secureFetch.mockReset().mockResolvedValue(response) + } + + it('reads one user on the instance origin with the Basic auth the tools send', async () => { + reply(jsonResponse({ result: [{ sys_id: 'abc' }] })) + + await expect( + validateServiceNowServiceAccount({ + apiToken: 'pass', + providerWorkspaceId: ' integration.user ', + domain: 'acme.service-now.com/nav_to.do?uri=incident.do', + }) + ).resolves.toMatchObject({ + displayName: 'ServiceNow (https://acme.service-now.com)', + normalizedDomain: 'https://acme.service-now.com', + normalizedWorkspaceId: 'integration.user', + }) + expect(secureFetch).toHaveBeenCalledWith( + 'https://acme.service-now.com/api/now/table/sys_user?sysparm_limit=1&sysparm_fields=sys_id', + '203.0.113.7', + expect.objectContaining({ + method: 'GET', + maxRedirects: 0, + headers: expect.objectContaining({ + Authorization: `Basic ${Buffer.from('integration.user:pass').toString('base64')}`, + }), + }) + ) + }) + + it('verifies an instance on a custom URL, as the earlier block allowed', async () => { + reply(jsonResponse({ result: [{ sys_id: 'abc' }] })) + + await expect( + validateServiceNowServiceAccount({ + apiToken: 'pass', + providerWorkspaceId: 'admin', + domain: 'support.acme.example', + }) + ).resolves.toMatchObject({ normalizedDomain: 'https://support.acme.example' }) + expect(secureFetch).toHaveBeenCalledWith( + 'https://support.acme.example/api/now/table/sys_user?sysparm_limit=1&sysparm_fields=sys_id', + '203.0.113.7', + expect.objectContaining({ maxRedirects: 0 }) + ) + }) + + it.each([ + [ + 'rejects a password ServiceNow answers with 401', + jsonResponse({ error: {} }, 401), + 'invalid_credentials', + ], + [ + 'accepts a user whose ACLs refuse the user table', + jsonResponse({ error: { message: 'User Not Authorized' }, status: 'failure' }, 403), + null, + ], + [ + 'does not take a hibernating instance page as proof', + new Response('Instance Hibernating', { + status: 200, + headers: { 'Content-Type': 'text/html' }, + }), + 'provider_unavailable', + ], + ] as const)('%s', async (_, response, code) => { + reply(response) + + const result = validateServiceNowServiceAccount({ + apiToken: 'pass', + providerWorkspaceId: 'admin', + domain: 'https://acme.service-now.com', + }) + + if (code) await expect(result).rejects.toMatchObject({ code }) + else await expect(result).resolves.toMatchObject({ principal: null }) + }) +}) + +describe('Twilio saved credential verification', () => { + const ACCOUNT_SID = `AC${'a'.repeat(32)}` + const API_KEY_SID = `SK${'b'.repeat(32)}` + + it('checks an auth token against the account resource, as before', async () => { + const fetch = vi.fn().mockResolvedValue(jsonResponse({ sid: ACCOUNT_SID })) + vi.stubGlobal('fetch', fetch) + + await expect( + validateTwilioServiceAccount({ apiToken: 'c'.repeat(32), providerWorkspaceId: ACCOUNT_SID }) + ).resolves.toMatchObject({ displayName: `Twilio (${ACCOUNT_SID})` }) + expect(fetch).toHaveBeenCalledWith( + `https://api.twilio.com/2010-04-01/Accounts/${ACCOUNT_SID}.json`, + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: `Basic ${Buffer.from(`${ACCOUNT_SID}:${'c'.repeat(32)}`).toString('base64')}`, + }), + }) + ) + }) + + it('checks an API key with a read a Standard key may make, sent as the key SID and secret', async () => { + const fetch = vi.fn().mockResolvedValue(jsonResponse({ incoming_phone_numbers: [] })) + vi.stubGlobal('fetch', fetch) + + await expect( + validateTwilioServiceAccount({ + apiToken: `${API_KEY_SID}:key-secret`, + providerWorkspaceId: ACCOUNT_SID, + }) + ).resolves.toMatchObject({ displayName: `Twilio (${ACCOUNT_SID})` }) + expect(fetch).toHaveBeenCalledWith( + `https://api.twilio.com/2010-04-01/Accounts/${ACCOUNT_SID}/IncomingPhoneNumbers.json?PageSize=1`, + expect.objectContaining({ + redirect: 'error', + headers: expect.objectContaining({ + Authorization: `Basic ${Buffer.from(`${API_KEY_SID}:key-secret`).toString('base64')}`, + }), + }) + ) + }) + + it('rejects an API key Twilio refuses for this account', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue(jsonResponse({ code: 20003, message: 'Authenticate' }, 401)) + ) + + await expect( + validateTwilioServiceAccount({ + apiToken: `${API_KEY_SID}:wrong`, + providerWorkspaceId: ACCOUNT_SID, + }) + ).rejects.toMatchObject({ code: 'invalid_credentials' }) + }) + + it.each([`SKshort:secret`, `${API_KEY_SID}:`, `${ACCOUNT_SID}:secret`, 'token:with:colons'])( + 'refuses %s, which is neither an auth token nor an API key, before any request', + async (apiToken) => { + const fetch = vi.fn() + vi.stubGlobal('fetch', fetch) + + await expect( + validateTwilioServiceAccount({ apiToken, providerWorkspaceId: ACCOUNT_SID }) + ).rejects.toMatchObject({ code: 'invalid_credentials' }) + expect(fetch).not.toHaveBeenCalled() + } + ) +}) diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/token-checks.ts b/apps/sim/lib/credentials/token-service-accounts/validators/token-checks.ts index e4f57a094e4..12555723594 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/token-checks.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/token-checks.ts @@ -1,5 +1,10 @@ import { toArray, toRecord } from '@sim/utils/object' -import { validateOktaDomain, validateSupabaseProjectId } from '@/lib/core/security/input-validation' +import { + normalizeHttpBaseUrl, + refuseUnsafeTypedUrl, + validateOktaDomain, + validateSupabaseProjectId, +} from '@/lib/core/security/input-validation' import { fetchProvider, TokenServiceAccountValidationError, @@ -11,9 +16,7 @@ import type { } from '@/lib/credentials/token-service-accounts/server' import { allowedDomain, - normalizeHttpBaseUrl, readJsonBody, - refuseUnsafeTypedUrl, tokenCheckValidator, } from '@/lib/credentials/token-service-accounts/validators/http' import { buildJupyterAuthHeaders, normalizeJupyterServerUrl } from '@/lib/internal/jupyter/protocol' @@ -24,6 +27,7 @@ import { convexApiUrl, convexAuthHeaders } from '@/tools/convex/utils' import { buildDynatraceUrl, dynatraceHeaders } from '@/tools/dynatrace/utils' import { normalizeEmailBisonBaseUrl } from '@/tools/emailbison/utils' import { normalizeGitLabHost } from '@/tools/gitlab/utils' +import { gongAuthorization } from '@/tools/gong/utils' import { GRANOLA_API_BASE, granolaHeaders } from '@/tools/granola/utils' import { buildJotformHeaders, buildJotformUrl, JOTFORM_REGION_HOSTS } from '@/tools/jotform/utils' import { @@ -36,8 +40,15 @@ import { getMailgunApiBaseUrl } from '@/tools/mailgun/utils' import { getNerdGraphEndpoint, newRelicHeaders } from '@/tools/new_relic/utils' import { oktaHeaders } from '@/tools/okta/utils' import { planeApiUrl, planeHeaders } from '@/tools/plane/utils' +import { + isPlanetScaleTokenPart, + PLANETSCALE_API_ORIGIN, + planetScaleHeaders, +} from '@/tools/planetscale/utils' +import { createBasicAuthHeader } from '@/tools/servicenow/utils' import { supabaseBaseUrl } from '@/tools/supabase/utils' import { getThriveBaseUrl, getThriveHeaders, THRIVE_HOSTS } from '@/tools/thrive/utils' +import { isTwilioApiKeyPair, twilioAuthorization } from '@/tools/twilio/utils' import { WHATSAPP_GRAPH_VERSION } from '@/tools/whatsapp/utils' /** @@ -60,9 +71,24 @@ export const validateAffinityServiceAccount = tokenCheckValidator({ }), }) -/** Listing one AgentMail inbox answers 200 for a valid key and 403 otherwise. */ +/** + * The AgentMail 403 codes its docs give for a valid key whose permissions or scope do not + * cover the request: https://docs.agentmail.to/knowledge-base/api-403-error + */ +const AGENTMAIL_SCOPED_KEY_CODES: ReadonlySet = new Set([ + 'missing_permission', + 'forbidden', +]) + +/** + * Listing one AgentMail inbox answers 200 for a valid key. A valid key that lacks the + * inbox-read permission or is scoped to a pod or inbox gets a 403 coded `missing_permission` + * or `forbidden`, and can still run the tools its scope covers. Every other refusal, from + * the gateway's bare `{"message":"Forbidden"}` to a suspended account, rejects the key. + */ export const validateAgentMailServiceAccount = tokenCheckValidator({ serviceLabel: 'AgentMail', + provesKey: (errorBody) => AGENTMAIL_SCOPED_KEY_CODES.has(toRecord(errorBody).code), request: ({ apiToken }) => ({ url: 'https://api.agentmail.to/v0/inboxes?limit=1', headers: { Authorization: `Bearer ${apiToken}`, Accept: 'application/json' }, @@ -376,9 +402,11 @@ export const validateDaytonaServiceAccount = tokenCheckValidator({ /** * Listing one session of the organization is the tools' own read on their host and auth: 200 - * proves the key and its access to that organization. Devin answers an unknown key, or a key - * from another organization, with a problem+json 403, so a 403 rejects. The id must survive - * URL encoding unchanged, so it can never steer the tools' unencoded path. + * proves the key and its access to that organization (it needs `ViewOrgSessions`). Devin + * answers an invalid key with 401, and both a key outside the organization and one without the + * permission with the same problem+json 403; nothing documented tells those two apart, so a + * 403 rejects. The id must survive URL encoding unchanged, so it can never steer the tools' + * unencoded path. */ export const validateDevinServiceAccount = tokenCheckValidator({ serviceLabel: 'Devin', @@ -621,8 +649,11 @@ const BITBUCKET_REPOSITORY = /^[A-Za-z0-9][A-Za-z0-9_-]{0,254}\/[A-Za-z0-9_-][A- * Reading one repository the token can see is the request Atlassian documents for all * three kinds (`GET /2.0/repositories/{workspace}/{repository}` with the `repository` * scope): 200 with the repository, 401 for a bad token, 403 without the scope, and 404 for - * a repository that does not exist or that the token cannot reach. + * a repository that does not exist or that the token cannot reach. An Atlassian API token + * created for Bitbucket passes the same read, since Bitbucket takes it as a Bearer token + * without the account email. * https://support.atlassian.com/bitbucket-cloud/docs/using-access-tokens/ + * https://support.atlassian.com/bitbucket-cloud/docs/using-api-tokens/ */ export const validateBitbucketServiceAccount = tokenCheckValidator({ serviceLabel: 'Bitbucket', @@ -679,6 +710,37 @@ export const validateGitLabServiceAccount = tokenCheckValidator({ }, }) +/** + * Listing workspaces is a cheap documented read on the fixed host the tools call, with the same + * Basic header the tools send (`gongAuthorization`). Gong documents a 403 ("Access denied") for + * this read without telling a bad key from a missing permission, so any refusal rejects. The + * access key is the Basic username, so one with a colon is refused unsent, as is a pair the + * header cannot encode. + */ +export const validateGongServiceAccount = tokenCheckValidator({ + serviceLabel: 'Gong', + workspaceId: ({ providerWorkspaceId }) => { + const accessKey = (providerWorkspaceId ?? '').trim() + if (!/^[\x21-\x39\x3b-\x7e]+$/.test(accessKey)) throw new Error('invalid access key') + return accessKey + }, + request: ({ apiToken, providerWorkspaceId = '' }) => { + let authorization: string + try { + authorization = gongAuthorization(providerWorkspaceId, apiToken) + } catch { + throw new TokenServiceAccountValidationError('invalid_credentials', 400, { + step: 'verify', + reason: 'invalid Gong access key secret', + }) + } + return { + url: 'https://api.gong.io/v2/workspaces', + headers: { Authorization: authorization, Accept: 'application/json' }, + } + }, +}) + /** * Grafana's `GET /api/user` needs a signed-in, non-anonymous identity but no role, so it * answers 200 for any valid service account token and 401 for a bad one, even where @@ -1004,12 +1066,23 @@ export const validateMem0ServiceAccount = tokenCheckValidator({ }), }) +/** + * The NerdGraph error classes New Relic's own Go client retries: NerdGraph failing to + * answer, not a verdict on the key. + * https://github.com/newrelic/newrelic-client-go/blob/main/internal/http/graphql.go + */ +const NERDGRAPH_RETRYABLE_ERROR_CLASSES: ReadonlySet = new Set([ + 'TIMEOUT', + 'INTERNAL_SERVER_ERROR', + 'SERVER_ERROR', +]) + /** * A New Relic User API key is bound to its data center, so it is checked against the * NerdGraph host of the region typed with it (`us` or `eu`, stored lower-cased because * the tools send anything but `eu` to the US host). An unknown key, or one from the other - * region, gets 401; NerdGraph can also answer 200 with an `errors` array, so only a - * resolved user proves the key. + * region, gets 401, or a 200 whose `errors` carry `BAD_API_KEY`, so only a resolved user + * proves the key. Only an error in a server class means NerdGraph failed to answer. */ export const validateNewRelicServiceAccount = tokenCheckValidator({ serviceLabel: 'New Relic', @@ -1020,7 +1093,20 @@ export const validateNewRelicServiceAccount = tokenCheckValidator({ headers: { ...newRelicHeaders(apiToken), Accept: 'application/json' }, body: JSON.stringify({ query: '{ actor { user { id } } }' }), }), - acceptsBody: (body) => toRecord(toRecord(toRecord(body).data).actor).user != null, + acceptsBody: (body) => { + const { data, errors } = toRecord(body) + if (toRecord(toRecord(data).actor).user != null) return true + const failedToAnswer = toArray(errors).some((error) => + NERDGRAPH_RETRYABLE_ERROR_CLASSES.has(toRecord(toRecord(error).extensions).errorClass) + ) + if (failedToAnswer) { + throw new TokenServiceAccountValidationError('provider_unavailable', 502, { + step: 'verify', + reason: 'NerdGraph answered with errors and no user', + }) + } + return false + }, }) /** @@ -1130,6 +1216,34 @@ export const validatePlaneServiceAccount = tokenCheckValidator({ }, }) +/** + * Listing the organizations a service token can reach is the read PlanetScale's own docs use, sent + * with the `:` Authorization the tools build (`planetScaleHeaders`). A bad ID or + * token gets 401. PlanetScale documents no meaning for a 403 here, so it rejects like any other + * refusal. An ID or token the tools' header builder refuses (a colon or whitespace would split + * the pair) is refused unsent. + */ +export const validatePlanetScaleServiceAccount = tokenCheckValidator({ + serviceLabel: 'PlanetScale', + workspaceId: ({ providerWorkspaceId }) => { + const tokenId = (providerWorkspaceId ?? '').trim() + if (!isPlanetScaleTokenPart(tokenId)) throw new Error('invalid service token ID') + return tokenId + }, + request: ({ apiToken, providerWorkspaceId = '' }) => { + let headers: Record + try { + headers = planetScaleHeaders({ serviceTokenId: providerWorkspaceId, serviceToken: apiToken }) + } catch { + throw new TokenServiceAccountValidationError('invalid_credentials', 400, { + step: 'verify', + reason: 'invalid PlanetScale service token', + }) + } + return { url: `${PLANETSCALE_API_ORIGIN}/v1/organizations`, headers } + }, +}) + /** Profound's organization categories endpoint answers 200 for a valid key and 401 otherwise. */ export const validateProfoundServiceAccount = tokenCheckValidator({ serviceLabel: 'Profound', @@ -1253,6 +1367,56 @@ export const validateSentryServiceAccount = tokenCheckValidator({ }), }) +/** + * A typed ServiceNow instance reduced to the https origin the tools build `/api/now/...` paths on: + * a bare host is taken as https and a pasted console path or query is dropped. Like a self-managed + * GitLab host, any public https host is allowed (instances on `service-now.com`, custom instance + * URLs, and self-hosted instances, matching what the earlier block accepted); the + * `configuredEndpoint` egress check refuses private and loopback addresses, and the saved password + * only ever goes to the host stored with it. + */ +function normalizeServiceNowInstanceUrl(raw: string | undefined): string { + const typed = refuseUnsafeTypedUrl(raw) + const url = new URL(/^https?:\/\//i.test(typed) ? typed : `https://${typed}`) + if (url.protocol !== 'https:') throw new Error('https only') + return url.origin +} + +/** + * Reading one user id from the Table API is a cheap documented read with the Basic + * `username:password` the tools send, on the instance it was typed with. ServiceNow answers bad + * credentials with 401 and a request its ACLs refuse with a JSON 403, which still proves the + * login. The username is the Basic username, so one with a colon is refused unsent. Only a + * Table API `result` list proves the login: a hibernating instance answers 200 with a page. + */ +export const validateServiceNowServiceAccount = tokenCheckValidator({ + serviceLabel: 'ServiceNow', + domain: ({ domain }) => normalizeServiceNowInstanceUrl(domain), + workspaceId: ({ providerWorkspaceId }) => { + const username = (providerWorkspaceId ?? '').trim() + if (!/^[^:\x00-\x1f\x7f]+$/.test(username)) throw new Error('invalid username') + return username + }, + egress: 'configuredEndpoint', + forbiddenMeansScoped: true, + request: ({ apiToken, domain, providerWorkspaceId = '' }) => ({ + url: `${domain}/api/now/table/sys_user?sysparm_limit=1&sysparm_fields=sys_id`, + headers: { + Authorization: createBasicAuthHeader(providerWorkspaceId, apiToken), + Accept: 'application/json', + }, + }), + acceptsBody: (body) => { + if (!Array.isArray(toRecord(body).result)) { + throw new TokenServiceAccountValidationError('provider_unavailable', 502, { + step: 'verify', + reason: 'ServiceNow returned an unexpected table read', + }) + } + return true + }, +}) + /** Similarweb's capabilities endpoint costs no data credits and answers 200 only for a valid key. */ export const validateSimilarwebServiceAccount = tokenCheckValidator({ serviceLabel: 'Similarweb', @@ -1425,9 +1589,14 @@ export const validateTriggerDevServiceAccount = tokenCheckValidator({ const TWILIO_ACCOUNT_SID = /^AC[0-9a-f]{32}$/ /** - * Fetching the account resource with the SID and auth token as Basic auth (the tools' scheme) - * answers 200 for a matching pair and 401 (code 20003) for a wrong token, an unknown SID, or a SID - * the token doesn't belong to. The SID is checked first so it can never rewrite the path. + * The credential holds the account SID plus either its auth token or an API key pair + * (`SKxxx:secret`, sent as Twilio's Basic username and password for a key). An auth token fetches + * the account resource (the tools' scheme, `accountSid:authToken`): 200 for a matching pair, 401 + * (code 20003) for a wrong token, an unknown SID, or a SID the token doesn't belong to. A Standard + * API key may not read the Accounts resource, so a key lists one of the account's phone numbers + * instead; Twilio refuses that read for a wrong secret or a key of another account. The SID is + * checked first so it can never rewrite the path, and a value with a colon that isn't a key pair + * is refused unsent. */ export const validateTwilioServiceAccount = tokenCheckValidator({ serviceLabel: 'Twilio', @@ -1436,13 +1605,23 @@ export const validateTwilioServiceAccount = tokenCheckValidator({ if (!TWILIO_ACCOUNT_SID.test(accountSid)) throw new Error('invalid Account SID') return accountSid }, - request: ({ apiToken, providerWorkspaceId }) => ({ - url: `https://api.twilio.com/2010-04-01/Accounts/${providerWorkspaceId}.json`, - headers: { - Authorization: `Basic ${Buffer.from(`${providerWorkspaceId}:${apiToken}`).toString('base64')}`, - Accept: 'application/json', - }, - }), + request: ({ apiToken, providerWorkspaceId = '' }) => { + const apiKey = isTwilioApiKeyPair(apiToken) + if (!apiKey && apiToken.includes(':')) { + throw new TokenServiceAccountValidationError('invalid_credentials', 400, { + step: 'verify', + reason: 'neither an auth token nor an API key pair', + }) + } + const account = `https://api.twilio.com/2010-04-01/Accounts/${providerWorkspaceId}` + return { + url: apiKey ? `${account}/IncomingPhoneNumbers.json?PageSize=1` : `${account}.json`, + headers: { + Authorization: twilioAuthorization(providerWorkspaceId, apiToken), + Accept: 'application/json', + }, + } + }, }) /** diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/webflow.test.ts b/apps/sim/lib/credentials/token-service-accounts/validators/webflow.test.ts new file mode 100644 index 00000000000..8c8ec4f3f50 --- /dev/null +++ b/apps/sim/lib/credentials/token-service-accounts/validators/webflow.test.ts @@ -0,0 +1,38 @@ +import { jsonResponse } from '@sim/testing/helpers/http' +import { describe, expect, it, vi } from 'vitest' +import { validateWebflowServiceAccount } from '@/lib/credentials/token-service-accounts/validators/webflow' + +function sitesResponse(sites: unknown[]) { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse({ sites }))) +} + +describe('validateWebflowServiceAccount', () => { + it('names the one site a site token reaches as its tenant', async () => { + sitesResponse([{ id: 'site-1', displayName: 'Marketing' }]) + + await expect(validateWebflowServiceAccount({ apiToken: 'wf-token' })).resolves.toMatchObject({ + displayName: 'Marketing', + principal: { kind: 'tenant', id: 'site-1' }, + }) + }) + + it('rejects a token that reaches no site as the wrong token, not an outage', async () => { + sitesResponse([]) + + await expect(validateWebflowServiceAccount({ apiToken: 'wf-token' })).rejects.toMatchObject({ + code: 'invalid_credentials', + }) + }) + + it('does not pin a token that reaches several sites to whichever comes first', async () => { + sitesResponse([ + { id: 'site-1', displayName: 'Marketing' }, + { id: 'site-2', displayName: 'Docs' }, + ]) + + await expect(validateWebflowServiceAccount({ apiToken: 'wf-token' })).resolves.toMatchObject({ + displayName: 'Webflow', + principal: null, + }) + }) +}) diff --git a/apps/sim/lib/credentials/token-service-accounts/validators/webflow.ts b/apps/sim/lib/credentials/token-service-accounts/validators/webflow.ts index 4da3aeba131..e1b1296eb3e 100644 --- a/apps/sim/lib/credentials/token-service-accounts/validators/webflow.ts +++ b/apps/sim/lib/credentials/token-service-accounts/validators/webflow.ts @@ -22,9 +22,10 @@ interface WebflowSite { * site and doubles as the whoami-equivalent (site tokens cannot call the * authorization endpoints). Requires the `sites:read` scope — a token missing * it surfaces as 401/403, which maps to `invalid_credentials`; any other - * non-2xx means Webflow is unavailable. A 200 with an empty `sites` array is - * treated as `provider_unavailable` since a site token should always list its - * one site. + * non-2xx means Webflow is unavailable. The route lists every site the token + * can access, so an empty list is a token that reaches no site, which the + * user must replace. Only a token that reaches exactly one site is named + * after it; one that reaches several has no single site to stand for it. */ export async function validateWebflowServiceAccount( fields: TokenServiceAccountFields @@ -42,11 +43,26 @@ export async function validateWebflowServiceAccount( await throwForProviderResponse(sitesRes, 'list_sites') const payload = await parseProviderJson<{ sites?: WebflowSite[] }>(sitesRes, 'list_sites') - const site = payload.sites?.[0] + if (!Array.isArray(payload.sites)) { + throw new TokenServiceAccountValidationError('provider_unavailable', 502, { + step: 'list_sites', + reason: 'missing sites array in response', + }) + } + if (payload.sites.length === 0) { + throw new TokenServiceAccountValidationError('invalid_credentials', 400, { + step: 'list_sites', + reason: 'token reaches no site', + }) + } + if (payload.sites.length > 1) { + return { displayName: 'Webflow', principal: null, auditMetadata: {} } + } + const [site] = payload.sites if (!site?.id) { throw new TokenServiceAccountValidationError('provider_unavailable', 502, { step: 'list_sites', - reason: 'missing or empty sites array in response', + reason: 'site without an id in response', }) } diff --git a/apps/sim/lib/credentials/workload-identity/issuer.test.ts b/apps/sim/lib/credentials/workload-identity/issuer.test.ts index 88da9a3419a..d3ab2054828 100644 --- a/apps/sim/lib/credentials/workload-identity/issuer.test.ts +++ b/apps/sim/lib/credentials/workload-identity/issuer.test.ts @@ -2,6 +2,7 @@ * @vitest-environment node */ import { generateKeyPairSync } from 'node:crypto' +import { getMockLogger } from '@sim/testing/mocks/logger.mock' import { createLocalJWKSet, decodeProtectedHeader, jwtVerify } from 'jose' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { env } from '@/lib/core/config/env' @@ -147,6 +148,36 @@ describe('workload identity issuer', () => { expect((await workloadIdentityDiscoveryResponse()).status).toBe(404) }) + it.each([ + 'https://sim.example.com', + 'https://oidc.example.com/workload', + 'https://sim.example.com/oidc/workload/v2', + ])( + 'keeps a pinned issuer on another path working, warning that a proxy must route it (%s)', + async (url) => { + setEnv('WORKLOAD_IDENTITY_ISSUER', url) + const logger = getMockLogger('WorkloadIdentityIssuer') + logger.warn.mockClear() + const issuer = await loadIssuer() + + expect(await issuer.isWorkloadIdentityConfigured()).toBe(true) + expect(issuer.getWorkloadIdentityIssuer()).toBe(url) + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('/oidc/workload'), { + issuer: url, + }) + } + ) + + it('accepts a pinned issuer behind a path prefix without a warning', async () => { + setEnv('WORKLOAD_IDENTITY_ISSUER', 'https://example.com/sim/oidc/workload') + const logger = getMockLogger('WorkloadIdentityIssuer') + logger.warn.mockClear() + const issuer = await loadIssuer() + + expect(await issuer.isWorkloadIdentityConfigured()).toBe(true) + expect(logger.warn).not.toHaveBeenCalled() + }) + it('keeps signing with the current key when the retiring key is malformed', async () => { setEnv('WORKLOAD_IDENTITY_PREVIOUS_KEY', 'not a PEM key') const issuer = await loadIssuer() @@ -183,10 +214,11 @@ describe('workload identity issuer', () => { ).rejects.toMatchObject({ detailCode: 'WORKLOAD_IDENTITY_NOT_CONFIGURED' }) }) - it('uses the pinned issuer URL when one is configured', async () => { - setEnv('WORKLOAD_IDENTITY_ISSUER', 'https://oidc.example.com/workload/') + it('uses a pinned issuer on another host when it keeps the served path', async () => { + setEnv('WORKLOAD_IDENTITY_ISSUER', 'https://oidc.example.com/oidc/workload/') const issuer = await loadIssuer() - expect(issuer.getWorkloadIdentityIssuer()).toBe('https://oidc.example.com/workload') + expect(await issuer.isWorkloadIdentityConfigured()).toBe(true) + expect(issuer.getWorkloadIdentityIssuer()).toBe('https://oidc.example.com/oidc/workload') }) }) diff --git a/apps/sim/lib/credentials/workload-identity/issuer.ts b/apps/sim/lib/credentials/workload-identity/issuer.ts index 135a36db6a1..42be37b1a75 100644 --- a/apps/sim/lib/credentials/workload-identity/issuer.ts +++ b/apps/sim/lib/credentials/workload-identity/issuer.ts @@ -74,6 +74,13 @@ async function loadPreviousPublicJwk(): Promise { * default port, and discovery appends paths to it, so any other issuer (or one * with a query or fragment) can never be trusted; keyless stays off * rather than advertising an issuer no relying party accepts. + * + * Sim serves discovery and keys under {@link WORKLOAD_IDENTITY_PATH}. A pinned + * issuer whose path ends there reaches them directly or through a + * prefix-stripping proxy, like the default issuer on a path-prefixed app URL. + * Any other path works only if a proxy routes it to that path, which Sim can't + * see, so it is warned about rather than refused: trust policies already name + * the pinned URL, and saving a keyless credential proves the route end to end. */ function assertPublicIssuer(issuer: string): void { const url = new URL(issuer) @@ -82,12 +89,19 @@ function assertPublicIssuer(issuer: string): void { 'Workload identity issuer must be an https URL without a port, query, or fragment' ) } - if (!env.WORKLOAD_IDENTITY_ISSUER?.trim()) { - logger.warn( - 'Workload identity issuer follows the app URL; set WORKLOAD_IDENTITY_ISSUER so customer trust policies survive a URL change', - { issuer } - ) + if (env.WORKLOAD_IDENTITY_ISSUER?.trim()) { + if (!url.pathname.endsWith(WORKLOAD_IDENTITY_PATH)) { + logger.warn( + `WORKLOAD_IDENTITY_ISSUER does not end in ${WORKLOAD_IDENTITY_PATH}, where Sim serves discovery and keys; clouds can verify its tokens only if a proxy routes the issuer there`, + { issuer } + ) + } + return } + logger.warn( + 'Workload identity issuer follows the app URL; set WORKLOAD_IDENTITY_ISSUER so customer trust policies survive a URL change', + { issuer } + ) } let signingMaterial: Promise | undefined diff --git a/apps/sim/lib/guardrails/validate-hallucination.test.ts b/apps/sim/lib/guardrails/validate-hallucination.test.ts index f3ebfa761c6..61b5a60b55f 100644 --- a/apps/sim/lib/guardrails/validate-hallucination.test.ts +++ b/apps/sim/lib/guardrails/validate-hallucination.test.ts @@ -1,11 +1,16 @@ import { encryptionMock, encryptionMockFns } from '@sim/testing/mocks/encryption.mock' import { providersMock, providersMockFns } from '@sim/testing/mocks/providers.mock' -import { providersUtilsMock } from '@sim/testing/mocks/providers-utils.mock' +import { providersUtilsMock, providersUtilsMockFns } from '@sim/testing/mocks/providers-utils.mock' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { BillingAttributionSnapshot } from '@/lib/billing/core/attribution' -const { mockSearchKnowledgeAsExecutor } = vi.hoisted(() => ({ +const { mockSearchKnowledgeAsExecutor, mockResolveVertexAccessToken } = vi.hoisted(() => ({ mockSearchKnowledgeAsExecutor: vi.fn(), + mockResolveVertexAccessToken: vi.fn(), +})) + +vi.mock('@/lib/internal/llm/credentials', () => ({ + resolveVertexAccessToken: mockResolveVertexAccessToken, })) vi.mock('@/lib/internal/knowledge/search', () => ({ @@ -165,6 +170,59 @@ describe('validateHallucination', () => { expect(mockExecuteProviderRequest).not.toHaveBeenCalled() }) + describe('with a Vertex AI model', () => { + const vertexInput = (registry: ResolvedSecretTraceRegistry) => ({ + ...createInput(registry), + model: 'vertex/gemini-2.5-pro', + apiKey: 'unused-key', + providerCredentials: { + vertexCredential: 'credential-vertex', + vertexProject: 'example-project', + vertexLocation: 'us-central1', + }, + }) + + beforeEach(() => { + mockResolveVertexAccessToken.mockReset() + mockResolveVertexAccessToken.mockImplementation( + async (_requestId: string, credentialId: string) => `access-token-for:${credentialId}` + ) + }) + + it('scores with the access token the saved credential resolves to', async () => { + providersUtilsMockFns.mockGetProviderFromModel.mockReturnValue('vertex') + + const result = await validateHallucination(vertexInput(new ResolvedSecretTraceRegistry())) + + expect(result).toMatchObject({ passed: true, score: 8 }) + expect(mockExecuteProviderRequest.mock.calls[0]?.[1]).toMatchObject({ + apiKey: 'access-token-for:credential-vertex', + vertexProject: 'example-project', + vertexLocation: 'us-central1', + }) + }) + + it('fails the check instead of calling the model when the credential cannot be resolved', async () => { + providersUtilsMockFns.mockGetProviderFromModel.mockReturnValue('vertex') + mockResolveVertexAccessToken.mockRejectedValue( + new Error('Vertex AI credential not found: credential-vertex') + ) + + const result = await validateHallucination(vertexInput(new ResolvedSecretTraceRegistry())) + + expect(result).toMatchObject({ passed: false }) + expect(mockExecuteProviderRequest).not.toHaveBeenCalled() + }) + + it('leaves another provider’s key alone', async () => { + providersUtilsMockFns.mockGetProviderFromModel.mockReturnValue('openai') + + await validateHallucination(vertexInput(new ResolvedSecretTraceRegistry())) + + expect(mockExecuteProviderRequest.mock.calls[0]?.[1]).toMatchObject({ apiKey: 'unused-key' }) + }) + }) + /** * Forwarding the caller's signal means the scoring model can now be aborted. A * cancelled run must not be reported as a guardrail verdict — `passed: false` would diff --git a/apps/sim/lib/guardrails/validate-hallucination.ts b/apps/sim/lib/guardrails/validate-hallucination.ts index d87d1387070..b294c3dea0d 100644 --- a/apps/sim/lib/guardrails/validate-hallucination.ts +++ b/apps/sim/lib/guardrails/validate-hallucination.ts @@ -1,13 +1,10 @@ -import { db } from '@sim/db' -import { account } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { isPlainRecord } from '@sim/utils/object' -import { eq } from 'drizzle-orm' import type { BillingAttributionSnapshot } from '@/lib/billing/core/attribution' import { searchKnowledgeAsExecutor } from '@/lib/internal/knowledge/search' +import { resolveVertexAccessToken } from '@/lib/internal/llm/credentials' import type { InternalToolOperationContext } from '@/lib/internal/tool-operations/types' -import { refreshTokenIfNeeded } from '@/lib/oauth/credential-service' import { projectResolvedSecretModelContent } from '@/executor/utils/resolved-secret-content-projection' import { refuseResolvedSecretProjection } from '@/executor/utils/resolved-secret-projection-refusal' import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' @@ -159,22 +156,11 @@ Evaluate the consistency and provide your score and reasoning in JSON format.` const providerId = getProviderFromModel(model) - let finalApiKey: string | undefined = apiKey - if (providerId === 'vertex' && providerCredentials?.vertexCredential) { - const credential = await db.query.account.findFirst({ - where: eq(account.id, providerCredentials.vertexCredential), - }) - if (credential) { - const { accessToken } = await refreshTokenIfNeeded( - requestId, - credential, - providerCredentials.vertexCredential - ) - if (accessToken) { - finalApiKey = accessToken - } - } - } + // The guardrails operation authorized this credential before validation began. + const finalApiKey = + providerId === 'vertex' && providerCredentials?.vertexCredential + ? await resolveVertexAccessToken(requestId, providerCredentials.vertexCredential) + : apiKey const response = await executeProviderRequest( providerId, diff --git a/apps/sim/lib/integrations/icon-mapping.ts b/apps/sim/lib/integrations/icon-mapping.ts index c9665b70362..b66173fab2e 100644 --- a/apps/sim/lib/integrations/icon-mapping.ts +++ b/apps/sim/lib/integrations/icon-mapping.ts @@ -368,6 +368,7 @@ export const blockTypeToIconMap: Record = { convex: ConvexIcon, convex_v2: ConvexIcon, crowdstrike: CrowdStrikeIcon, + crowdstrike_v2: CrowdStrikeIcon, crunchbase: CrunchbaseIcon, crunchbase_v2: CrunchbaseIcon, cursor: CursorIcon, @@ -376,6 +377,7 @@ export const blockTypeToIconMap: Record = { databricks: DatabricksIcon, databricks_v2: DatabricksIcon, datadog: DatadogIcon, + datadog_v2: DatadogIcon, datagma: DatagmaIcon, daytona: DaytonaIcon, daytona_v2: DaytonaIcon, @@ -435,6 +437,7 @@ export const blockTypeToIconMap: Record = { gmail: GmailIcon, gmail_v2: GmailIcon, gong: GongIcon, + gong_v2: GongIcon, google_ads: GoogleAdsIcon, google_appsheet: GoogleAppsheetIcon, google_bigquery: GoogleBigQueryIcon, @@ -591,6 +594,7 @@ export const blockTypeToIconMap: Record = { plane: PlaneIcon, plane_v2: PlaneIcon, planetscale: PlanetScaleIcon, + planetscale_v2: PlanetScaleIcon, polymarket: PolymarketIcon, postgresql: PostgresIcon, posthog: PosthogIcon, @@ -630,6 +634,7 @@ export const blockTypeToIconMap: Record = { s3: S3Icon, s3_v2: S3Icon, sailpoint: SailPointIcon, + sailpoint_v2: SailPointIcon, salesforce: SalesforceIcon, sap_concur: SapConcurIcon, sap_s4hana: SapS4HanaIcon, @@ -643,7 +648,7 @@ export const blockTypeToIconMap: Record = { sentry_v2: SentryIcon, serper: SerperIcon, servicenow: ServiceNowIcon, - servicenow_v2: ServiceNowIcon, + servicenow_v3: ServiceNowIcon, ses: SESIcon, ses_v2: SESIcon, sftp: SftpIcon, @@ -732,10 +737,12 @@ export const blockTypeToIconMap: Record = { youtube: YouTubeIcon, youtube_v2: YouTubeIcon, zendesk: ZendeskIcon, + zendesk_v2: ZendeskIcon, zep: ZepIcon, zep_v2: ZepIcon, zerobounce: ZeroBounceIcon, zoho_desk: ZohoDeskIcon, zoom: ZoomIcon, zoominfo: ZoomInfoIcon, + zoominfo_v2: ZoomInfoIcon, } diff --git a/apps/sim/lib/internal/aws/credentials.test.ts b/apps/sim/lib/internal/aws/credentials.test.ts index 72f2c4d9647..4c35cb52b77 100644 --- a/apps/sim/lib/internal/aws/credentials.test.ts +++ b/apps/sim/lib/internal/aws/credentials.test.ts @@ -18,6 +18,12 @@ vi.mock('@/lib/credentials/aws-service-account.server', () => ({ resolveAwsSessionCredentials: mockResolveAwsSessionCredentials, })) +import { + ExpiredTokenException, + IDPCommunicationErrorException, + IDPRejectedClaimException, + InvalidIdentityTokenException, +} from '@aws-sdk/client-sts' import { awsClientCredentials, withAwsCredential } from '@/lib/internal/aws/credentials' function call(overrides: Partial = {}): InternalToolOperationCall { @@ -157,4 +163,53 @@ describe('withAwsCredential', () => { expect(response.status).toBe(403) expect(handler).not.toHaveBeenCalled() }) + + const stsFailure = (name: string, httpStatusCode: number) => + Object.assign(new Error(`${name} from STS`), { name, $metadata: { httpStatusCode } }) + const sdkFailure = ( + Exception: + | typeof IDPCommunicationErrorException + | typeof InvalidIdentityTokenException + | typeof ExpiredTokenException + | typeof IDPRejectedClaimException + ) => new Exception({ message: 'from STS', $metadata: { httpStatusCode: 400 } }) + + it.each([ + ['STS could not reach the OIDC issuer', sdkFailure(IDPCommunicationErrorException), 503], + ['STS throttled the exchange', stsFailure('Throttling', 400), 503], + ['STS failed on its side', stsFailure('InternalFailure', 500), 503], + [ + 'the connection to STS reset', + Object.assign(new Error('socket hang up'), { code: 'ECONNRESET' }), + 503, + ], + [ + 'the request handler wrapped a dropped socket', + new Error('Request failed', { + cause: Object.assign(new Error('other side closed'), { code: 'UND_ERR_SOCKET' }), + }), + 503, + ], + ['the role denies the session', stsFailure('AccessDenied', 403), 403], + ['STS rejects the identity token', sdkFailure(InvalidIdentityTokenException), 403], + ['the identity token expired', sdkFailure(ExpiredTokenException), 403], + ['the issuer rejected the claim', sdkFailure(IDPRejectedClaimException), 403], + ])('maps a failure where %s to the right status', async (_case, error, status) => { + mockGetAwsCredentialConfig.mockResolvedValue({ config: {}, owner: { workspaceId: 'ws-1' } }) + mockResolveAwsSessionCredentials.mockRejectedValue(error) + const handler = vi.fn() + + const response = (await withAwsCredential(handler)( + call({ + context: { + workflowId: 'wf-1', + workspaceId: 'ws-1', + authorizedServiceAccountCredential: { credentialId: 'cred-1', service: 'aws' }, + }, + }) + )) as Response + + expect(response.status).toBe(status) + expect(handler).not.toHaveBeenCalled() + }) }) diff --git a/apps/sim/lib/internal/aws/credentials.ts b/apps/sim/lib/internal/aws/credentials.ts index 8e05d583eef..eb8a3686b61 100644 --- a/apps/sim/lib/internal/aws/credentials.ts +++ b/apps/sim/lib/internal/aws/credentials.ts @@ -1,6 +1,7 @@ import { AsyncLocalStorage } from 'node:async_hooks' import { getErrorMessage } from '@sim/utils/errors' import { isRecordLike } from '@sim/utils/object' +import { isRetryableNetworkError } from '@/lib/core/errors/retryable-infrastructure' import { awsRequestHandler } from '@/lib/core/network/proxy-agent.server' import { type AwsSessionCredentials, @@ -39,16 +40,33 @@ import type { const sessionStorage = new AsyncLocalStorage() +/** The resolved STS session that signs for `accessKeyId`, if this call has one. */ +function sessionFor(accessKeyId: string): AwsSessionCredentials | undefined { + const session = sessionStorage.getStore() + return session?.sessionToken && session.accessKeyId === accessKeyId ? session : undefined +} + /** SDK credentials for a client built from an operation's key fields. */ export function awsClientCredentials( accessKeyId: string, secretAccessKey: string ): { accessKeyId: string; secretAccessKey: string; sessionToken?: string } { - const session = sessionStorage.getStore() - if (session?.sessionToken && session.accessKeyId === accessKeyId) { - return { accessKeyId, secretAccessKey, sessionToken: session.sessionToken } - } - return { accessKeyId, secretAccessKey } + const sessionToken = sessionFor(accessKeyId)?.sessionToken + return sessionToken + ? { accessKeyId, secretAccessKey, sessionToken } + : { accessKeyId, secretAccessKey } +} + +/** + * How long the STS session behind `accessKeyId` lasts and when it ends, or + * undefined for long-lived keys. Anything signed with a session, like an S3 + * presigned URL, stops working when it ends whatever expiry it asked for. + */ +export function awsSessionLifetime( + accessKeyId: string +): Pick | undefined { + const session = sessionFor(accessKeyId) + return session && { durationSeconds: session.durationSeconds, expiration: session.expiration } } /** @@ -94,6 +112,27 @@ export async function resolveAuthorizedAwsCredential( }) } +const THROTTLING_ERROR_NAMES = new Set(['Throttling', 'ThrottlingException']) + +/** + * Whether resolving a credential failed because AWS was briefly unavailable rather + * than because it denied the credential: an error STS models as retryable + * (IDPCommunicationError, when STS could not reach Sim's OIDC issuer), throttling, + * an STS 5xx, or a network failure. The SDK has already retried these. + */ +function isTransientAwsFailure(error: unknown): boolean { + if (!isRecordLike(error)) return false + const status = isRecordLike(error.$metadata) ? error.$metadata.httpStatusCode : undefined + return ( + error.$retryable !== undefined || + THROTTLING_ERROR_NAMES.has(String(error.name)) || + status === 429 || + (typeof status === 'number' && status >= 500) || + error.name === 'TimeoutError' || + isRetryableNetworkError(error) + ) +} + /** Wraps a family's operation handler so it also accepts a saved AWS credential. */ export function withAwsCredential( handler: InternalToolOperationHandler @@ -114,7 +153,7 @@ export function withAwsCredential( request.signal?.throwIfAborted() return Response.json( { error: `Could not use the AWS credential: ${getErrorMessage(error, 'Unknown error')}` }, - { status: 403 } + { status: isTransientAwsFailure(error) ? 503 : 403 } ) } request.signal?.throwIfAborted() diff --git a/apps/sim/lib/internal/crowdstrike/client.ts b/apps/sim/lib/internal/crowdstrike/client.ts index 1c4ed72526f..92c4974d3ee 100644 --- a/apps/sim/lib/internal/crowdstrike/client.ts +++ b/apps/sim/lib/internal/crowdstrike/client.ts @@ -2,7 +2,7 @@ import { toNumberOrNull, toStringOrNull } from '@sim/utils/coerce' import { isRecordLike, toRecordOrNull } from '@sim/utils/object' import { MAX_JSON_API_RESPONSE_BYTES } from '@/lib/core/security/input-validation.server' import { readResponseTextWithLimit } from '@/lib/core/utils/stream-limits' -import type { CrowdStrikeBaseParams, CrowdStrikeCloud } from '@/tools/crowdstrike/types' +import type { CrowdStrikeCloud, CrowdStrikeInlineCredentials } from '@/tools/crowdstrike/types' export type JsonRecord = Record @@ -19,6 +19,11 @@ export function getCloudBaseUrl(cloud: CrowdStrikeCloud): string { return CLOUD_BASE_URLS[cloud] } +/** Narrows an untrusted value to one of the Falcon clouds in {@link CLOUD_BASE_URLS}. */ +export function isCrowdStrikeCloud(value: unknown): value is CrowdStrikeCloud { + return typeof value === 'string' && Object.hasOwn(CLOUD_BASE_URLS, value) +} + export function getStringArray(value: unknown): string[] { if (!Array.isArray(value)) { return [] @@ -162,10 +167,11 @@ export class CrowdStrikeAuthError extends Error { } } -async function readFalconJson(response: Response): Promise { +async function readFalconJson(response: Response, signal?: AbortSignal): Promise { const text = await readResponseTextWithLimit(response, { maxBytes: MAX_JSON_API_RESPONSE_BYTES, label: 'CrowdStrike response body', + signal, }) try { @@ -175,28 +181,84 @@ async function readFalconJson(response: Response): Promise { } } -export async function getAccessToken( - params: CrowdStrikeBaseParams, - signal?: AbortSignal -): Promise { +/** Falcon's documented lifetime for an access token, used when `expires_in` is unusable. */ +const CROWDSTRIKE_TOKEN_LIFETIME_SECONDS = 30 * 60 + +/** A token response's `expires_in`, or Falcon's standard 30-minute lifetime when it is unusable. */ +function getTokenLifetimeSeconds(expiresIn: unknown): number { + return typeof expiresIn === 'number' && Number.isFinite(expiresIn) && expiresIn > 0 + ? expiresIn + : CROWDSTRIKE_TOKEN_LIFETIME_SECONDS +} + +/** API client credentials for one Falcon token request. */ +interface CrowdStrikeTokenRequest extends CrowdStrikeInlineCredentials { + /** The child CID an MSSP parent's token is locked to. */ + memberCid?: string +} + +/** + * Raised when a token request that does not follow redirects is answered with + * one. Falcon redirects a token request sent to a cloud other than the API + * client's own and names that cloud in `X-Cs-Region`; `region` carries it only + * when it is one of the Falcon clouds Sim knows. + */ +export class CrowdStrikeTokenRedirectError extends Error { + constructor( + readonly status: number, + readonly region: CrowdStrikeCloud | null + ) { + super('CrowdStrike token endpoint answered with a redirect') + this.name = 'CrowdStrikeTokenRedirectError' + } +} + +/** + * Exchanges API client credentials for a Falcon access token: + * `POST {cloud API origin}/oauth2/token` with `client_id`, `client_secret`, + * and, for an MSSP parent acting on one child, `member_cid`, as form fields. + * With `followRedirects: false` a redirect is never followed and throws + * {@link CrowdStrikeTokenRedirectError}, so the secret reaches no other host; + * the legacy block's inline path keeps following one as it always has. A + * refusal throws {@link CrowdStrikeAuthError} with Falcon's status. + * + * @see https://developer.crowdstrike.com/api-reference/collections/oauth2.md + */ +export async function requestCrowdStrikeToken( + request: CrowdStrikeTokenRequest, + options: { followRedirects: boolean; signal?: AbortSignal } +): Promise<{ token: string; expiresInSeconds: number }> { + const { followRedirects, signal } = options signal?.throwIfAborted() - const baseUrl = getCloudBaseUrl(params.cloud) - const response = await fetch(`${baseUrl}/oauth2/token`, { + const body = new URLSearchParams({ + client_id: request.clientId, + client_secret: request.clientSecret, + grant_type: 'client_credentials', + }) + if (request.memberCid) body.set('member_cid', request.memberCid) + + const response = await fetch(`${getCloudBaseUrl(request.cloud)}/oauth2/token`, { method: 'POST', headers: { Accept: 'application/json', 'Content-Type': 'application/x-www-form-urlencoded', }, - body: new URLSearchParams({ - client_id: params.clientId, - client_secret: params.clientSecret, - grant_type: 'client_credentials', - }).toString(), + body: body.toString(), cache: 'no-store', + redirect: followRedirects ? 'follow' : 'manual', signal, }) - const data = await readFalconJson(response) + if (!followRedirects && response.status >= 300 && response.status < 400) { + await response.body?.cancel().catch(() => {}) + const region = response.headers.get('x-cs-region')?.trim().toLowerCase() + throw new CrowdStrikeTokenRedirectError( + response.status, + isCrowdStrikeCloud(region) ? region : null + ) + } + + const data = await readFalconJson(response, signal) signal?.throwIfAborted() if (!response.ok) { throw new CrowdStrikeAuthError( @@ -204,12 +266,27 @@ export async function getAccessToken( response.status ) } - - if (!isRecordLike(data) || typeof data.access_token !== 'string') { + if (!isRecordLike(data) || typeof data.access_token !== 'string' || !data.access_token.trim()) { throw new CrowdStrikeAuthError('CrowdStrike authentication did not return an access token', 502) } - return data.access_token + return { + token: data.access_token, + expiresInSeconds: getTokenLifetimeSeconds(data.expires_in), + } +} + +/** + * A new token for inline (legacy block) credentials, minted for every request + * as the legacy block always has, so a token Falcon has since revoked or + * rescoped is never sent again. + */ +export async function getAccessToken( + params: CrowdStrikeInlineCredentials, + signal?: AbortSignal +): Promise { + const { token } = await requestCrowdStrikeToken(params, { followRedirects: true, signal }) + return token } interface CrowdStrikeRequestOptions { diff --git a/apps/sim/lib/internal/crowdstrike/operations.test.ts b/apps/sim/lib/internal/crowdstrike/operations.test.ts index 0429a724b29..0b918893f7f 100644 --- a/apps/sim/lib/internal/crowdstrike/operations.test.ts +++ b/apps/sim/lib/internal/crowdstrike/operations.test.ts @@ -10,6 +10,7 @@ const fetchMock = vi.fn() describe('CrowdStrike operations', () => { beforeEach(() => { + fetchMock.mockReset() vi.stubGlobal('fetch', fetchMock) }) @@ -60,4 +61,58 @@ describe('CrowdStrike operations', () => { expect(call[1]).toMatchObject({ signal: controller.signal }) } }) + + it("sends a saved credential's token to its own cloud without minting one", async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ resources: [] })) + + const result = await executeCrowdStrikeRequest({ + operation: 'crowdstrike_query_sensors', + accessToken: 'minted-token', + cloud: 'eu-1', + }) + + expect(result).toMatchObject({ ok: true }) + expect(fetchMock).toHaveBeenCalledTimes(1) + const [url, init] = fetchMock.mock.calls[0] + expect(new URL(url).origin).toBe('https://api.eu-1.crowdstrike.com') + expect(new URL(url).pathname).not.toBe('/oauth2/token') + expect(init.headers).toMatchObject({ Authorization: 'Bearer minted-token' }) + }) + + it('refuses a request carrying neither inline credentials nor a token, before any call', async () => { + await expect( + executeCrowdStrikeRequest({ operation: 'crowdstrike_query_sensors', cloud: 'us-1' }) + ).rejects.toMatchObject({ status: 400 }) + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('never sends an inline request a token Falcon has since stopped accepting', async () => { + const body = { + operation: 'crowdstrike_query_sensors' as const, + clientId: 'scoped-client', + clientSecret: 'scoped-secret', + cloud: 'us-2' as const, + } + let minted = 0 + const refusedTokens = new Set() + fetchMock.mockImplementation(async (url: string, init: RequestInit) => { + if (url.endsWith('/oauth2/token')) { + minted += 1 + return jsonResponse({ access_token: `token-${minted}`, expires_in: 1799 }) + } + const auth = (init.headers as Record).Authorization + return refusedTokens.has(auth) + ? jsonResponse({ errors: [{ code: 403, message: 'access denied' }] }, 403) + : jsonResponse({ resources: [] }) + }) + + await expect(executeCrowdStrikeRequest(body)).resolves.toMatchObject({ ok: true }) + refusedTokens.add('Bearer token-1') + await expect(executeCrowdStrikeRequest(body)).resolves.toMatchObject({ ok: true }) + const tokenCalls = fetchMock.mock.calls.filter(([url]) => String(url).endsWith('/oauth2/token')) + expect(tokenCalls.map(([url]) => url)).toEqual([ + 'https://api.us-2.crowdstrike.com/oauth2/token', + 'https://api.us-2.crowdstrike.com/oauth2/token', + ]) + }) }) diff --git a/apps/sim/lib/internal/crowdstrike/operations.ts b/apps/sim/lib/internal/crowdstrike/operations.ts index d45d2d1f792..b18d79427f8 100644 --- a/apps/sim/lib/internal/crowdstrike/operations.ts +++ b/apps/sim/lib/internal/crowdstrike/operations.ts @@ -5,6 +5,7 @@ import { truncate } from '@sim/utils/string' import type { CrowdstrikeQueryBody } from '@/lib/api/contracts/tools/crowdstrike' import { buildUrl, + CrowdStrikeAuthError, type CrowdStrikeCallResult, callCrowdStrike, getAccessToken, @@ -31,6 +32,7 @@ import { } from '@/lib/internal/crowdstrike/normalizers' import type { CrowdStrikeActionParameter, + CrowdStrikeInlineCredentials, CrowdStrikeQuerySensorsParams, CrowdStrikeSensorAggregateBucket, CrowdStrikeSensorAggregateResult, @@ -1000,13 +1002,25 @@ export async function executeCrowdStrikeOperation( } } +function inlineCredentials(body: CrowdstrikeQueryBody): CrowdStrikeInlineCredentials { + if (!body.clientId || !body.clientSecret) { + throw new CrowdStrikeAuthError('CrowdStrike Client ID and Client Secret are required', 400) + } + return { clientId: body.clientId, clientSecret: body.clientSecret, cloud: body.cloud } +} + +/** + * Runs one CrowdStrike operation against `body.cloud`. A token minted from a + * saved credential (`accessToken`) is used as is; inline credentials mint a + * new token for the request. + */ export async function executeCrowdStrikeRequest( body: CrowdstrikeQueryBody, signal?: AbortSignal ): Promise { signal?.throwIfAborted() const baseUrl = getCloudBaseUrl(body.cloud) - const accessToken = await getAccessToken(body, signal) + const accessToken = body.accessToken || (await getAccessToken(inlineCredentials(body), signal)) signal?.throwIfAborted() logger.info('CrowdStrike request', { @@ -1014,13 +1028,9 @@ export async function executeCrowdStrikeRequest( operation: body.operation, }) - if ( - body.operation === 'crowdstrike_query_sensors' || + return body.operation === 'crowdstrike_query_sensors' || body.operation === 'crowdstrike_get_sensor_details' || body.operation === 'crowdstrike_get_sensor_aggregates' - ) { - return executeSensorOperation(body, baseUrl, accessToken, signal) - } - - return executeCrowdStrikeOperation(body, baseUrl, accessToken, signal) + ? executeSensorOperation(body, baseUrl, accessToken, signal) + : executeCrowdStrikeOperation(body, baseUrl, accessToken, signal) } diff --git a/apps/sim/lib/internal/s3/operations.test.ts b/apps/sim/lib/internal/s3/operations.test.ts index c5e1b443f1b..edd6899adfc 100644 --- a/apps/sim/lib/internal/s3/operations.test.ts +++ b/apps/sim/lib/internal/s3/operations.test.ts @@ -7,7 +7,7 @@ import { filesAuthorizationMock, filesAuthorizationMockFns, } from '@sim/testing/mocks/files-authorization.mock' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ createS3Client: vi.fn(), @@ -26,8 +26,9 @@ vi.mock('@/app/api/files/authorization', () => filesAuthorizationMock) vi.mock('@/lib/uploads/utils/file', () => fileUtilsMock) vi.mock('@/lib/uploads/utils/file.server', () => fileUtilsServerMock) +import { runWithAwsSession } from '@/lib/internal/aws/credentials' import { S3OperationError } from '@/lib/internal/s3/errors' -import { executeS3PutObject } from '@/lib/internal/s3/operations' +import { executeS3PresignedUrl, executeS3PutObject } from '@/lib/internal/s3/operations' const { mockAssertToolFileAccess } = filesAuthorizationMockFns const { mockDownloadServableFileFromStorage } = fileUtilsServerMockFns @@ -78,3 +79,70 @@ describe('S3 operations', () => { expect(mocks.createS3Client).not.toHaveBeenCalled() }) }) + +describe('S3 presigned URLs', () => { + const NOW = new Date('2030-01-01T00:00:00.000Z') + const SESSION = { + accessKeyId: 'ASIASESSIONKEY', + secretAccessKey: 'session-secret', + sessionToken: 'session-token', + durationSeconds: 3600, + expiration: new Date(NOW.getTime() + 3600 * 1000), + } + const presign = (accessKeyId: string, expiresIn: number) => + executeS3PresignedUrl({ + accessKeyId, + secretAccessKey: 'secret', + region: 'us-east-1', + bucketName: 'bucket', + objectKey: 'report.csv', + method: 'get', + expiresIn, + }) + + beforeEach(() => { + vi.useFakeTimers({ toFake: ['Date'] }) + vi.setSystemTime(NOW) + mocks.createS3Client.mockReturnValue({ send: mocks.send, destroy: mocks.destroy }) + mocks.getSignedUrl.mockResolvedValue( + 'https://bucket.s3.amazonaws.com/report.csv?X-Amz-Signature=x' + ) + }) + afterEach(() => vi.useRealTimers()) + + it('refuses an expiry past the end of the role session that signs it', async () => { + await expect( + runWithAwsSession(SESSION, () => presign(SESSION.accessKeyId, 7200)) + ).rejects.toMatchObject({ + status: 400, + message: expect.stringMatching(/3600 seconds.*session duration/), + }) + expect(mocks.getSignedUrl).not.toHaveBeenCalled() + }) + + it('allows the full session duration after the time spent minting the session', async () => { + vi.setSystemTime(NOW.getTime() + 750) + + const result = await runWithAwsSession(SESSION, () => presign(SESSION.accessKeyId, 3600)) + + expect(mocks.getSignedUrl).toHaveBeenCalledWith(expect.anything(), expect.anything(), { + expiresIn: 3600, + }) + expect(result.output.expiresAt).toBe(SESSION.expiration.toISOString()) + }) + + it('reports an expiry the role session outlives', async () => { + const result = await runWithAwsSession(SESSION, () => presign(SESSION.accessKeyId, 3600)) + + expect(result.output.expiresAt).toBe('2030-01-01T01:00:00.000Z') + expect(mocks.getSignedUrl).toHaveBeenCalledWith(expect.anything(), expect.anything(), { + expiresIn: 3600, + }) + }) + + it('keeps the full seven days for long-lived access keys', async () => { + const result = await presign('AKIASTATICKEY', 604800) + + expect(result.output.expiresAt).toBe('2030-01-08T00:00:00.000Z') + }) +}) diff --git a/apps/sim/lib/internal/s3/operations.ts b/apps/sim/lib/internal/s3/operations.ts index 948b89d2435..56a82eb2aff 100644 --- a/apps/sim/lib/internal/s3/operations.ts +++ b/apps/sim/lib/internal/s3/operations.ts @@ -28,6 +28,7 @@ import type { AwsS3ListObjectsBody } from '@/lib/api/contracts/tools/aws/s3-list import type { AwsS3PresignedUrlBody } from '@/lib/api/contracts/tools/aws/s3-presigned-url' import type { AwsS3PutObjectBody } from '@/lib/api/contracts/tools/aws/s3-put-object' import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits' +import { awsSessionLifetime } from '@/lib/internal/aws/credentials' import { createS3Client, type S3ConnectionConfig } from '@/lib/internal/s3/client' import { S3OperationError } from '@/lib/internal/s3/errors' import { MAX_BUFFERED_TRANSFER_BYTES } from '@/lib/uploads/shared/types' @@ -280,7 +281,27 @@ export async function executeS3ListObjects(input: AwsS3ListObjectsBody, signal?: }) } +/** + * A URL signed with a saved role's STS session stops working when that session + * ends. The session is minted for this call, so its full duration is the + * longest expiry the URL can keep; comparing against the duration rather than + * the time left keeps mint latency and clock skew from refusing the default + * expiry on a default-length session. + */ export async function executeS3PresignedUrl(input: AwsS3PresignedUrlBody, signal?: AbortSignal) { + const now = Date.now() + const session = awsSessionLifetime(input.accessKeyId) + const sessionSeconds = session?.durationSeconds + if (sessionSeconds !== undefined && input.expiresIn > sessionSeconds) { + throw new S3OperationError( + `This presigned URL would stop working when the AWS role session that signs it ends, after ${sessionSeconds} seconds. Set an expiry of ${sessionSeconds} seconds or less, or give the credential a longer session duration.`, + 400 + ) + } + const requestedEnd = now + input.expiresIn * 1000 + const expiresAt = session?.expiration + ? Math.min(requestedEnd, session.expiration.getTime()) + : requestedEnd return withS3Client(input, async (client) => { signal?.throwIfAborted() const command = @@ -299,7 +320,7 @@ export async function executeS3PresignedUrl(input: AwsS3PresignedUrlBody, signal url, method: input.method, expiresIn: input.expiresIn, - expiresAt: new Date(Date.now() + input.expiresIn * 1000).toISOString(), + expiresAt: new Date(expiresAt).toISOString(), }, } }) diff --git a/apps/sim/lib/internal/sailpoint/client.ts b/apps/sim/lib/internal/sailpoint/client.ts index 95160617558..43d721cbfe5 100644 --- a/apps/sim/lib/internal/sailpoint/client.ts +++ b/apps/sim/lib/internal/sailpoint/client.ts @@ -10,12 +10,32 @@ import { readResponseTextWithLimit, } from '@/lib/core/utils/stream-limits' +/** API client credentials typed inline on the legacy block. */ export interface SailPointCredentials { clientId: string clientSecret: string tenant: string } +/** A token minted from a saved SailPoint credential, with the tenant host it was minted for. */ +interface SailPointTokenCredentials { + accessToken: string + tenant: string +} + +export type SailPointAuth = SailPointCredentials | SailPointTokenCredentials + +/** Raised when the tenant's token endpoint refuses the exchange, carrying its HTTP status. */ +export class SailPointAuthError extends Error { + constructor( + message: string, + readonly status: number + ) { + super(message) + this.name = 'SailPointAuthError' + } +} + export interface SailPointHosts { apiBaseUrl: string host: string @@ -157,11 +177,20 @@ async function readBoundedBody( } } -async function exchangeAccessToken( - credentials: SailPointCredentials, +/** + * Exchanges API client credentials for an access token at a tenant's token + * endpoint (`client_credentials` grant, credentials in the form body), with + * redirects refused and up to four backoff retries on 429. A refusal throws + * {@link SailPointAuthError} with the provider's status and message. + * + * @see https://developer.sailpoint.com/docs/api/authentication + */ +export async function requestSailPointToken( + tokenUrl: string, + clientId: string, + clientSecret: string, signal: AbortSignal -): Promise<{ token: string; ttlMs: number }> { - const { tokenUrl } = resolveSailPointHosts(credentials.tenant) +): Promise<{ token: string; expiresInSeconds: number }> { let attempt = 0 while (true) { @@ -174,8 +203,8 @@ async function exchangeAccessToken( }, body: new URLSearchParams({ grant_type: 'client_credentials', - client_id: credentials.clientId, - client_secret: credentials.clientSecret, + client_id: clientId, + client_secret: clientSecret, }).toString(), cache: 'no-store', redirect: 'error', @@ -197,23 +226,39 @@ async function exchangeAccessToken( signal ) if (!response.ok) { - throw new Error(getSailPointErrorMessage(data, 'Failed to authenticate with SailPoint')) + throw new SailPointAuthError( + getSailPointErrorMessage(data, 'Failed to authenticate with SailPoint'), + response.status + ) } if (!isRecordLike(data) || typeof data.access_token !== 'string' || !data.access_token) { throw new Error('SailPoint authentication did not return an access token') } const parsedExpiry = Number(data.expires_in) - const expiresInSeconds = Number.isFinite(parsedExpiry) && parsedExpiry > 0 ? parsedExpiry : 3600 - const bufferMs = Math.min(TOKEN_EXPIRY_BUFFER_MS, expiresInSeconds * 100) - // lru-cache reads a TTL of 0 as "never expires", so keep at least 1ms. return { token: data.access_token, - ttlMs: Math.max(Math.floor(expiresInSeconds * 1000 - bufferMs), 1), + expiresInSeconds: Number.isFinite(parsedExpiry) && parsedExpiry > 0 ? parsedExpiry : 3600, } } } +async function exchangeAccessToken( + credentials: SailPointCredentials, + signal: AbortSignal +): Promise<{ token: string; ttlMs: number }> { + const { tokenUrl } = resolveSailPointHosts(credentials.tenant) + const { token, expiresInSeconds } = await requestSailPointToken( + tokenUrl, + credentials.clientId, + credentials.clientSecret, + signal + ) + const bufferMs = Math.min(TOKEN_EXPIRY_BUFFER_MS, expiresInSeconds * 100) + // lru-cache reads a TTL of 0 as "never expires", so keep at least 1ms. + return { token, ttlMs: Math.max(Math.floor(expiresInSeconds * 1000 - bufferMs), 1) } +} + export function invalidateSailPointToken(credentials: SailPointCredentials): void { tokenCache.delete(credentialsCacheKey(credentials)) } @@ -232,8 +277,13 @@ export async function getSailPointAccessToken( return token } +/** + * Sends one authenticated request to the tenant. A saved credential's token + * (`accessToken`) is sent as is; inline credentials use a cached token and, + * when the tenant rejects it, mint once more and retry. + */ export async function sailpointFetch( - credentials: SailPointCredentials, + credentials: SailPointAuth, buildRequest: (hosts: SailPointHosts) => { init: RequestInit; url: string }, options: { maxRetries?: number; signal?: AbortSignal } = {} ): Promise { @@ -244,7 +294,11 @@ export async function sailpointFetch( while (true) { options.signal?.throwIfAborted() - const token = await getSailPointAccessToken(credentials, options.signal) + const inline = 'accessToken' in credentials ? undefined : credentials + const token = + 'accessToken' in credentials + ? credentials.accessToken + : await getSailPointAccessToken(credentials, options.signal) const { init, url } = buildRequest(hosts) const headers = new Headers(init.headers) headers.set('Authorization', `Bearer ${token}`) @@ -258,9 +312,9 @@ export async function sailpointFetch( signal: options.signal, }) - if (response.status === 401 && !refreshedOn401) { + if (response.status === 401 && inline && !refreshedOn401) { await consumeOrCancelBody(response, DEFAULT_MAX_ERROR_BODY_BYTES) - invalidateSailPointToken(credentials) + invalidateSailPointToken(inline) refreshedOn401 = true continue } diff --git a/apps/sim/lib/internal/sailpoint/execute-tool.test.ts b/apps/sim/lib/internal/sailpoint/execute-tool.test.ts index 229091f672e..ec46bfc3903 100644 --- a/apps/sim/lib/internal/sailpoint/execute-tool.test.ts +++ b/apps/sim/lib/internal/sailpoint/execute-tool.test.ts @@ -121,4 +121,45 @@ describe('SailPoint internal tool handler', () => { expect(mockProcessFilesToUserFiles).not.toHaveBeenCalled() expect(mockFetch).not.toHaveBeenCalled() }) + + it("sends a saved credential's token to its tenant without an exchange, and never re-mints on a 401", async () => { + mockFetch + .mockResolvedValueOnce(Response.json({ id: 'identity' })) + .mockResolvedValueOnce(Response.json({ error: 'expired token' }, { status: 401 })) + const tokenInput = { accessToken: 'minted-token', tenant: 'acme.api.identitynow.com' } + const call = () => + executeSailPointTool({ + toolId: 'sailpoint_get_identity', + input: { ...tokenInput, operation: 'sailpoint_get_identity', id: 'identity' }, + headers: new Headers(), + context: { workflowId: 'workflow' }, + requestId: 'request-id', + }) + + expect((await call()).status).toBe(200) + expect((await call()).status).toBe(401) + + expect(mockFetch).toHaveBeenCalledTimes(2) + for (const [url, init] of mockFetch.mock.calls) { + expect(new URL(String(url)).host).toBe('acme.api.identitynow.com') + expect(new URL(String(url)).pathname).not.toBe('/oauth/token') + expect(new Headers(init?.headers).get('Authorization')).toBe('Bearer minted-token') + } + }) + + it('refuses input carrying neither inline credentials nor a token before any call', async () => { + const response = await executeSailPointTool({ + toolId: 'sailpoint_get_identity', + input: { tenant: 'acme', operation: 'sailpoint_get_identity', id: 'identity' }, + headers: new Headers(), + context: { workflowId: 'workflow' }, + requestId: 'request-id', + }) + + expect(response.status).toBe(400) + await expect(response.json()).resolves.toMatchObject({ + error: expect.stringContaining('Client ID is required'), + }) + expect(mockFetch).not.toHaveBeenCalled() + }) }) diff --git a/apps/sim/lib/internal/sailpoint/operations.ts b/apps/sim/lib/internal/sailpoint/operations.ts index 21b8b677201..3ed6105282b 100644 --- a/apps/sim/lib/internal/sailpoint/operations.ts +++ b/apps/sim/lib/internal/sailpoint/operations.ts @@ -5,7 +5,7 @@ import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits' import { getSailPointErrorMessage, readTotalCount, - type SailPointCredentials, + type SailPointAuth, type SailPointFetchResult, type SailPointHosts, sailpointFetch, @@ -239,7 +239,7 @@ function outputForResult(result: SailPointFetchResult, kind: ResultKind): Respon } async function executeRequest( - credentials: SailPointCredentials, + credentials: SailPointAuth, context: SailPointOperationContext, buildRequest: (hosts: SailPointHosts) => { init: RequestInit; url: string }, kind: ResultKind @@ -250,7 +250,7 @@ async function executeRequest( async function executeLoad( input: InputRecord, - credentials: SailPointCredentials, + credentials: SailPointAuth, context: SailPointOperationContext ): Promise { context.signal?.throwIfAborted() @@ -341,11 +341,13 @@ export async function executeSailPointOperation( context: SailPointOperationContext ): Promise { const input = parsedInput as InputRecord - const credentials: SailPointCredentials = { - clientId: String(input.clientId), - clientSecret: String(input.clientSecret), - tenant: String(input.tenant), - } + const credentials: SailPointAuth = input.accessToken + ? { accessToken: String(input.accessToken), tenant: String(input.tenant) } + : { + clientId: String(input.clientId), + clientSecret: String(input.clientSecret), + tenant: String(input.tenant), + } switch (input.operation) { case 'sailpoint_search': { diff --git a/apps/sim/lib/internal/sailpoint/schema.ts b/apps/sim/lib/internal/sailpoint/schema.ts index abde70b0b8b..bb3b7b39c76 100644 --- a/apps/sim/lib/internal/sailpoint/schema.ts +++ b/apps/sim/lib/internal/sailpoint/schema.ts @@ -24,12 +24,30 @@ const requiredString = (label: string, max = MAX_ID_LENGTH) => const optionalString = (max = MAX_FILTER_LENGTH) => z.string().max(max).optional() +/** + * Inline API client credentials, or the token a saved SailPoint credential + * minted for `tenant` in their place; {@link operationSchema} requires one or + * the other. + */ const baseFields = { - clientId: requiredString('Client ID'), - clientSecret: requiredString('Client Secret', 8192), + clientId: requiredString('Client ID').optional(), + clientSecret: requiredString('Client Secret', 8192).optional(), + accessToken: requiredString('Access token', 16_384).optional(), tenant: requiredString('Tenant', 253), } +function requireAuth( + value: { accessToken?: unknown; clientId?: unknown; clientSecret?: unknown }, + ctx: z.RefinementCtx +): void { + if (value.accessToken) return + if (!value.clientId) + ctx.addIssue({ code: 'custom', path: ['clientId'], message: 'Client ID is required' }) + if (!value.clientSecret) { + ctx.addIssue({ code: 'custom', path: ['clientSecret'], message: 'Client Secret is required' }) + } +} + const offsetField = z.coerce.number().int().min(0).optional() const countField = z.boolean().optional() const limitField = (max: number) => z.coerce.number().int().min(0).max(max).optional() @@ -203,7 +221,9 @@ const reviewDecisionSchema = z }) function operationSchema(operation: T, fields: S) { - return z.object({ ...baseFields, operation: z.literal(operation), ...fields }) + return z + .object({ ...baseFields, operation: z.literal(operation), ...fields }) + .superRefine(requireAuth) } const schemas = { diff --git a/apps/sim/lib/internal/twilio-voice/operations.test.ts b/apps/sim/lib/internal/twilio-voice/operations.test.ts index bfe505fd4d7..abd43ee52a2 100644 --- a/apps/sim/lib/internal/twilio-voice/operations.test.ts +++ b/apps/sim/lib/internal/twilio-voice/operations.test.ts @@ -73,4 +73,20 @@ describe('getTwilioRecording', () => { output: { duration: 42, transcriptionText: 'hello', file: storedFile }, }) }) + it('authenticates every request with an API key as its SID and secret', async () => { + const apiKey = `SK${'b'.repeat(32)}:key-secret` + await getTwilioRecording( + { accountSid: 'AC123', authToken: apiKey, recordingSid: 'RE123' }, + { requestId: 'request-1' } + ) + + expect(mockSecureFetchWithPinnedIP).toHaveBeenCalledTimes(3) + for (const [, , init] of mockSecureFetchWithPinnedIP.mock.calls) { + expect(init.headers).toEqual( + expect.objectContaining({ + Authorization: `Basic ${Buffer.from(apiKey).toString('base64')}`, + }) + ) + } + }) }) diff --git a/apps/sim/lib/internal/twilio-voice/operations.ts b/apps/sim/lib/internal/twilio-voice/operations.ts index 647b39f009e..07749f689b2 100644 --- a/apps/sim/lib/internal/twilio-voice/operations.ts +++ b/apps/sim/lib/internal/twilio-voice/operations.ts @@ -15,6 +15,7 @@ import { import { TwilioVoiceOperationError } from '@/lib/internal/twilio-voice/errors' import { MAX_BUFFERED_TRANSFER_BYTES } from '@/lib/uploads/shared/types' import { getExtensionFromMimeType } from '@/lib/uploads/utils/file' +import { twilioAuthorization } from '@/tools/twilio/utils' import type { TwilioGetRecordingParams } from '@/tools/twilio_voice/types' const logger = createLogger('TwilioGetRecordingOperation') @@ -79,7 +80,7 @@ export async function getTwilioRecording( 400 ) } - const authHeader = `Basic ${Buffer.from(`${input.accountSid}:${input.authToken}`).toString('base64')}` + const authHeader = twilioAuthorization(input.accountSid, input.authToken) const infoUrl = `https://api.twilio.com/2010-04-01/Accounts/${encodeURIComponent(input.accountSid)}/Recordings/${encodeURIComponent(input.recordingSid)}.json` const infoResponse = await fetchPinned( infoUrl, diff --git a/apps/sim/lib/internal/zoominfo/client.test.ts b/apps/sim/lib/internal/zoominfo/client.test.ts new file mode 100644 index 00000000000..deae32584f4 --- /dev/null +++ b/apps/sim/lib/internal/zoominfo/client.test.ts @@ -0,0 +1,53 @@ +import { + inputValidationMock, + inputValidationMockFns, +} from '@sim/testing/mocks/input-validation.mock' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock) + +import { requestZoomInfo } from '@/lib/internal/zoominfo/client' + +const { mockSecureFetchWithValidation } = inputValidationMockFns +const REQUEST = { path: '/data/v1/companies/search', method: 'POST' as const, body: {} } + +describe('ZoomInfo requests', () => { + beforeEach(() => { + mockSecureFetchWithValidation.mockReset() + }) + + it("sends a saved credential's token to the API without a token exchange", async () => { + mockSecureFetchWithValidation.mockResolvedValue(Response.json({ data: [] })) + + await expect( + requestZoomInfo({ ...REQUEST, accessToken: 'minted-token' }, 'request-1') + ).resolves.toEqual({ status: 200, data: { data: [] } }) + + expect(mockSecureFetchWithValidation).toHaveBeenCalledTimes(1) + const [url, options] = mockSecureFetchWithValidation.mock.calls[0] + expect(url).toBe('https://api.zoominfo.com/gtm/data/v1/companies/search') + expect(options).toMatchObject({ maxRedirects: 0 }) + expect(options.headers.Authorization).toBe('Bearer minted-token') + }) + + it('refuses a request carrying neither inline credentials nor a token, before any call', async () => { + await expect( + requestZoomInfo({ ...REQUEST, clientId: 'client-only' }, 'request-2') + ).rejects.toMatchObject({ status: 400 }) + expect(mockSecureFetchWithValidation).not.toHaveBeenCalled() + }) + + it('keeps reporting an inline token refusal exactly as before', async () => { + mockSecureFetchWithValidation.mockResolvedValue( + Response.json({ error: 'invalid_client' }, { status: 401 }) + ) + + await expect( + requestZoomInfo({ ...REQUEST, clientId: 'client', clientSecret: 'secret' }, 'request-3') + ).rejects.toMatchObject({ + message: 'ZoomInfo token request failed: HTTP 401', + status: 500, + providerStatus: undefined, + }) + }) +}) diff --git a/apps/sim/lib/internal/zoominfo/client.ts b/apps/sim/lib/internal/zoominfo/client.ts index d98e19ea868..f9c75e17bb7 100644 --- a/apps/sim/lib/internal/zoominfo/client.ts +++ b/apps/sim/lib/internal/zoominfo/client.ts @@ -18,6 +18,8 @@ const OUTBOUND_FETCH_TIMEOUT_MS = 30_000 const MAX_TOKEN_RESPONSE_BYTES = 256 * 1024 const TOKEN_CACHE_MAX_ENTRIES = 500 const TOKEN_SAFETY_WINDOW_MS = 60_000 +/** Lifetime assumed when a token response carries no usable `expires_in`. */ +const DEFAULT_TOKEN_LIFETIME_SECONDS = 3300 interface CachedToken { accessToken: string @@ -42,7 +44,37 @@ export class ZoomInfoOperationError extends Error { } } -function tokenCacheKey(auth: ZoomInfoAuth): string { +/** + * Raised when ZoomInfo's token endpoint refuses an exchange. Answers the + * operation exactly as every token failure always has (HTTP 500 naming the + * provider status), and carries that status for the saved-credential minter. + */ +export class ZoomInfoTokenError extends ZoomInfoOperationError { + constructor(readonly tokenStatus: number) { + super(`ZoomInfo token request failed: HTTP ${tokenStatus}`, 500) + this.name = 'ZoomInfoTokenError' + } +} + +interface ZoomInfoClientCredentials { + clientId: string + clientSecret: string +} + +/** + * The token a saved credential minted, or the inline client credentials to + * mint one with. Refuses input carrying neither. + */ +function resolveZoomInfoAuth( + auth: ZoomInfoAuth +): { accessToken: string } | { credentials: ZoomInfoClientCredentials } { + if (auth.accessToken) return { accessToken: auth.accessToken } + if (!auth.clientId) throw new ZoomInfoOperationError('clientId is required', 400) + if (!auth.clientSecret) throw new ZoomInfoOperationError('clientSecret is required', 400) + return { credentials: { clientId: auth.clientId, clientSecret: auth.clientSecret } } +} + +function tokenCacheKey(auth: ZoomInfoClientCredentials): string { const secretHash = createHash('sha256').update(auth.clientSecret).digest('hex').slice(0, 16) return `${auth.clientId}::${secretHash}` } @@ -57,18 +89,20 @@ function rememberToken(key: string, token: CachedToken): void { } } -async function fetchAccessToken( - auth: ZoomInfoAuth, - requestId: string, - signal?: AbortSignal -): Promise { +/** + * Exchanges client credentials for an access token with the client + * credentials grant at ZoomInfo's token endpoint, credentials in HTTP Basic + * auth as ZoomInfo recommends, redirects refused. A refusal throws + * {@link ZoomInfoTokenError}. + * + * @see https://docs.zoominfo.com/docs/client-credentials-flow + */ +export async function requestZoomInfoToken( + auth: ZoomInfoClientCredentials, + options: { requestId?: string; signal?: AbortSignal } = {} +): Promise<{ accessToken: string; expiresInSeconds: number; scope?: string }> { + const { requestId, signal } = options signal?.throwIfAborted() - const cacheKey = tokenCacheKey(auth) - const cached = TOKEN_CACHE.get(cacheKey) - if (cached && cached.expiresAt - TOKEN_SAFETY_WINDOW_MS > Date.now()) { - return cached.accessToken - } - const tokenUrl = assertSafeZoomInfoUrl(ZOOMINFO_TOKEN_URL, 'tokenUrl').toString() const basic = Buffer.from(`${auth.clientId}:${auth.clientSecret}`).toString('base64') const response = await secureFetchWithValidation( @@ -84,6 +118,7 @@ async function fetchAccessToken( body: new URLSearchParams({ grant_type: 'client_credentials' }).toString(), timeout: OUTBOUND_FETCH_TIMEOUT_MS, maxResponseBytes: MAX_TOKEN_RESPONSE_BYTES, + maxRedirects: 0, signal, }, 'tokenUrl' @@ -93,17 +128,44 @@ async function fetchAccessToken( if (!response.ok) { const text = await response.text().catch(() => '') logger.warn('ZoomInfo token fetch failed', { requestId, status: response.status, error: text }) - throw new ZoomInfoOperationError(`ZoomInfo token request failed: HTTP ${response.status}`, 500) + throw new ZoomInfoTokenError(response.status) + } + const data = (await response.json()) as { + access_token?: string + expires_in?: number + scope?: string } - const data = (await response.json()) as { access_token?: string; expires_in?: number } if (!data.access_token) { throw new ZoomInfoOperationError('ZoomInfo token response missing access_token', 500) } - rememberToken(cacheKey, { + return { accessToken: data.access_token, - expiresAt: Date.now() + (data.expires_in ?? 3300) * 1000, + expiresInSeconds: + typeof data.expires_in === 'number' && Number.isFinite(data.expires_in) && data.expires_in > 0 + ? data.expires_in + : DEFAULT_TOKEN_LIFETIME_SECONDS, + ...(typeof data.scope === 'string' ? { scope: data.scope } : {}), + } +} + +async function fetchAccessToken( + auth: ZoomInfoClientCredentials, + requestId: string, + signal?: AbortSignal +): Promise { + signal?.throwIfAborted() + const cacheKey = tokenCacheKey(auth) + const cached = TOKEN_CACHE.get(cacheKey) + if (cached && cached.expiresAt - TOKEN_SAFETY_WINDOW_MS > Date.now()) { + return cached.accessToken + } + + const { accessToken, expiresInSeconds } = await requestZoomInfoToken(auth, { + requestId, + signal, }) - return data.access_token + rememberToken(cacheKey, { accessToken, expiresAt: Date.now() + expiresInSeconds * 1000 }) + return accessToken } function buildApiUrl(input: ZoomInfoProviderRequest): string { @@ -174,6 +236,7 @@ async function invokeZoomInfo( : undefined, timeout: OUTBOUND_FETCH_TIMEOUT_MS, maxResponseBytes: MAX_JSON_API_RESPONSE_BYTES, + maxRedirects: 0, signal, }, 'apiUrl' @@ -196,7 +259,11 @@ export async function requestZoomInfo( requestId: string, signal?: AbortSignal ): Promise<{ status: number; data: unknown }> { - const accessToken = await fetchAccessToken(input, requestId, signal) + const auth = resolveZoomInfoAuth(input) + const accessToken = + 'accessToken' in auth + ? auth.accessToken + : await fetchAccessToken(auth.credentials, requestId, signal) const invocation = await invokeZoomInfo(input, accessToken, signal) if (invocation.status < 200 || invocation.status >= 300) { throw new ZoomInfoOperationError( diff --git a/apps/sim/lib/internal/zoominfo/operations.ts b/apps/sim/lib/internal/zoominfo/operations.ts index 5e0e6f54cec..e5c04378d93 100644 --- a/apps/sim/lib/internal/zoominfo/operations.ts +++ b/apps/sim/lib/internal/zoominfo/operations.ts @@ -1,6 +1,7 @@ import { getErrorMessage } from '@sim/utils/errors' import { requestZoomInfo } from '@/lib/internal/zoominfo/client' import { + type ZoomInfoAuth, type ZoomInfoEnrichCompaniesInput, type ZoomInfoEnrichContactsInput, type ZoomInfoProviderRequest, @@ -107,8 +108,12 @@ function toNumberOrUndefined(value: unknown): number | undefined { return Number.isFinite(number) ? number : undefined } -function authInput(input: { clientId: string; clientSecret: string }) { - return { clientId: input.clientId, clientSecret: input.clientSecret } +function authInput(input: ZoomInfoAuth) { + return { + clientId: input.clientId, + clientSecret: input.clientSecret, + accessToken: input.accessToken, + } } function paginationQuery(page: unknown, rpp: unknown): Record | undefined { diff --git a/apps/sim/lib/internal/zoominfo/schema.ts b/apps/sim/lib/internal/zoominfo/schema.ts index 60f57e34a1f..54b79bc412b 100644 --- a/apps/sim/lib/internal/zoominfo/schema.ts +++ b/apps/sim/lib/internal/zoominfo/schema.ts @@ -4,9 +4,14 @@ import { z } from 'zod' export const ZOOMINFO_API_BASE = 'https://api.zoominfo.com/gtm' export const ZOOMINFO_TOKEN_URL = `${ZOOMINFO_API_BASE}/oauth/v1/token` +/** + * Inline client credentials, or the token a saved ZoomInfo credential minted + * in their place. `resolveZoomInfoAuth` requires one or the other. + */ export const zoomInfoAuthSchema = z.object({ - clientId: z.string().min(1, 'clientId is required'), - clientSecret: z.string().min(1, 'clientSecret is required'), + clientId: z.string().min(1, 'clientId is required').optional(), + clientSecret: z.string().min(1, 'clientSecret is required').optional(), + accessToken: z.string().min(1).max(16_384).optional(), }) export const zoomInfoProviderRequestSchema = zoomInfoAuthSchema.extend({ diff --git a/apps/sim/lib/oauth/credential-service-cancellation.test.ts b/apps/sim/lib/oauth/credential-service-cancellation.test.ts index d50e3357828..c6e2f83eba8 100644 --- a/apps/sim/lib/oauth/credential-service-cancellation.test.ts +++ b/apps/sim/lib/oauth/credential-service-cancellation.test.ts @@ -8,7 +8,10 @@ import { RAMP_SERVICE_ACCOUNT_PROVIDER_ID, } from '@/lib/credentials/client-credential-accounts/descriptors' import * as clientAccounts from '@/lib/credentials/client-credential-accounts/server' -import { resolveServiceAccountToken } from '@/lib/oauth/credential-service' +import { + forgetRejectedClientCredentialToken, + resolveServiceAccountToken, +} from '@/lib/oauth/credential-service' vi.mock('@/lib/core/security/encryption', () => encryptionMock) @@ -84,3 +87,42 @@ describe('client credential cancellation isolation', () => { ).resolves.toEqual({ accessToken: token.accessToken }) }) }) + +describe('client credential token cache', () => { + it('serves the region and application key a token was minted with along with the cached token', async () => { + const mint = vi.fn() + mint.mockResolvedValue({ ...token, domain: 'eu-1', applicationKey: 'application-key' }) + vi.spyOn(clientAccounts, 'getClientCredentialAccountMinter').mockReturnValue(mint) + + for (let call = 0; call < 2; call++) { + queueTableRows(credential, [storedCredential]) + await expect( + resolveServiceAccountToken('ramp-cached-region', RAMP_SERVICE_ACCOUNT_PROVIDER_ID) + ).resolves.toMatchObject({ + accessToken: token.accessToken, + domain: 'eu-1', + applicationKey: 'application-key', + }) + } + expect(mint).toHaveBeenCalledTimes(1) + }) + + it('mints a replacement once the provider rejects the cached token, and only that token', async () => { + const mint = vi.fn() + mint + .mockResolvedValueOnce({ ...token, accessToken: 'rejected-token' }) + .mockResolvedValueOnce({ ...token, accessToken: 'replacement-token' }) + vi.spyOn(clientAccounts, 'getClientCredentialAccountMinter').mockReturnValue(mint) + const resolve = () => { + queueTableRows(credential, [storedCredential]) + return resolveServiceAccountToken('ramp-rejected', RAMP_SERVICE_ACCOUNT_PROVIDER_ID) + } + + await expect(resolve()).resolves.toEqual({ accessToken: 'rejected-token' }) + forgetRejectedClientCredentialToken('rejected-token') + await expect(resolve()).resolves.toEqual({ accessToken: 'replacement-token' }) + forgetRejectedClientCredentialToken('rejected-token') + await expect(resolve()).resolves.toEqual({ accessToken: 'replacement-token' }) + expect(mint).toHaveBeenCalledTimes(2) + }) +}) diff --git a/apps/sim/lib/oauth/credential-service.test.ts b/apps/sim/lib/oauth/credential-service.test.ts index 4d04f3b1f2a..9700af466a4 100644 --- a/apps/sim/lib/oauth/credential-service.test.ts +++ b/apps/sim/lib/oauth/credential-service.test.ts @@ -30,6 +30,14 @@ vi.mock('@/lib/concurrency/leader-lock', () => ({ })) vi.mock('@/lib/core/security/encryption', () => encryptionMock) +vi.mock('@/lib/credentials/coordinated-client-token', () => ({ + resolveCoordinatedClientToken: async (input: { + exchange: () => Promise<{ accessToken: string; expiresAt: Date }> + }) => { + const exchanged = await input.exchange() + return { accessToken: exchanged.accessToken, expiresInSeconds: 86_000 } + }, +})) vi.mock('@/lib/credentials/workload-identity/issuer', () => ({ isWorkloadIdentityConfigured: async () => true, mintWorkloadIdentityToken: async ({ subject }: { subject: string }) => `sim-oidc|${subject}`, @@ -1353,3 +1361,199 @@ describe('OCI service-account resolver', () => { ).resolves.toEqual({ accessToken: 'credential-authoritative' }) }) }) + +describe('token provider credentials saved in client-credentials mode', () => { + const fetchMock = vi.fn() + const shopifyBlob = { + type: 'token_service_account', + providerId: 'shopify-service-account', + authMethod: 'client_credentials', + clientId: 'client-1', + clientSecret: 'secret-1', + domain: 'acme.myshopify.com', + } + + beforeEach(() => { + resetDbChainMock() + vi.clearAllMocks() + fetchMock.mockReset() + vi.stubGlobal('fetch', fetchMock) + mocks.coalesceLocally.mockImplementation( + async (_key: string, producer: () => Promise) => producer() + ) + }) + + function storeBlob(credentialId: string, blob: Record, reads = 1) { + for (let read = 0; read < reads; read++) { + queueTableRows(credential, [{ encryptedServiceAccountKey: `enc-${credentialId}` }]) + } + mocks.decryptSecret.mockResolvedValue({ decrypted: JSON.stringify(blob) }) + } + + it('mints a Shopify token with the stored client credentials and returns the store domain', async () => { + storeBlob('shopify-cc-1', shopifyBlob) + fetchMock.mockResolvedValue(Response.json({ access_token: 'minted', expires_in: 86399 })) + + await expect( + resolveServiceAccountToken('shopify-cc-1', 'shopify-service-account') + ).resolves.toEqual({ accessToken: 'minted', domain: 'acme.myshopify.com' }) + const request = new Request(fetchMock.mock.calls[0][0], fetchMock.mock.calls[0][1]) + expect(request.url).toBe('https://acme.myshopify.com/admin/oauth/access_token') + expect(mocks.decryptSecret).toHaveBeenCalledTimes(1) + }) + + it('leaves a coordinated Shopify token to the coordinator instead of caching it per instance', async () => { + storeBlob('shopify-cc-2', shopifyBlob, 2) + fetchMock.mockImplementation(async () => + Response.json({ access_token: `minted-${fetchMock.mock.calls.length}`, expires_in: 86399 }) + ) + + await resolveServiceAccountToken('shopify-cc-2', 'shopify-service-account') + await expect( + resolveServiceAccountToken('shopify-cc-2', 'shopify-service-account') + ).resolves.toMatchObject({ accessToken: 'minted-2' }) + }) + + it('leaves a Linear app token to the coordinator instead of caching it per instance', async () => { + const linearBlob = { + type: 'token_service_account', + providerId: 'linear-service-account', + authMethod: 'client_credentials', + clientId: 'linear-client', + clientSecret: 'linear-secret', + } + fetchMock.mockImplementation(async () => + Response.json({ + access_token: `lin_oauth_${fetchMock.mock.calls.length}`, + expires_in: 2_591_999, + }) + ) + storeBlob('linear-cc', linearBlob, 2) + + await expect( + resolveServiceAccountToken('linear-cc', 'linear-service-account') + ).resolves.toEqual({ accessToken: 'lin_oauth_1', domain: undefined }) + await expect( + resolveServiceAccountToken('linear-cc', 'linear-service-account') + ).resolves.toMatchObject({ accessToken: 'lin_oauth_2' }) + }) + + it('still returns a pasted Shopify token as is', async () => { + storeBlob( + 'shopify-pasted', + { + type: 'token_service_account', + providerId: 'shopify-service-account', + apiToken: 'shpat_pasted', + domain: 'acme.myshopify.com', + }, + 1 + ) + + await expect( + resolveServiceAccountToken('shopify-pasted', 'shopify-service-account') + ).resolves.toEqual({ + accessToken: 'shpat_pasted', + domain: 'acme.myshopify.com', + providerWorkspaceId: undefined, + }) + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('refuses a client-credentials blob stored under another provider', async () => { + storeBlob('shopify-wrong', { ...shopifyBlob, providerId: 'hubspot-service-account' }, 1) + + await expect( + resolveServiceAccountToken('shopify-wrong', 'shopify-service-account') + ).rejects.toThrow('malformed') + expect(fetchMock).not.toHaveBeenCalled() + }) +}) + +describe('Atlassian service-account resolution', () => { + const fetchMock = vi.fn() + + beforeEach(() => { + resetDbChainMock() + vi.clearAllMocks() + fetchMock.mockReset() + vi.stubGlobal('fetch', fetchMock) + mocks.coalesceLocally.mockImplementation( + async (_key: string, producer: () => Promise) => producer() + ) + }) + + function storeBlob(blob: Record, reads: number) { + for (let read = 0; read < reads; read++) { + queueTableRows(credential, [{ encryptedServiceAccountKey: 'enc-atlassian' }]) + } + mocks.decryptSecret.mockResolvedValue({ decrypted: JSON.stringify(blob) }) + } + + it('mints a token for an OAuth 2.0 credential and keeps its site binding', async () => { + storeBlob( + { + type: 'atlassian_service_account', + authMethod: 'client_credentials', + clientId: 'atl-client', + clientSecret: 'atl-secret', + domain: 'acme.atlassian.net', + cloudId: 'cloud-1', + atlassianProduct: 'jira', + }, + 1 + ) + fetchMock.mockResolvedValue(Response.json({ access_token: 'atl-minted', expires_in: 3600 })) + + await expect( + resolveServiceAccountToken('atl-cc', 'atlassian-service-account') + ).resolves.toEqual({ + accessToken: 'atl-minted', + cloudId: 'cloud-1', + domain: 'acme.atlassian.net', + }) + expect(new Request(fetchMock.mock.calls[0][0], fetchMock.mock.calls[0][1]).url).toBe( + 'https://auth.atlassian.com/oauth/token' + ) + expect(mocks.decryptSecret).toHaveBeenCalledTimes(1) + }) + + it('still returns a scoped API token as is', async () => { + storeBlob( + { + type: 'atlassian_service_account', + apiToken: 'scoped-token', + domain: 'acme.atlassian.net', + cloudId: 'cloud-1', + }, + 1 + ) + + await expect( + resolveServiceAccountToken('atl-token', 'atlassian-service-account') + ).resolves.toEqual({ + accessToken: 'scoped-token', + cloudId: 'cloud-1', + domain: 'acme.atlassian.net', + }) + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('refuses a blob with neither a token nor a complete OAuth 2.0 credential', async () => { + storeBlob( + { + type: 'atlassian_service_account', + authMethod: 'client_credentials', + clientId: 'atl-client', + domain: 'acme.atlassian.net', + cloudId: 'cloud-1', + }, + 1 + ) + + await expect( + resolveServiceAccountToken('atl-broken', 'atlassian-service-account') + ).rejects.toThrow('malformed') + expect(fetchMock).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/oauth/credential-service.ts b/apps/sim/lib/oauth/credential-service.ts index ec263abf4d6..433eba9edaa 100644 --- a/apps/sim/lib/oauth/credential-service.ts +++ b/apps/sim/lib/oauth/credential-service.ts @@ -4,12 +4,14 @@ import { account, credential } from '@sim/db/schema' import { sealedTextEquals } from '@sim/db/sealed-text' import { createLogger } from '@sim/logger' import { getPostgresErrorCode, toError } from '@sim/utils/errors' +import { isRecordLike } from '@sim/utils/object' import { and, desc, eq, isNotNull, sql } from 'drizzle-orm' import { withLeaderLock } from '@/lib/concurrency/leader-lock' import { coalesceLocally } from '@/lib/concurrency/singleflight' import { env } from '@/lib/core/config/env' import { decryptSecret } from '@/lib/core/security/encryption' import { waitWithAbort } from '@/lib/core/utils/concurrency' +import { mintAtlassianServiceAccountToken } from '@/lib/credentials/atlassian-client-credentials' import { isClientCredentialAccountProviderId, VANTA_SERVICE_ACCOUNT_PROVIDER_ID, @@ -19,14 +21,17 @@ import { parseClientCredentialAccountSecretBlob, } from '@/lib/credentials/client-credential-accounts/server' import { isGoogleWorkloadIdentitySecret } from '@/lib/credentials/google-workload-identity' +import { getTokenClientCredentialMinter } from '@/lib/credentials/token-service-accounts/client-credentials' import { + type CLIENT_CREDENTIALS_AUTH_METHOD, getTokenServiceAccountDescriptor, isTokenServiceAccountProviderId, SNOWFLAKE_SERVICE_ACCOUNT_PROVIDER_ID, } from '@/lib/credentials/token-service-accounts/descriptors' import { - parseTokenServiceAccountSecretBlob, - type TokenServiceAccountSecretBlob, + parseStoredSecretMode, + parseTokenServiceAccountSecret, + type TokenServiceAccountClientCredentialBlob, } from '@/lib/credentials/token-service-accounts/server' import { CLEARED_REFRESH_REVOCATION, CredentialRevokedError } from '@/lib/oauth/credential-revoked' import { @@ -497,21 +502,78 @@ export async function getSlackBotCredential( } } -interface AtlassianServiceAccountSecret { +interface AtlassianServiceAccountSecretBase { type: typeof ATLASSIAN_SERVICE_ACCOUNT_SECRET_TYPE - apiToken: string domain: string cloudId: string atlassianAccountId?: string } +/** A scoped API token, used as the bearer token as is. */ +interface AtlassianServiceAccountTokenSecret extends AtlassianServiceAccountSecretBase { + apiToken: string +} + +/** An OAuth 2.0 credential, minted into a bearer token with the client-credentials grant. */ +interface AtlassianServiceAccountClientSecret extends AtlassianServiceAccountSecretBase { + authMethod: typeof CLIENT_CREDENTIALS_AUTH_METHOD + clientId: string + clientSecret: string +} + +type AtlassianServiceAccountSecret = + | AtlassianServiceAccountTokenSecret + | AtlassianServiceAccountClientSecret + +function parseAtlassianServiceAccountSecret(decrypted: string): AtlassianServiceAccountSecret { + const malformed = new Error('Stored Atlassian service account secret is malformed') + const parsed: unknown = JSON.parse(decrypted) + if ( + !isRecordLike(parsed) || + parsed.type !== ATLASSIAN_SERVICE_ACCOUNT_SECRET_TYPE || + typeof parsed.cloudId !== 'string' || + !parsed.cloudId + ) { + throw malformed + } + const base: AtlassianServiceAccountSecretBase = { + type: ATLASSIAN_SERVICE_ACCOUNT_SECRET_TYPE, + domain: typeof parsed.domain === 'string' ? parsed.domain : '', + cloudId: parsed.cloudId, + ...(typeof parsed.atlassianAccountId === 'string' + ? { atlassianAccountId: parsed.atlassianAccountId } + : {}), + } + const mode = parseStoredSecretMode(parsed) + if (!mode) throw malformed + return { ...base, ...mode } +} + /** - * Loads the decrypted Atlassian service account secret blob for a credential. + * Loads the decrypted Atlassian service account secret blob for a credential: + * a scoped API token, or an OAuth 2.0 credential's client ID and secret. * Throws if the credential is missing or not an Atlassian service account. */ export async function getAtlassianServiceAccountSecret( credentialId: string ): Promise { + const { decrypted } = await readStoredServiceAccountSecret( + credentialId, + 'Atlassian service account secret not found' + ) + return parseAtlassianServiceAccountSecret(decrypted) +} + +/** A credential's encrypted service-account secret, read once along with its plaintext. */ +interface StoredServiceAccountSecret { + encryptedServiceAccountKey: string + decrypted: string +} + +async function readStoredServiceAccountSecret( + credentialId: string, + notFoundMessage: string +): Promise { const [credentialRow] = await db .select({ encryptedServiceAccountKey: credential.encryptedServiceAccountKey }) .from(credential) @@ -519,19 +581,11 @@ export async function getAtlassianServiceAccountSecret( .limit(1) if (!credentialRow?.encryptedServiceAccountKey) { - throw new Error('Atlassian service account secret not found') + throw new Error(notFoundMessage) } const { decrypted } = await decryptSecret(credentialRow.encryptedServiceAccountKey) - const parsed = JSON.parse(decrypted) as AtlassianServiceAccountSecret - if ( - parsed.type !== ATLASSIAN_SERVICE_ACCOUNT_SECRET_TYPE || - !parsed.apiToken || - !parsed.cloudId - ) { - throw new Error('Stored Atlassian service account secret is malformed') - } - return parsed + return { encryptedServiceAccountKey: credentialRow.encryptedServiceAccountKey, decrypted } } /** @@ -547,6 +601,8 @@ export interface ServiceAccountTokenResult { /** Atlassian and domain-scoped token providers (e.g. Shopify) — the site/store domain. */ domain?: string providerWorkspaceId?: string + /** Datadog only — the application key sent beside the API key (`accessToken`). */ + applicationKey?: string /** Salesforce or NetSuite — the provider origin the token must be used against. */ instanceUrl?: string /** @@ -558,37 +614,16 @@ export interface ServiceAccountTokenResult { * Set when the token must be sent in an `x-api-token` header instead of * `Authorization: Bearer` (e.g. Pipedrive personal API tokens), or is a * Sim-signed OIDC token a keyless Snowflake credential presents through - * workload identity federation. Absent means Bearer; OAuth credentials never + * workload identity federation, or `basic` for an HTTP Basic credential (a + * Zendesk API token sign-in). Absent means Bearer; OAuth credentials never * carry it. */ - authStyle?: 'x-api-token' | 'snowflake-workload-identity' -} - -/** - * Loads and parses the decrypted secret blob for a token service-account - * credential (pasted long-lived provider token). Throws if the credential is - * missing or the blob doesn't belong to the expected provider. - */ -async function getTokenServiceAccountSecret( - credentialId: string, - providerId: string -): Promise { - const [credentialRow] = await db - .select({ encryptedServiceAccountKey: credential.encryptedServiceAccountKey }) - .from(credential) - .where(eq(credential.id, credentialId)) - .limit(1) - - if (!credentialRow?.encryptedServiceAccountKey) { - throw new Error('Token service account secret not found') - } - - const { decrypted } = await decryptSecret(credentialRow.encryptedServiceAccountKey) - return parseTokenServiceAccountSecretBlob(decrypted, providerId) + authStyle?: 'x-api-token' | 'snowflake-workload-identity' | 'basic' } interface CachedClientCredentialToken { - accessToken: string + /** The minted token and the destination fields it is bound to, exactly as tools receive them. */ + token: ServiceAccountTokenResult expiresAtMs: number /** * Fingerprint of the encrypted secret the token was minted from (see @@ -597,10 +632,12 @@ interface CachedClientCredentialToken { * the cached token belongs to the old app and must be re-minted. */ secretFingerprint: string - /** Salesforce or NetSuite — the provider origin returned alongside the minted token. */ - instanceUrl?: string - /** Zoho Desk only — the Desk REST base derived from the token's api_domain. */ - apiDomain?: string +} + +/** A token minted from a stored secret, with how long it stays valid. */ +interface MintedServiceAccountToken { + token: ServiceAccountTokenResult + expiresInSeconds: number } interface FailedClientCredentialMint { @@ -652,6 +689,19 @@ function pruneExpiredClientCredentialCaches(nowMs: number): void { } } +/** + * Drops this instance's cached client-credential token once a provider has + * refused it (a revoked API client, or a token minted before the client was + * granted a scope), so the next resolution mints a replacement instead of + * serving the refused token until it nears expiry. Matching on the token value + * leaves alone a replacement another call has already minted. + */ +export function forgetRejectedClientCredentialToken(accessToken: string): void { + for (const [identity, entry] of clientCredentialTokenCache) { + if (entry.token.accessToken === accessToken) clientCredentialTokenCache.delete(identity) + } +} + /** * Rotation fingerprint for a stored encrypted secret: the ciphertext prefix * (IV + first blocks) is unique per encryption, so any re-encrypt — secret @@ -662,17 +712,21 @@ function secretFingerprintOf(encryptedServiceAccountKey: string): string { } /** - * Resolves a client-credential service-account credential to a short-lived - * access token: decrypts the stored credential material (client id + secret, - * or the private key and run-as username for key-based grants) and mints via - * the provider's registered minter (skipping the connect-time identity - * lookup), read-through the per-instance cache. Wrapped in `coalesceLocally` - * so concurrent block executions on one instance share a single mint. + * Resolves a stored secret that mints short-lived tokens, read-through the + * per-instance cache: re-reads the credential row, serves a cached token minted + * from the same ciphertext while enough of it remains, replays a recent mint + * failure, and otherwise decrypts and mints. Wrapped in `coalesceLocally` so + * concurrent block executions on one instance share a single mint. + * `sharedToken` providers coordinate one token per application themselves, so + * their tokens are never cached here, and a caller that already read the + * secret to choose its mode passes it as `stored` rather than reading it twice. */ -async function resolveClientCredentialAccountToken( +async function resolveMintedServiceAccountToken( credentialId: string, - providerId: string, - options?: CredentialTokenResolutionOptions + mint: (decrypted: string) => Promise, + options: CredentialTokenResolutionOptions | undefined, + sharedToken: boolean, + stored?: StoredServiceAccountSecret ): Promise { const signal = options?.signal signal?.throwIfAborted() @@ -682,30 +736,29 @@ async function resolveClientCredentialAccountToken( : credentialId const resolution = coalesceLocally(`ccsa:${cacheIdentity}`, async () => { pruneExpiredClientCredentialCaches(Date.now()) - const [credentialRow] = await db - .select({ encryptedServiceAccountKey: credential.encryptedServiceAccountKey }) - .from(credential) - .where(eq(credential.id, credentialId)) - .limit(1) - if (!credentialRow?.encryptedServiceAccountKey) { + const encryptedServiceAccountKey = + stored?.encryptedServiceAccountKey ?? + ( + await db + .select({ encryptedServiceAccountKey: credential.encryptedServiceAccountKey }) + .from(credential) + .where(eq(credential.id, credentialId)) + .limit(1) + )[0]?.encryptedServiceAccountKey + if (!encryptedServiceAccountKey) { clientCredentialTokenCache.delete(cacheIdentity) clientCredentialMintFailureCache.delete(cacheIdentity) throw new Error('Client-credential service account secret not found') } - const secretFingerprint = secretFingerprintOf(credentialRow.encryptedServiceAccountKey) + const secretFingerprint = secretFingerprintOf(encryptedServiceAccountKey) - const usesSharedToken = providerId === VANTA_SERVICE_ACCOUNT_PROVIDER_ID - const cached = usesSharedToken ? undefined : clientCredentialTokenCache.get(cacheIdentity) + const cached = sharedToken ? undefined : clientCredentialTokenCache.get(cacheIdentity) if ( cached && cached.secretFingerprint === secretFingerprint && cached.expiresAtMs - Date.now() > CLIENT_CREDENTIAL_TOKEN_MIN_TTL_MS ) { - return { - accessToken: cached.accessToken, - instanceUrl: cached.instanceUrl, - apiDomain: cached.apiDomain, - } + return { ...cached.token } } const failed = clientCredentialMintFailureCache.get(cacheIdentity) @@ -719,13 +772,49 @@ async function resolveClientCredentialAccountToken( clientCredentialMintFailureCache.delete(cacheIdentity) try { - const { decrypted } = await decryptSecret(credentialRow.encryptedServiceAccountKey) + const decrypted = + stored?.decrypted ?? (await decryptSecret(encryptedServiceAccountKey)).decrypted + const minted = await mint(decrypted) + if (!sharedToken) + clientCredentialTokenCache.set(cacheIdentity, { + token: minted.token, + expiresAtMs: Date.now() + minted.expiresInSeconds * 1000, + secretFingerprint, + }) + return { ...minted.token } + } catch (error) { + if (!(error instanceof Error && error.name === 'AbortError')) { + clientCredentialMintFailureCache.set(cacheIdentity, { + error: options?.privacyMode === 'selector' ? new Error('Credential mint failed') : error, + secretFingerprint, + expiresAtMs: Date.now() + CLIENT_CREDENTIAL_MINT_FAILURE_TTL_MS, + }) + } + throw error + } + }) + return waitWithAbort(resolution, signal) +} + +/** + * Resolves a client-credential service-account credential to a short-lived + * access token: decrypts the stored credential material (client id + secret, + * or the private key and run-as username for key-based grants) and mints via + * the provider's registered minter, skipping the connect-time identity lookup. + */ +function resolveClientCredentialAccountToken( + credentialId: string, + providerId: string, + options?: CredentialTokenResolutionOptions +): Promise { + return resolveMintedServiceAccountToken( + credentialId, + async (decrypted) => { const blob = parseClientCredentialAccountSecretBlob(decrypted, providerId) const minter = getClientCredentialAccountMinter(providerId) if (!minter) { throw new Error(`No minter registered for service-account provider ${providerId}`) } - const mint = await minter( { clientId: blob.clientId, @@ -737,34 +826,63 @@ async function resolveClientCredentialAccountToken( authMethod: blob.authMethod, privateKey: blob.privateKey, username: blob.username, + apiToken: blob.apiToken, }, { skipIdentity: true } ) - if (!usesSharedToken) - clientCredentialTokenCache.set(cacheIdentity, { + return { + token: { accessToken: mint.accessToken, - expiresAtMs: Date.now() + mint.expiresInSeconds * 1000, - secretFingerprint, instanceUrl: mint.instanceUrl, apiDomain: mint.apiDomain, - }) - return { - accessToken: mint.accessToken, - instanceUrl: mint.instanceUrl, - apiDomain: mint.apiDomain, + ...(mint.domain ? { domain: mint.domain } : {}), + ...(mint.applicationKey ? { applicationKey: mint.applicationKey } : {}), + ...(mint.authStyle ? { authStyle: mint.authStyle } : {}), + }, + expiresInSeconds: mint.expiresInSeconds, } - } catch (error) { - if (!(error instanceof Error && error.name === 'AbortError')) { - clientCredentialMintFailureCache.set(cacheIdentity, { - error: options?.privacyMode === 'selector' ? new Error('Credential mint failed') : error, - secretFingerprint, - expiresAtMs: Date.now() + CLIENT_CREDENTIAL_MINT_FAILURE_TTL_MS, - }) + }, + options, + providerId === VANTA_SERVICE_ACCOUNT_PROVIDER_ID + ) +} + +/** + * Resolves a token provider credential saved in client-credentials mode + * (Shopify Dev Dashboard app, Linear OAuth app): mints with the stored client + * ID and secret instead of returning a pasted token. The stored store domain + * travels with the token exactly as it does for a pasted one. Every grant + * shares one coordinated token per application, so none is cached per instance. + */ +function resolveTokenClientCredentialToken( + credentialId: string, + secret: TokenServiceAccountClientCredentialBlob, + stored: StoredServiceAccountSecret, + options?: CredentialTokenResolutionOptions +): Promise { + const mint = getTokenClientCredentialMinter(secret.providerId) + if (!mint) { + return Promise.reject( + new Error( + `No client-credentials grant registered for service-account provider ${secret.providerId}` + ) + ) + } + const { clientId, clientSecret, domain } = secret + return resolveMintedServiceAccountToken( + credentialId, + async () => { + // The mint is shared by every coalesced caller, so no one caller's signal may cancel it. + const minted = await mint({ clientId, clientSecret, domain }) + return { + token: { accessToken: minted.accessToken, domain }, + expiresInSeconds: minted.expiresInSeconds, } - throw error - } - }) - return waitWithAbort(resolution, signal) + }, + options, + true, + stored + ) } interface ServiceAccountTokenOptions extends CredentialTokenResolutionOptions { @@ -838,9 +956,29 @@ const SERVICE_ACCOUNT_TOKEN_RESOLVERS: Record { - const secret = await getAtlassianServiceAccountSecret(credentialId) - return { accessToken: secret.apiToken, cloudId: secret.cloudId, domain: secret.domain } + [ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID]: async (credentialId, options) => { + const stored = await readStoredServiceAccountSecret( + credentialId, + 'Atlassian service account secret not found' + ) + const secret = parseAtlassianServiceAccountSecret(stored.decrypted) + if (!('authMethod' in secret)) { + return { accessToken: secret.apiToken, cloudId: secret.cloudId, domain: secret.domain } + } + const { clientId, clientSecret, cloudId, domain } = secret + return resolveMintedServiceAccountToken( + credentialId, + async () => { + const minted = await mintAtlassianServiceAccountToken(clientId, clientSecret) + return { + token: { accessToken: minted.accessToken, cloudId, domain }, + expiresInSeconds: minted.expiresInSeconds, + } + }, + options, + true, + stored + ) }, [SLACK_CUSTOM_BOT_PROVIDER_ID]: async (credentialId) => { const botCredential = await getSlackBotCredential(credentialId) @@ -887,7 +1025,14 @@ export async function resolveServiceAccountToken( return resolveSnowflakeCredentialToken(credentialId) } if (providerId && isTokenServiceAccountProviderId(providerId)) { - const secret = await getTokenServiceAccountSecret(credentialId, providerId) + const stored = await readStoredServiceAccountSecret( + credentialId, + 'Token service account secret not found' + ) + const secret = parseTokenServiceAccountSecret(stored.decrypted, providerId) + if ('authMethod' in secret) { + return resolveTokenClientCredentialToken(credentialId, secret, stored, options) + } const descriptorAuthStyle = getTokenServiceAccountDescriptor(providerId)?.authStyle return { accessToken: secret.apiToken, diff --git a/apps/sim/lib/oauth/oauth.ts b/apps/sim/lib/oauth/oauth.ts index 2ffd1bd701c..6e34d5b29d5 100644 --- a/apps/sim/lib/oauth/oauth.ts +++ b/apps/sim/lib/oauth/oauth.ts @@ -36,9 +36,11 @@ import { CodaIcon, ConfluenceIcon, ConvexIcon, + CrowdStrikeIcon, CrunchbaseIcon, CursorIcon, DatabricksIcon, + DatadogIcon, DaytonaIcon, DevinIcon, DiscordIcon, @@ -60,6 +62,7 @@ import { GithubIcon, GitLabIcon, GmailIcon, + GongIcon, GoogleAdsIcon, GoogleBigQueryIcon, GoogleCalendarIcon, @@ -128,6 +131,7 @@ import { PersonaIcon, PipedriveIcon, PlaneIcon, + PlanetScaleIcon, PowerBIIcon, ProfoundIcon, PulseIcon, @@ -142,9 +146,11 @@ import { RipplingIcon, RocketlaneIcon, RootlyIcon, + SailPointIcon, SalesforceIcon, SendgridIcon, SentryIcon, + ServiceNowIcon, ShopifyIcon, SimilarwebIcon, SixtyfourIcon, @@ -178,9 +184,11 @@ import { xIcon, YouComIcon, YouTubeIcon, + ZendeskIcon, ZepIcon, ZohoDeskIcon, ZoomIcon, + ZoomInfoIcon, } from '@/components/icons' import { env } from '@/lib/core/config/env' import { isSlackExtendedScopesEnabled } from '@/lib/core/config/env-flags' @@ -591,7 +599,8 @@ export const OAUTH_PROVIDERS: Record = { }, 'vertex-ai': { name: 'Vertex AI', - description: 'Access Google Cloud Vertex AI for Gemini models with OAuth.', + description: + 'Access Google Cloud Vertex AI for Gemini models with OAuth or a Google service account.', providerId: 'vertex-ai', icon: VertexIcon, baseProviderIcon: VertexIcon, @@ -600,6 +609,7 @@ export const OAUTH_PROVIDERS: Record = { 'https://www.googleapis.com/auth/userinfo.profile', 'https://www.googleapis.com/auth/cloud-platform', ], + serviceAccountProviderId: 'google-service-account', }, }, defaultService: 'gmail', @@ -671,6 +681,7 @@ export const OAUTH_PROVIDERS: Record = { providerId: 'microsoft-intune', icon: IntuneIcon, baseProviderIcon: MicrosoftIcon, + serviceAccountProviderId: 'microsoft-app-service-account', scopes: [ 'openid', 'profile', @@ -1659,6 +1670,75 @@ export const OAUTH_PROVIDERS: Record = { }, defaultService: 'fivetran', }, + zoominfo: { + name: 'ZoomInfo', + icon: ZoomInfoIcon, + services: { + zoominfo: { + name: 'ZoomInfo', + description: 'Search and enrich B2B company and contact data with ZoomInfo.', + providerId: 'zoominfo', + serviceAccountProviderId: 'zoominfo-service-account', + authType: 'service_account', + icon: ZoomInfoIcon, + baseProviderIcon: ZoomInfoIcon, + scopes: [], + }, + }, + defaultService: 'zoominfo', + }, + sailpoint: { + name: 'SailPoint', + icon: SailPointIcon, + services: { + sailpoint: { + name: 'SailPoint', + description: 'Govern identities and access in SailPoint Identity Security Cloud.', + providerId: 'sailpoint', + serviceAccountProviderId: 'sailpoint-service-account', + authType: 'service_account', + icon: SailPointIcon, + baseProviderIcon: SailPointIcon, + scopes: [], + }, + }, + defaultService: 'sailpoint', + }, + datadog: { + name: 'Datadog', + icon: DatadogIcon, + services: { + datadog: { + name: 'Datadog', + description: 'Monitor infrastructure, applications, and logs with Datadog.', + providerId: 'datadog', + serviceAccountProviderId: 'datadog-service-account', + authType: 'service_account', + icon: DatadogIcon, + baseProviderIcon: DatadogIcon, + scopes: [], + }, + }, + defaultService: 'datadog', + }, + crowdstrike: { + name: 'CrowdStrike', + icon: CrowdStrikeIcon, + services: { + crowdstrike: { + name: 'CrowdStrike', + description: + 'Investigate and respond to CrowdStrike Falcon alerts, hosts, IOCs, and vulnerabilities.', + providerId: 'crowdstrike', + serviceAccountProviderId: 'crowdstrike-service-account', + authType: 'service_account', + icon: CrowdStrikeIcon, + baseProviderIcon: CrowdStrikeIcon, + scopes: [], + }, + }, + defaultService: 'crowdstrike', + }, vanta: { name: 'Vanta', icon: VantaIcon, @@ -2641,6 +2721,23 @@ export const OAUTH_PROVIDERS: Record = { }, defaultService: 'zep', }, + zendesk: { + name: 'Zendesk', + icon: ZendeskIcon, + services: { + zendesk: { + name: 'Zendesk', + description: 'Manage tickets, users, and organizations in Zendesk Support.', + providerId: 'zendesk', + serviceAccountProviderId: 'zendesk-service-account', + icon: ZendeskIcon, + baseProviderIcon: ZendeskIcon, + scopes: [], + authType: 'service_account', + }, + }, + defaultService: 'zendesk', + }, discord: { name: 'Discord', icon: DiscordIcon, @@ -3472,6 +3569,59 @@ export const OAUTH_PROVIDERS: Record = { }, defaultService: 'thrive', }, + planetscale: { + name: 'PlanetScale', + icon: PlanetScaleIcon, + services: { + planetscale: { + name: 'PlanetScale', + description: 'Manage PlanetScale databases, branches, backups, and deploy requests.', + providerId: 'planetscale', + serviceAccountProviderId: 'planetscale-service-account', + icon: PlanetScaleIcon, + baseProviderIcon: PlanetScaleIcon, + scopes: [], + authType: 'service_account', + }, + }, + defaultService: 'planetscale', + }, + gong: { + name: 'Gong', + icon: GongIcon, + services: { + gong: { + name: 'Gong', + description: + 'Read calls, transcripts, users, and activity, and manage Engage flows in Gong.', + providerId: 'gong', + serviceAccountProviderId: 'gong-service-account', + icon: GongIcon, + baseProviderIcon: GongIcon, + scopes: [], + authType: 'service_account', + }, + }, + defaultService: 'gong', + }, + servicenow: { + name: 'ServiceNow', + icon: ServiceNowIcon, + services: { + servicenow: { + name: 'ServiceNow', + description: + 'Work with incidents, change requests, catalog items, and records in ServiceNow.', + providerId: 'servicenow', + serviceAccountProviderId: 'servicenow-service-account', + icon: ServiceNowIcon, + baseProviderIcon: ServiceNowIcon, + scopes: [], + authType: 'service_account', + }, + }, + defaultService: 'servicenow', + }, higgsfield: { name: 'Higgsfield', icon: HiggsfieldIcon, diff --git a/apps/sim/lib/oauth/token-resolution.test.ts b/apps/sim/lib/oauth/token-resolution.test.ts index 30f9189c6d7..6353dd66a25 100644 --- a/apps/sim/lib/oauth/token-resolution.test.ts +++ b/apps/sim/lib/oauth/token-resolution.test.ts @@ -582,11 +582,48 @@ describe('ordinary tool credential binding', () => { expect(mockRefreshTokenIfNeeded).not.toHaveBeenCalled() }) + it('releases the application key a Datadog key pair resolves beside its API key', async () => { + mockGetToolMetadata.mockReturnValue({ + id: 'fixture-tool', + params: {}, + oauth: { required: false, provider: 'datadog', credentialKind: 'service-account' }, + }) + mockResolveOAuthAccountId.mockResolvedValue({ + ...serviceAccount, + providerId: 'datadog-service-account', + }) + mockResolveServiceAccountToken.mockResolvedValue({ + accessToken: 'dd-api-key', + applicationKey: 'dd-application-key', + domain: 'datadoghq.eu', + }) + + expect( + await resolveCredentialAccessToken({ + requestId: 'binding-test', + credentialId: 'credential-1', + toolId: 'fixture-tool', + authenticate, + }) + ).toMatchObject({ + ok: true, + token: { + accessToken: 'dd-api-key', + applicationKey: 'dd-application-key', + domain: 'datadoghq.eu', + }, + }) + }) + it.each([ ['oracle-fusion', 'oracle-fusion-service-account'], ['vanta', 'vanta-service-account'], ['ramp', 'ramp-service-account'], ['greenhouse', 'greenhouse-service-account'], + ['crowdstrike', 'crowdstrike-service-account'], + ['datadog', 'datadog-service-account'], + ['sailpoint', 'sailpoint-service-account'], + ['zoominfo', 'zoominfo-service-account'], ['google-email', 'google-service-account'], ['jira', 'atlassian-service-account'], ['confluence', 'atlassian-service-account'], @@ -694,7 +731,7 @@ describe('Microsoft app credential binding', () => { } ) - it.each(['microsoft-ad', 'sharepoint', 'microsoft-dataverse'] as const)( + it.each(['microsoft-ad', 'sharepoint', 'microsoft-dataverse', 'microsoft-intune'] as const)( 'mints an app-only token for %s and tells the resolver which service it is for', async (provider) => { toolFor(provider) diff --git a/apps/sim/lib/oauth/token-resolution.ts b/apps/sim/lib/oauth/token-resolution.ts index 60fa5bb0883..bbb8193a6c2 100644 --- a/apps/sim/lib/oauth/token-resolution.ts +++ b/apps/sim/lib/oauth/token-resolution.ts @@ -437,6 +437,7 @@ export async function resolveCredentialToken( cloudId: result.cloudId, domain: result.domain, providerWorkspaceId: result.providerWorkspaceId, + applicationKey: result.applicationKey, instanceUrl: result.instanceUrl, apiDomain: result.apiDomain, authStyle: result.authStyle, @@ -493,6 +494,15 @@ export async function resolveCredentialToken( 'This credential cannot be used here. Keyless Google Cloud credentials work only with Google Cloud tools such as BigQuery and cannot impersonate a Google Workspace user; use a JSON key credential instead', } } + if (error.code === 'grant_not_permitted') { + return { + ok: false, + status: 400, + code: error.code, + error: + 'The provider does not allow this app’s client credentials for the connected account — check the app’s installation and organization, then reconnect the credential', + } + } if (error.code === 'account_not_found') { return { ok: false, diff --git a/apps/sim/lib/oauth/types.ts b/apps/sim/lib/oauth/types.ts index d8221c2ff05..5a79cced08d 100644 --- a/apps/sim/lib/oauth/types.ts +++ b/apps/sim/lib/oauth/types.ts @@ -115,6 +115,10 @@ export type OAuthProvider = | 'ramp' | 'helpscout' | 'fivetran' + | 'zoominfo' + | 'sailpoint' + | 'datadog' + | 'crowdstrike' | 'vanta' | 'hubspot' | 'harmonic' @@ -122,6 +126,9 @@ export type OAuthProvider = | 'gauge' | 'higgsfield' | 'thrive' + | 'planetscale' + | 'gong' + | 'servicenow' | 'supabase' | 'infisical' | 'okta' @@ -169,6 +176,7 @@ export type OAuthProvider = | 'cursor' | 'discord' | 'zep' + | 'zendesk' | 'mem0' | 'youtube' | 'youcom' @@ -291,6 +299,10 @@ export type OAuthService = | 'ramp' | 'helpscout' | 'fivetran' + | 'zoominfo' + | 'sailpoint' + | 'datadog' + | 'crowdstrike' | 'vanta' | 'hubspot' | 'harmonic' @@ -298,6 +310,9 @@ export type OAuthService = | 'gauge' | 'higgsfield' | 'thrive' + | 'planetscale' + | 'gong' + | 'servicenow' | 'supabase' | 'infisical' | 'okta' @@ -346,6 +361,7 @@ export type OAuthService = | 'cursor' | 'discord' | 'zep' + | 'zendesk' | 'mem0' | 'youtube' | 'youcom' diff --git a/apps/sim/lib/permission-groups/block-successors.generated.ts b/apps/sim/lib/permission-groups/block-successors.generated.ts index fbd8d5f4198..5fdfd4f0989 100644 --- a/apps/sim/lib/permission-groups/block-successors.generated.ts +++ b/apps/sim/lib/permission-groups/block-successors.generated.ts @@ -37,10 +37,12 @@ export const BLOCK_ACCESS_SUCCESSORS: Record = { codepipeline: 'codepipeline_v2', confluence: 'confluence_v2', convex: 'convex_v2', + crowdstrike: 'crowdstrike_v2', crunchbase: 'crunchbase_v2', cursor: 'cursor_v3', cursor_v2: 'cursor_v3', databricks: 'databricks_v2', + datadog: 'datadog_v2', daytona: 'daytona_v2', devin: 'devin_v2', discord: 'discord_v2', @@ -68,6 +70,7 @@ export const BLOCK_ACCESS_SUCCESSORS: Record = { github_v2: 'github_v3', gitlab: 'gitlab_v2', gmail: 'gmail_v2', + gong: 'gong_v2', google_calendar: 'google_calendar_v2', google_sheets: 'google_sheets_v2', google_slides: 'google_slides_v2', @@ -118,6 +121,7 @@ export const BLOCK_ACCESS_SUCCESSORS: Record = { pagerduty: 'pagerduty_v2', persona: 'persona_v2', plane: 'plane_v2', + planetscale: 'planetscale_v2', profound: 'profound_v2', pulse: 'pulse_v3', pulse_v2: 'pulse_v3', @@ -134,10 +138,12 @@ export const BLOCK_ACCESS_SUCCESSORS: Record = { rootly: 'rootly_v2', router: 'router_v2', s3: 's3_v2', + sailpoint: 'sailpoint_v2', secrets_manager: 'secrets_manager_v2', sendgrid: 'sendgrid_v2', sentry: 'sentry_v2', - servicenow: 'servicenow_v2', + servicenow: 'servicenow_v3', + servicenow_v2: 'servicenow_v3', ses: 'ses_v2', sftp: 'sftp_v2', sharepoint: 'sharepoint_v2', @@ -176,5 +182,7 @@ export const BLOCK_ACCESS_SUCCESSORS: Record = { workflow: 'workflow_input', youcom: 'youcom_v2', youtube: 'youtube_v2', + zendesk: 'zendesk_v2', zep: 'zep_v2', + zoominfo: 'zoominfo_v2', } diff --git a/apps/sim/lib/selectors/manifest.ts b/apps/sim/lib/selectors/manifest.ts index 929b4daff1c..6680645f226 100644 --- a/apps/sim/lib/selectors/manifest.ts +++ b/apps/sim/lib/selectors/manifest.ts @@ -479,6 +479,31 @@ export const selectorManifest = { detail: true, } ), + 'planetscale.savedDatabases': providerSelector(['organization'], { + readiness: { all: ['oauthCredential', 'organization'] }, + listMode: 'paginated', + search: true, + detail: true, + }), + 'planetscale.savedBranches': providerSelector(['organization', 'database'], { + readiness: { all: ['oauthCredential', 'organization', 'database'] }, + listMode: 'paginated', + search: true, + detail: true, + }), + 'planetscale.savedBackups': providerSelector(['organization', 'database', 'branch'], { + sourceFields: { branch: ['branch', 'parentBranch'] }, + readiness: { all: ['oauthCredential', 'organization', 'database'] }, + listMode: 'paginated', + search: false, + detail: true, + }), + 'planetscale.savedDeployRequests': providerSelector(['organization', 'database'], { + readiness: { all: ['oauthCredential', 'organization', 'database'] }, + listMode: 'paginated', + search: false, + detail: true, + }), 'cloudwatch.logGroups': rawProviderSelector( ['awsAccessKeyId', 'awsSecretAccessKey', 'awsRegion'], { diff --git a/apps/sim/lib/selectors/server/providers/planetscale.test.ts b/apps/sim/lib/selectors/server/providers/planetscale.test.ts index 74c1abe6f9c..8f947399469 100644 --- a/apps/sim/lib/selectors/server/providers/planetscale.test.ts +++ b/apps/sim/lib/selectors/server/providers/planetscale.test.ts @@ -1,4 +1,11 @@ +import { + selectorCredentialBundleMock, + selectorCredentialBundleMockFns, +} from '@sim/testing/mocks/selector-credential-bundle.mock' import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/lib/selectors/server/providers/credential-bundle', () => selectorCredentialBundleMock) + import { buildSelectorContextFromValues, getSelectorContextSubBlocks, @@ -272,3 +279,45 @@ describe('PlanetScale server selectors', () => { ).rejects.toMatchObject({ name: 'AbortError' }) }) }) + +describe('PlanetScale saved-credential selectors', () => { + const fetchMock = vi.fn() + const resolveBundle = selectorCredentialBundleMockFns.mockResolveSelectorCredentialBundle + beforeEach(() => { + vi.stubGlobal('fetch', fetchMock) + fetchMock.mockReset() + resolveBundle.mockReset() + }) + + it.each([ + 'planetscale.savedDatabases', + 'planetscale.savedBranches', + 'planetscale.savedBackups', + 'planetscale.savedDeployRequests', + ] as const)('%s lists with the saved token ID and token, never typed ones', async (key) => { + resolveBundle.mockResolvedValue({ + accessToken: 'saved-secret', + providerWorkspaceId: 'saved-id', + }) + fetchMock.mockResolvedValueOnce(page([])) + const input = args({ selectorKey: key }) + + await expect(planetScaleSelectorAttachments[key].execute(input)).resolves.toMatchObject({ + kind: 'list', + }) + expect(new Headers(fetchMock.mock.calls[0]?.[1]?.headers).get('Authorization')).toBe( + 'saved-id:saved-secret' + ) + }) + + it('refuses a saved credential without a token ID before contacting PlanetScale', async () => { + resolveBundle.mockResolvedValue({ accessToken: 'saved-secret' }) + + await expect( + planetScaleSelectorAttachments['planetscale.savedDatabases'].execute( + args({ selectorKey: 'planetscale.savedDatabases' }) + ) + ).rejects.toMatchObject({ name: 'SelectorConnectionUnavailableError' }) + expect(fetchMock).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/selectors/server/providers/planetscale.ts b/apps/sim/lib/selectors/server/providers/planetscale.ts index aba75b71e93..2ed6880c487 100644 --- a/apps/sim/lib/selectors/server/providers/planetscale.ts +++ b/apps/sim/lib/selectors/server/providers/planetscale.ts @@ -1,19 +1,57 @@ -import type { ServerSelectorAttachmentMap } from '@/lib/selectors/server/types' +import type { ServerSelectorKey } from '@/lib/selectors/manifest' +import { SelectorConnectionUnavailableError } from '@/lib/selectors/server/errors' +import { resolveSelectorCredentialBundle } from '@/lib/selectors/server/providers/credential-bundle' +import type { + ExecuteServerSelectorArgs, + ServerSelectorAttachment, + ServerSelectorAttachmentMap, +} from '@/lib/selectors/server/types' import { listPlanetScaleOptions, type PlanetScaleListingKind } from '@/tools/planetscale/listing' +import type { PlanetScaleCredentials } from '@/tools/planetscale/types' -function attachment(kind: PlanetScaleListingKind) { +type PlanetScaleSelectorKey = Extract + +const savedCredential = { + kind: 'stored', + field: 'oauthCredential', + serviceIds: ['planetscale'], +} as const + +/** The service token pair typed into the legacy block; the listing refuses a missing half. */ +function typedCredentials(args: ExecuteServerSelectorArgs): PlanetScaleCredentials { + return { + serviceTokenId: args.context.serviceTokenId ?? '', + serviceToken: args.context.serviceToken ?? '', + } +} + +/** The service token pair of an authorized saved credential: its ID and its token. */ +async function savedCredentials(args: ExecuteServerSelectorArgs): Promise { + const { accessToken, providerWorkspaceId } = await resolveSelectorCredentialBundle({ + credential: args.credential, + protectedValues: args.protectedValues, + }) + if (!providerWorkspaceId) throw new SelectorConnectionUnavailableError() + return { serviceTokenId: providerWorkspaceId, serviceToken: accessToken } +} + +function attachment( + kind: PlanetScaleListingKind, + credentials: ( + args: ExecuteServerSelectorArgs + ) => PlanetScaleCredentials | Promise, + credential?: typeof savedCredential +): ServerSelectorAttachment { return { + ...(credential ? { credential } : {}), integrationBlockTypes: ['planetscale'], - destination: 'fixed' as const, - execute: ( - args: Parameters[0] - ) => + destination: 'fixed', + execute: async (args) => listPlanetScaleOptions({ kind, scope: { - serviceTokenId: args.context.serviceTokenId!, - serviceToken: args.context.serviceToken!, - organization: args.context.organization!, + ...(await credentials(args)), + organization: args.context.organization ?? '', database: args.context.database, branch: args.context.branch, }, @@ -24,13 +62,16 @@ function attachment(kind: PlanetScaleListingKind) { } export const planetScaleSelectorAttachments = { - 'planetscale.databases': attachment('databases'), - 'planetscale.branches': attachment('branches'), - 'planetscale.backups': attachment('backups'), - 'planetscale.deployRequests': attachment('deployRequests'), -} satisfies ServerSelectorAttachmentMap< - | 'planetscale.databases' - | 'planetscale.branches' - | 'planetscale.backups' - | 'planetscale.deployRequests' -> + 'planetscale.databases': attachment('databases', typedCredentials), + 'planetscale.branches': attachment('branches', typedCredentials), + 'planetscale.backups': attachment('backups', typedCredentials), + 'planetscale.deployRequests': attachment('deployRequests', typedCredentials), + 'planetscale.savedDatabases': attachment('databases', savedCredentials, savedCredential), + 'planetscale.savedBranches': attachment('branches', savedCredentials, savedCredential), + 'planetscale.savedBackups': attachment('backups', savedCredentials, savedCredential), + 'planetscale.savedDeployRequests': attachment( + 'deployRequests', + savedCredentials, + savedCredential + ), +} satisfies ServerSelectorAttachmentMap diff --git a/apps/sim/scripts/saved-credential-contract.test.ts b/apps/sim/scripts/saved-credential-contract.test.ts index 93a970b13d2..f80dc64cd5e 100644 --- a/apps/sim/scripts/saved-credential-contract.test.ts +++ b/apps/sim/scripts/saved-credential-contract.test.ts @@ -248,4 +248,76 @@ describe('savedCredentialContractErrors', () => { expect(errors).toEqual([expect.stringContaining('no credential policy')]) }) + + /** An operation tool that mints its own token from inline client credentials, at a chosen region. */ + function mintingTool(oauth: OAuthConfig) { + const executable = { + ...operationTool('minting_fixture', { + accessToken: { type: 'string' }, + clientId: { type: 'string' }, + clientSecret: { type: 'string' }, + region: { type: 'string' }, + }), + oauth, + } + return { + metadata: () => executable as ToolMetadata, + executable: () => executable, + } + } + + const mintingOAuth: OAuthConfig = { + required: false, + provider: PROVIDER, + credentialKind: 'service-account', + credentialParams: { accessToken: 'accessToken', region: 'domain' }, + tokenReplacesParams: ['clientId', 'clientSecret'], + credentialSuppliedParams: ['accessToken', 'region', 'clientId', 'clientSecret'], + } + + it('passes a tool whose inline client credentials the minted token replaces', () => { + const errors = savedCredentialContractErrors( + nativeBlock('minting_fixture'), + [], + mintingTool(mintingOAuth) + ) + + expect(errors).toEqual([]) + }) + + it('fails a token-replaced secret when the credential never fills the token', () => { + const errors = savedCredentialContractErrors( + nativeBlock('minting_fixture'), + [], + mintingTool({ ...mintingOAuth, credentialParams: { region: 'domain' } }) + ) + + expect(errors).toContainEqual( + expect.stringContaining('needs credentialParams to fill "accessToken"') + ) + }) + + it('still fails a credential-supplied secret that neither the credential nor its token fills', () => { + const errors = savedCredentialContractErrors( + nativeBlock('minting_fixture'), + [], + mintingTool({ ...mintingOAuth, tokenReplacesParams: ['clientId'] }) + ) + + expect(errors).toEqual([expect.stringContaining('"clientSecret" is credential-supplied')]) + }) + + it('still requires the region a minting tool sends its token to be filled by the credential', () => { + const errors = savedCredentialContractErrors( + nativeBlock('minting_fixture'), + [], + mintingTool({ + ...mintingOAuth, + credentialParams: { accessToken: 'accessToken' }, + credentialSuppliedParams: ['accessToken', 'clientId', 'clientSecret'], + }) + ) + + expect(errors).toEqual([expect.stringContaining('"region" decides the host')]) + }) }) diff --git a/apps/sim/scripts/saved-credential-contract.ts b/apps/sim/scripts/saved-credential-contract.ts index 6ce31786fc5..aca2505a1da 100644 --- a/apps/sim/scripts/saved-credential-contract.ts +++ b/apps/sim/scripts/saved-credential-contract.ts @@ -45,12 +45,17 @@ const DESTINATION_PARAM = const OPERATION_PAYLOAD_PARAMS: Readonly> = { // The page the remote browser opens; the token goes to api.browser-use.com. browser_use_run_task: ['startUrl'], + // A label CrowdStrike records on the RTR session; the token goes to the credential's Falcon cloud. + crowdstrike_init_rtr_session: ['origin'], // A guild id the operation never sends; it posts only to the channel's discord.com endpoint. discord_send_message: ['serverId'], // The audio Fireflies downloads; the token goes to api.fireflies.ai. fireflies_upload_audio: ['audioUrl'], // A filter on the asset's cloud account; the token goes to the credential's Vanta API domain. vanta_list_vulnerable_assets: ['assetExternalAccountId'], + // Search filters sent in the request body; the token goes only to api.zoominfo.com. + zoominfo_search_companies: ['companyWebsite', 'metroRegion'], + zoominfo_search_news: ['url'], // The status page's own domain, sent as a form field to the UptimeRobot API. uptimerobot_create_psp: ['customDomain'], uptimerobot_update_psp: ['customDomain'], @@ -307,8 +312,17 @@ export function savedCredentialContractErrors( } const mapped = new Set(Object.keys(oauth.credentialParams ?? {})) if (!OPERATION_RESOLVED_CREDENTIAL_PROVIDERS.has(oauth.provider)) { + if ( + oauth.tokenReplacesParams?.length && + oauth.credentialParams?.accessToken !== 'accessToken' + ) { + errors.push( + `Block "${block.type}" → tool "${toolId}": tokenReplacesParams needs credentialParams to fill "accessToken" with the minted token.` + ) + } + const replacedByToken = new Set(oauth.tokenReplacesParams ?? []) for (const param of oauth.credentialSuppliedParams ?? []) { - if (!mapped.has(param)) { + if (!mapped.has(param) && !replacedByToken.has(param)) { errors.push( `Block "${block.type}" → tool "${toolId}": "${param}" is credential-supplied but nothing fills it (add it to credentialParams).` ) diff --git a/apps/sim/serializer/saved-credentials.test.ts b/apps/sim/serializer/saved-credentials.test.ts index 6dc28653548..72d18047f71 100644 --- a/apps/sim/serializer/saved-credentials.test.ts +++ b/apps/sim/serializer/saved-credentials.test.ts @@ -29,9 +29,11 @@ import { CloudTrailBlock, CloudTrailV2Block } from '@/blocks/blocks/cloudtrail' import { CloudWatchBlock, CloudWatchV2Block } from '@/blocks/blocks/cloudwatch' import { CodePipelineBlock, CodePipelineV2Block } from '@/blocks/blocks/codepipeline' import { ConvexBlock, ConvexV2Block } from '@/blocks/blocks/convex' +import { CrowdStrikeBlock, CrowdStrikeV2Block } from '@/blocks/blocks/crowdstrike' import { CrunchbaseBlock, CrunchbaseV2Block } from '@/blocks/blocks/crunchbase' import { CursorV2Block, CursorV3Block } from '@/blocks/blocks/cursor' import { DatabricksBlock, DatabricksV2Block } from '@/blocks/blocks/databricks' +import { DatadogBlock, DatadogV2Block } from '@/blocks/blocks/datadog' import { DaytonaBlock, DaytonaV2Block } from '@/blocks/blocks/daytona' import { DevinBlock, DevinV2Block } from '@/blocks/blocks/devin' import { DiscordBlock, DiscordV2Block } from '@/blocks/blocks/discord' @@ -48,6 +50,7 @@ import { FirefliesV2Block, FirefliesV3Block } from '@/blocks/blocks/fireflies' import { FlintBlock, FlintV2Block } from '@/blocks/blocks/flint' import { GammaBlock, GammaV2Block } from '@/blocks/blocks/gamma' import { GitLabBlock, GitLabV2Block } from '@/blocks/blocks/gitlab' +import { GongBlock, GongV2Block } from '@/blocks/blocks/gong' import { GrafanaBlock, GrafanaV2Block } from '@/blocks/blocks/grafana' import { GrainV2Block, GrainV3Block } from '@/blocks/blocks/grain' import { GranolaBlock, GranolaV2Block } from '@/blocks/blocks/granola' @@ -78,6 +81,7 @@ import { OtterBlock, OtterV2Block } from '@/blocks/blocks/otter' import { PagerDutyBlock, PagerDutyV2Block } from '@/blocks/blocks/pagerduty' import { PersonaBlock, PersonaV2Block } from '@/blocks/blocks/persona' import { PlaneBlock, PlaneV2Block } from '@/blocks/blocks/plane' +import { PlanetScaleBlock, PlanetScaleV2Block } from '@/blocks/blocks/planetscale' import { ProfoundBlock, ProfoundV2Block } from '@/blocks/blocks/profound' import { PulseV2Block, PulseV3Block } from '@/blocks/blocks/pulse' import { QuartrBlock, QuartrV2Block } from '@/blocks/blocks/quartr' @@ -90,9 +94,11 @@ import { RipplingBlock, RipplingV2Block } from '@/blocks/blocks/rippling' import { RocketlaneBlock, RocketlaneV2Block } from '@/blocks/blocks/rocketlane' import { RootlyBlock, RootlyV2Block } from '@/blocks/blocks/rootly' import { S3Block, S3V2Block } from '@/blocks/blocks/s3' +import { SailPointBlock, SailPointV2Block } from '@/blocks/blocks/sailpoint' import { SecretsManagerBlock, SecretsManagerV2Block } from '@/blocks/blocks/secrets_manager' import { SendGridBlock, SendGridV2Block } from '@/blocks/blocks/sendgrid' import { SentryBlock, SentryV2Block } from '@/blocks/blocks/sentry' +import { ServiceNowV2Block, ServiceNowV3Block } from '@/blocks/blocks/servicenow' import { SESBlock, SESV2Block } from '@/blocks/blocks/ses' import { SimilarwebBlock, SimilarwebV2Block } from '@/blocks/blocks/similarweb' import { SixtyfourBlock, SixtyfourV2Block } from '@/blocks/blocks/sixtyfour' @@ -120,7 +126,9 @@ import { VercelBlock, VercelV2Block } from '@/blocks/blocks/vercel' import { WhatsAppBlock, WhatsAppV2Block } from '@/blocks/blocks/whatsapp' import { YouComBlock, YouComV2Block } from '@/blocks/blocks/youcom' import { YouTubeBlock, YouTubeV2Block } from '@/blocks/blocks/youtube' +import { ZendeskBlock, ZendeskV2Block } from '@/blocks/blocks/zendesk' import { ZepBlock, ZepV2Block } from '@/blocks/blocks/zep' +import { ZoomInfoBlock, ZoomInfoV2Block } from '@/blocks/blocks/zoominfo' import type { BlockConfig } from '@/blocks/types' import { Serializer } from '@/serializer' import type { BlockState } from '@/stores/workflows/workflow/types' @@ -141,6 +149,8 @@ const VERSIONS: Array<{ secretIds: readonly string[] /** Replaced connection fields the legacy block never required (a host defaulting to the vendor's). */ optionalIds?: readonly string[] + /** An operation that shows every replaced field, for a legacy block that shows some only there. */ + legacyOperation?: string }> = [ { legacy: AppConfigBlock, @@ -347,7 +357,35 @@ const VERSIONS: Array<{ { legacy: UpstashBlock, successor: UpstashV2Block, secretIds: ['restUrl', 'restToken'] }, { legacy: EmailBisonBlock, successor: EmailBisonV2Block, secretIds: ['apiKey'] }, { legacy: DevinBlock, successor: DevinV2Block, secretIds: ['apiKey'] }, + { + legacy: ZendeskBlock, + successor: ZendeskV2Block, + secretIds: ['email', 'apiToken', 'subdomain'], + }, { legacy: OktaBlock, successor: OktaV2Block, secretIds: ['apiKey'] }, + { + legacy: ZoomInfoBlock, + successor: ZoomInfoV2Block, + secretIds: ['clientId', 'clientSecret'], + }, + { + legacy: SailPointBlock, + successor: SailPointV2Block, + secretIds: ['tenant', 'clientId', 'clientSecret'], + }, + { + legacy: DatadogBlock, + successor: DatadogV2Block, + secretIds: ['apiKey', 'applicationKey', 'site'], + optionalIds: ['applicationKey', 'site'], + legacyOperation: 'datadog_get_monitor', + }, + { + legacy: CrowdStrikeBlock, + successor: CrowdStrikeV2Block, + secretIds: ['clientId', 'clientSecret', 'cloud'], + optionalIds: ['cloud'], + }, { legacy: InfisicalBlock, successor: InfisicalV2Block, @@ -361,6 +399,17 @@ const VERSIONS: Array<{ secretIds: ['tenantId', 'apiKey', 'host'], optionalIds: ['host'], }, + { + legacy: PlanetScaleBlock, + successor: PlanetScaleV2Block, + secretIds: ['serviceTokenId', 'serviceToken'], + }, + { legacy: GongBlock, successor: GongV2Block, secretIds: ['accessKey', 'accessKeySecret'] }, + { + legacy: ServiceNowV2Block, + successor: ServiceNowV3Block, + secretIds: ['instanceUrl', 'username', 'password'], + }, ] function valuesFor(ids: readonly string[], prefix: string): Record { @@ -412,27 +461,35 @@ function missingRequiredFields(block: BlockState): string { } } -describe.each(VERSIONS)('$legacy.type legacy block', ({ legacy, secretIds, optionalIds = [] }) => { - beforeEach(() => { - vi.spyOn(blocks, 'getBlock').mockReturnValue(legacy) - }) +describe.each(VERSIONS)( + '$legacy.type legacy block', + ({ legacy, secretIds, optionalIds = [], legacyOperation }) => { + beforeEach(() => { + vi.spyOn(blocks, 'getBlock').mockReturnValue(legacy) + }) - it('serializes its inline secrets exactly as before', () => { - const params = serialize(buildBlock(legacy, valuesFor(secretIds, 'inline'))) + it('serializes its inline secrets exactly as before', () => { + const params = serialize( + buildBlock(legacy, { + ...(legacyOperation ? { operation: legacyOperation } : {}), + ...valuesFor(secretIds, 'inline'), + }) + ) - for (const id of secretIds) expect(params[id]).toBe(`inline-${id}`) - expect(params.oauthCredential).toBeUndefined() - }) + for (const id of secretIds) expect(params[id]).toBe(`inline-${id}`) + expect(params.oauthCredential).toBeUndefined() + }) - it('still requires its inline secrets', () => { - const requiredIds = secretIds.filter((id) => !optionalIds.includes(id)) - const message = missingRequiredFields(buildBlock(legacy, {})) - const titles = fieldTitles(legacy, requiredIds) + it('still requires its inline secrets', () => { + const requiredIds = secretIds.filter((id) => !optionalIds.includes(id)) + const message = missingRequiredFields(buildBlock(legacy, {})) + const titles = fieldTitles(legacy, requiredIds) - expect(titles).toHaveLength(requiredIds.length) - for (const title of titles) expect(message).toContain(title) - }) -}) + expect(titles).toHaveLength(requiredIds.length) + for (const title of titles) expect(message).toContain(title) + }) + } +) describe.each(VERSIONS)('$successor.type credential-only block', ({ successor, secretIds }) => { beforeEach(() => { diff --git a/apps/sim/tools/crowdstrike/create_indicators.ts b/apps/sim/tools/crowdstrike/create_indicators.ts index e1d2185a842..863d41289fe 100644 --- a/apps/sim/tools/crowdstrike/create_indicators.ts +++ b/apps/sim/tools/crowdstrike/create_indicators.ts @@ -2,6 +2,10 @@ import type { CrowdStrikeCreateIndicatorsParams, CrowdStrikeCreateIndicatorsResponse, } from '@/tools/crowdstrike/types' +import { + CROWDSTRIKE_CREDENTIAL_OAUTH, + savedCredentialTokenParam, +} from '@/tools/shared/saved-credentials' import type { InternalToolConfig } from '@/tools/types' export const crowdstrikeCreateIndicatorsTool: InternalToolConfig< @@ -13,6 +17,7 @@ export const crowdstrikeCreateIndicatorsTool: InternalToolConfig< description: 'Create custom CrowdStrike Falcon indicators of compromise (POST /iocs/entities/indicators/v1). Each indicator can allow, detect, or block activity across the fleet, so a wrong value can suppress detections or break legitimate software. Requires the "IOC Management: Write" API scope.', version: '1.0.0', + oauth: CROWDSTRIKE_CREDENTIAL_OAUTH, params: { clientId: { @@ -33,6 +38,7 @@ export const crowdstrikeCreateIndicatorsTool: InternalToolConfig< visibility: 'user-only', description: 'CrowdStrike Falcon cloud region', }, + accessToken: savedCredentialTokenParam('CrowdStrike'), indicators: { type: 'json', required: true, @@ -65,6 +71,7 @@ export const crowdstrikeCreateIndicatorsTool: InternalToolConfig< cloud: params.cloud, clientId: params.clientId, clientSecret: params.clientSecret, + accessToken: params.accessToken, comment: params.comment, ignoreWarnings: params.ignoreWarnings, indicators: params.indicators, diff --git a/apps/sim/tools/crowdstrike/delete_indicators.ts b/apps/sim/tools/crowdstrike/delete_indicators.ts index 6ccba973ecc..0af39eb5107 100644 --- a/apps/sim/tools/crowdstrike/delete_indicators.ts +++ b/apps/sim/tools/crowdstrike/delete_indicators.ts @@ -2,6 +2,10 @@ import type { CrowdStrikeDeleteIndicatorsParams, CrowdStrikeDeleteIndicatorsResponse, } from '@/tools/crowdstrike/types' +import { + CROWDSTRIKE_CREDENTIAL_OAUTH, + savedCredentialTokenParam, +} from '@/tools/shared/saved-credentials' import type { InternalToolConfig } from '@/tools/types' export const crowdstrikeDeleteIndicatorsTool: InternalToolConfig< @@ -13,6 +17,7 @@ export const crowdstrikeDeleteIndicatorsTool: InternalToolConfig< description: 'Permanently delete custom CrowdStrike Falcon indicators of compromise (DELETE /iocs/entities/indicators/v1). Cannot be undone; deleting a blocking indicator removes that protection from every host, and a broad filter can delete far more than intended. Supply an ID list or a filter, never both -- CrowdStrike lets a filter silently override the IDs, so this tool rejects that instead. Requires the "IOC Management: Write" API scope.', version: '1.0.0', + oauth: CROWDSTRIKE_CREDENTIAL_OAUTH, params: { clientId: { @@ -33,6 +38,7 @@ export const crowdstrikeDeleteIndicatorsTool: InternalToolConfig< visibility: 'user-only', description: 'CrowdStrike Falcon cloud region', }, + accessToken: savedCredentialTokenParam('CrowdStrike'), indicatorIds: { type: 'json', required: false, @@ -59,6 +65,7 @@ export const crowdstrikeDeleteIndicatorsTool: InternalToolConfig< cloud: params.cloud, clientId: params.clientId, clientSecret: params.clientSecret, + accessToken: params.accessToken, comment: params.comment, filter: params.filter, indicatorIds: params.indicatorIds, diff --git a/apps/sim/tools/crowdstrike/delete_rtr_session.ts b/apps/sim/tools/crowdstrike/delete_rtr_session.ts index 7c4d572956e..1523f886e91 100644 --- a/apps/sim/tools/crowdstrike/delete_rtr_session.ts +++ b/apps/sim/tools/crowdstrike/delete_rtr_session.ts @@ -2,6 +2,10 @@ import type { CrowdStrikeDeleteRtrSessionParams, CrowdStrikeDeleteRtrSessionResponse, } from '@/tools/crowdstrike/types' +import { + CROWDSTRIKE_CREDENTIAL_OAUTH, + savedCredentialTokenParam, +} from '@/tools/shared/saved-credentials' import type { InternalToolConfig } from '@/tools/types' export const crowdstrikeDeleteRtrSessionTool: InternalToolConfig< @@ -13,6 +17,7 @@ export const crowdstrikeDeleteRtrSessionTool: InternalToolConfig< description: 'Close an open CrowdStrike Falcon Real Time Response session (DELETE /real-time-response/entities/sessions/v1). Requires the "Real time response: Read" API scope.', version: '1.0.0', + oauth: CROWDSTRIKE_CREDENTIAL_OAUTH, params: { clientId: { @@ -33,6 +38,7 @@ export const crowdstrikeDeleteRtrSessionTool: InternalToolConfig< visibility: 'user-only', description: 'CrowdStrike Falcon cloud region', }, + accessToken: savedCredentialTokenParam('CrowdStrike'), sessionId: { type: 'string', required: true, @@ -46,6 +52,7 @@ export const crowdstrikeDeleteRtrSessionTool: InternalToolConfig< cloud: params.cloud, clientId: params.clientId, clientSecret: params.clientSecret, + accessToken: params.accessToken, operation: 'crowdstrike_delete_rtr_session', sessionId: params.sessionId, }), diff --git a/apps/sim/tools/crowdstrike/execute_rtr_command.ts b/apps/sim/tools/crowdstrike/execute_rtr_command.ts index 38ba5949cf7..1c79d117e89 100644 --- a/apps/sim/tools/crowdstrike/execute_rtr_command.ts +++ b/apps/sim/tools/crowdstrike/execute_rtr_command.ts @@ -2,6 +2,10 @@ import type { CrowdStrikeExecuteRtrCommandParams, CrowdStrikeExecuteRtrCommandResponse, } from '@/tools/crowdstrike/types' +import { + CROWDSTRIKE_CREDENTIAL_OAUTH, + savedCredentialTokenParam, +} from '@/tools/shared/saved-credentials' import type { InternalToolConfig } from '@/tools/types' export const crowdstrikeExecuteRtrCommandTool: InternalToolConfig< @@ -13,6 +17,7 @@ export const crowdstrikeExecuteRtrCommandTool: InternalToolConfig< description: 'Run a read-only Real Time Response command in an open CrowdStrike Falcon session (POST /real-time-response/entities/command/v1). baseCommand names the family only (cat, cd, clear, csrutil, env, eventlog, filehash, getsid, help, history, ifconfig, ipconfig, ls, mount, netstat, ps, reg, users); subcommands go in commandString. Host-modifying commands need the Active Responder or Admin endpoints. Requires the "Real time response: Read" API scope.', version: '1.0.0', + oauth: CROWDSTRIKE_CREDENTIAL_OAUTH, params: { clientId: { @@ -33,6 +38,7 @@ export const crowdstrikeExecuteRtrCommandTool: InternalToolConfig< visibility: 'user-only', description: 'CrowdStrike Falcon cloud region', }, + accessToken: savedCredentialTokenParam('CrowdStrike'), sessionId: { type: 'string', required: true, @@ -61,6 +67,7 @@ export const crowdstrikeExecuteRtrCommandTool: InternalToolConfig< cloud: params.cloud, clientId: params.clientId, clientSecret: params.clientSecret, + accessToken: params.accessToken, commandString: params.commandString, operation: 'crowdstrike_execute_rtr_command', sessionId: params.sessionId, diff --git a/apps/sim/tools/crowdstrike/get_alert_details.ts b/apps/sim/tools/crowdstrike/get_alert_details.ts index a4a2146bc86..65797325539 100644 --- a/apps/sim/tools/crowdstrike/get_alert_details.ts +++ b/apps/sim/tools/crowdstrike/get_alert_details.ts @@ -2,6 +2,10 @@ import type { CrowdStrikeGetAlertDetailsParams, CrowdStrikeGetAlertDetailsResponse, } from '@/tools/crowdstrike/types' +import { + CROWDSTRIKE_CREDENTIAL_OAUTH, + savedCredentialTokenParam, +} from '@/tools/shared/saved-credentials' import type { InternalToolConfig } from '@/tools/types' export const crowdstrikeGetAlertDetailsTool: InternalToolConfig< @@ -13,6 +17,7 @@ export const crowdstrikeGetAlertDetailsTool: InternalToolConfig< description: 'Get full CrowdStrike Falcon alert records for one or more composite alert IDs (POST /alerts/entities/alerts/v2). Requires the "Alerts: Read" API scope.', version: '1.0.0', + oauth: CROWDSTRIKE_CREDENTIAL_OAUTH, params: { clientId: { @@ -33,6 +38,7 @@ export const crowdstrikeGetAlertDetailsTool: InternalToolConfig< visibility: 'user-only', description: 'CrowdStrike Falcon cloud region', }, + accessToken: savedCredentialTokenParam('CrowdStrike'), compositeIds: { type: 'json', required: true, @@ -52,6 +58,7 @@ export const crowdstrikeGetAlertDetailsTool: InternalToolConfig< cloud: params.cloud, clientId: params.clientId, clientSecret: params.clientSecret, + accessToken: params.accessToken, compositeIds: params.compositeIds, includeHidden: params.includeHidden, operation: 'crowdstrike_get_alert_details', diff --git a/apps/sim/tools/crowdstrike/get_case_details.ts b/apps/sim/tools/crowdstrike/get_case_details.ts index 3d74bc00420..d469d6a9440 100644 --- a/apps/sim/tools/crowdstrike/get_case_details.ts +++ b/apps/sim/tools/crowdstrike/get_case_details.ts @@ -2,6 +2,10 @@ import type { CrowdStrikeGetCaseDetailsParams, CrowdStrikeGetCaseDetailsResponse, } from '@/tools/crowdstrike/types' +import { + CROWDSTRIKE_CREDENTIAL_OAUTH, + savedCredentialTokenParam, +} from '@/tools/shared/saved-credentials' import type { InternalToolConfig } from '@/tools/types' export const crowdstrikeGetCaseDetailsTool: InternalToolConfig< @@ -13,6 +17,7 @@ export const crowdstrikeGetCaseDetailsTool: InternalToolConfig< description: 'Get CrowdStrike Falcon Case Management case records for one or more case IDs (POST /cases/entities/cases/v2). Requires the "Cases: Read" API scope.', version: '1.0.0', + oauth: CROWDSTRIKE_CREDENTIAL_OAUTH, params: { clientId: { @@ -33,6 +38,7 @@ export const crowdstrikeGetCaseDetailsTool: InternalToolConfig< visibility: 'user-only', description: 'CrowdStrike Falcon cloud region', }, + accessToken: savedCredentialTokenParam('CrowdStrike'), caseIds: { type: 'json', required: true, @@ -47,6 +53,7 @@ export const crowdstrikeGetCaseDetailsTool: InternalToolConfig< cloud: params.cloud, clientId: params.clientId, clientSecret: params.clientSecret, + accessToken: params.accessToken, operation: 'crowdstrike_get_case_details', }), }, diff --git a/apps/sim/tools/crowdstrike/get_host_group_details.ts b/apps/sim/tools/crowdstrike/get_host_group_details.ts index 8c73e722779..c0de0d17cb7 100644 --- a/apps/sim/tools/crowdstrike/get_host_group_details.ts +++ b/apps/sim/tools/crowdstrike/get_host_group_details.ts @@ -2,6 +2,10 @@ import type { CrowdStrikeGetHostGroupDetailsParams, CrowdStrikeGetHostGroupDetailsResponse, } from '@/tools/crowdstrike/types' +import { + CROWDSTRIKE_CREDENTIAL_OAUTH, + savedCredentialTokenParam, +} from '@/tools/shared/saved-credentials' import type { InternalToolConfig } from '@/tools/types' export const crowdstrikeGetHostGroupDetailsTool: InternalToolConfig< @@ -13,6 +17,7 @@ export const crowdstrikeGetHostGroupDetailsTool: InternalToolConfig< description: 'Get CrowdStrike Falcon host group records for one or more group IDs (GET /devices/entities/host-groups/v1). Requires the "Host groups: Read" API scope.', version: '1.0.0', + oauth: CROWDSTRIKE_CREDENTIAL_OAUTH, params: { clientId: { @@ -33,6 +38,7 @@ export const crowdstrikeGetHostGroupDetailsTool: InternalToolConfig< visibility: 'user-only', description: 'CrowdStrike Falcon cloud region', }, + accessToken: savedCredentialTokenParam('CrowdStrike'), hostGroupIds: { type: 'json', required: true, @@ -46,6 +52,7 @@ export const crowdstrikeGetHostGroupDetailsTool: InternalToolConfig< cloud: params.cloud, clientId: params.clientId, clientSecret: params.clientSecret, + accessToken: params.accessToken, hostGroupIds: params.hostGroupIds, operation: 'crowdstrike_get_host_group_details', }), diff --git a/apps/sim/tools/crowdstrike/get_indicator_details.ts b/apps/sim/tools/crowdstrike/get_indicator_details.ts index 08c1fd4019d..d9387e1699a 100644 --- a/apps/sim/tools/crowdstrike/get_indicator_details.ts +++ b/apps/sim/tools/crowdstrike/get_indicator_details.ts @@ -2,6 +2,10 @@ import type { CrowdStrikeGetIndicatorDetailsParams, CrowdStrikeGetIndicatorDetailsResponse, } from '@/tools/crowdstrike/types' +import { + CROWDSTRIKE_CREDENTIAL_OAUTH, + savedCredentialTokenParam, +} from '@/tools/shared/saved-credentials' import type { InternalToolConfig } from '@/tools/types' export const crowdstrikeGetIndicatorDetailsTool: InternalToolConfig< @@ -13,6 +17,7 @@ export const crowdstrikeGetIndicatorDetailsTool: InternalToolConfig< description: 'Get custom CrowdStrike Falcon indicator of compromise (IOC) records for one or more IOC IDs (GET /iocs/entities/indicators/v1). Requires the "IOC Management: Read" API scope.', version: '1.0.0', + oauth: CROWDSTRIKE_CREDENTIAL_OAUTH, params: { clientId: { @@ -33,6 +38,7 @@ export const crowdstrikeGetIndicatorDetailsTool: InternalToolConfig< visibility: 'user-only', description: 'CrowdStrike Falcon cloud region', }, + accessToken: savedCredentialTokenParam('CrowdStrike'), indicatorIds: { type: 'json', required: true, @@ -46,6 +52,7 @@ export const crowdstrikeGetIndicatorDetailsTool: InternalToolConfig< cloud: params.cloud, clientId: params.clientId, clientSecret: params.clientSecret, + accessToken: params.accessToken, indicatorIds: params.indicatorIds, operation: 'crowdstrike_get_indicator_details', }), diff --git a/apps/sim/tools/crowdstrike/get_rtr_command_status.ts b/apps/sim/tools/crowdstrike/get_rtr_command_status.ts index c9dbccd562a..988b44aaa9a 100644 --- a/apps/sim/tools/crowdstrike/get_rtr_command_status.ts +++ b/apps/sim/tools/crowdstrike/get_rtr_command_status.ts @@ -2,6 +2,10 @@ import type { CrowdStrikeGetRtrCommandStatusParams, CrowdStrikeGetRtrCommandStatusResponse, } from '@/tools/crowdstrike/types' +import { + CROWDSTRIKE_CREDENTIAL_OAUTH, + savedCredentialTokenParam, +} from '@/tools/shared/saved-credentials' import type { InternalToolConfig } from '@/tools/types' export const crowdstrikeGetRtrCommandStatusTool: InternalToolConfig< @@ -13,6 +17,7 @@ export const crowdstrikeGetRtrCommandStatusTool: InternalToolConfig< description: 'Get the status and output of a Real Time Response command by cloud request ID (GET /real-time-response/entities/command/v1). Long output is chunked across sequences, so increment the sequence ID to read the next chunk. Requires the "Real time response: Read" API scope.', version: '1.0.0', + oauth: CROWDSTRIKE_CREDENTIAL_OAUTH, params: { clientId: { @@ -33,6 +38,7 @@ export const crowdstrikeGetRtrCommandStatusTool: InternalToolConfig< visibility: 'user-only', description: 'CrowdStrike Falcon cloud region', }, + accessToken: savedCredentialTokenParam('CrowdStrike'), cloudRequestId: { type: 'string', required: true, @@ -52,6 +58,7 @@ export const crowdstrikeGetRtrCommandStatusTool: InternalToolConfig< cloud: params.cloud, clientId: params.clientId, clientSecret: params.clientSecret, + accessToken: params.accessToken, cloudRequestId: params.cloudRequestId, operation: 'crowdstrike_get_rtr_command_status', sequenceId: params.sequenceId, diff --git a/apps/sim/tools/crowdstrike/get_sensor_aggregates.ts b/apps/sim/tools/crowdstrike/get_sensor_aggregates.ts index 19ad9bef489..23dc31270a7 100644 --- a/apps/sim/tools/crowdstrike/get_sensor_aggregates.ts +++ b/apps/sim/tools/crowdstrike/get_sensor_aggregates.ts @@ -2,6 +2,10 @@ import type { CrowdStrikeGetSensorAggregatesParams, CrowdStrikeGetSensorAggregatesResponse, } from '@/tools/crowdstrike/types' +import { + CROWDSTRIKE_CREDENTIAL_OAUTH, + savedCredentialTokenParam, +} from '@/tools/shared/saved-credentials' import type { InternalToolConfig } from '@/tools/types' export const crowdstrikeGetSensorAggregatesTool: InternalToolConfig< @@ -13,6 +17,7 @@ export const crowdstrikeGetSensorAggregatesTool: InternalToolConfig< description: 'Aggregate CrowdStrike Identity Protection sensors from a JSON aggregate query body (POST /identity-protection/aggregates/devices/GET/v1). These are the domain controllers Falcon Identity Protection monitors, not Falcon endpoint sensors. Requires the "Identity Protection Entities: Read" API scope.', version: '1.0.0', + oauth: CROWDSTRIKE_CREDENTIAL_OAUTH, params: { clientId: { @@ -33,6 +38,7 @@ export const crowdstrikeGetSensorAggregatesTool: InternalToolConfig< visibility: 'user-only', description: 'CrowdStrike Falcon cloud region', }, + accessToken: savedCredentialTokenParam('CrowdStrike'), aggregateQuery: { type: 'json', required: true, @@ -47,6 +53,7 @@ export const crowdstrikeGetSensorAggregatesTool: InternalToolConfig< cloud: params.cloud, clientId: params.clientId, clientSecret: params.clientSecret, + accessToken: params.accessToken, operation: 'crowdstrike_get_sensor_aggregates', }), }, diff --git a/apps/sim/tools/crowdstrike/get_sensor_details.ts b/apps/sim/tools/crowdstrike/get_sensor_details.ts index a58f997c8fa..f882d90be50 100644 --- a/apps/sim/tools/crowdstrike/get_sensor_details.ts +++ b/apps/sim/tools/crowdstrike/get_sensor_details.ts @@ -2,6 +2,10 @@ import type { CrowdStrikeGetSensorDetailsParams, CrowdStrikeGetSensorDetailsResponse, } from '@/tools/crowdstrike/types' +import { + CROWDSTRIKE_CREDENTIAL_OAUTH, + savedCredentialTokenParam, +} from '@/tools/shared/saved-credentials' import type { InternalToolConfig } from '@/tools/types' export const crowdstrikeGetSensorDetailsTool: InternalToolConfig< @@ -13,6 +17,7 @@ export const crowdstrikeGetSensorDetailsTool: InternalToolConfig< description: 'Get CrowdStrike Identity Protection sensor details for one or more device IDs (POST /identity-protection/entities/devices/GET/v1). These are the domain controllers Falcon Identity Protection monitors, not Falcon endpoint sensors. Requires the "Identity Protection Entities: Read" API scope.', version: '1.0.0', + oauth: CROWDSTRIKE_CREDENTIAL_OAUTH, params: { clientId: { @@ -33,6 +38,7 @@ export const crowdstrikeGetSensorDetailsTool: InternalToolConfig< visibility: 'user-only', description: 'CrowdStrike Falcon cloud region', }, + accessToken: savedCredentialTokenParam('CrowdStrike'), ids: { type: 'json', required: true, @@ -46,6 +52,7 @@ export const crowdstrikeGetSensorDetailsTool: InternalToolConfig< cloud: params.cloud, clientId: params.clientId, clientSecret: params.clientSecret, + accessToken: params.accessToken, ids: params.ids, operation: 'crowdstrike_get_sensor_details', }), diff --git a/apps/sim/tools/crowdstrike/get_vulnerability_details.ts b/apps/sim/tools/crowdstrike/get_vulnerability_details.ts index bc172aef105..27dfe30a52a 100644 --- a/apps/sim/tools/crowdstrike/get_vulnerability_details.ts +++ b/apps/sim/tools/crowdstrike/get_vulnerability_details.ts @@ -2,6 +2,10 @@ import type { CrowdStrikeGetVulnerabilityDetailsParams, CrowdStrikeGetVulnerabilityDetailsResponse, } from '@/tools/crowdstrike/types' +import { + CROWDSTRIKE_CREDENTIAL_OAUTH, + savedCredentialTokenParam, +} from '@/tools/shared/saved-credentials' import type { InternalToolConfig } from '@/tools/types' export const crowdstrikeGetVulnerabilityDetailsTool: InternalToolConfig< @@ -13,6 +17,7 @@ export const crowdstrikeGetVulnerabilityDetailsTool: InternalToolConfig< description: 'Get CrowdStrike Falcon Spotlight vulnerability records for one or more vulnerability IDs, including CVE, affected host, application, and remediation details (GET /spotlight/entities/vulnerabilities/v2). Requires the spotlight-vulnerabilities:read API scope, shown as "Vulnerabilities: Read" in the Falcon API client UI.', version: '1.0.0', + oauth: CROWDSTRIKE_CREDENTIAL_OAUTH, params: { clientId: { @@ -33,6 +38,7 @@ export const crowdstrikeGetVulnerabilityDetailsTool: InternalToolConfig< visibility: 'user-only', description: 'CrowdStrike Falcon cloud region', }, + accessToken: savedCredentialTokenParam('CrowdStrike'), vulnerabilityIds: { type: 'json', required: true, @@ -46,6 +52,7 @@ export const crowdstrikeGetVulnerabilityDetailsTool: InternalToolConfig< cloud: params.cloud, clientId: params.clientId, clientSecret: params.clientSecret, + accessToken: params.accessToken, operation: 'crowdstrike_get_vulnerability_details', vulnerabilityIds: params.vulnerabilityIds, }), diff --git a/apps/sim/tools/crowdstrike/init_rtr_session.ts b/apps/sim/tools/crowdstrike/init_rtr_session.ts index 097644b25e7..493c7b132d2 100644 --- a/apps/sim/tools/crowdstrike/init_rtr_session.ts +++ b/apps/sim/tools/crowdstrike/init_rtr_session.ts @@ -2,6 +2,10 @@ import type { CrowdStrikeInitRtrSessionParams, CrowdStrikeInitRtrSessionResponse, } from '@/tools/crowdstrike/types' +import { + CROWDSTRIKE_CREDENTIAL_OAUTH, + savedCredentialTokenParam, +} from '@/tools/shared/saved-credentials' import type { InternalToolConfig } from '@/tools/types' export const crowdstrikeInitRtrSessionTool: InternalToolConfig< @@ -13,6 +17,7 @@ export const crowdstrikeInitRtrSessionTool: InternalToolConfig< description: 'Open a CrowdStrike Falcon Real Time Response session against a host so read-only commands can be run on it (POST /real-time-response/entities/sessions/v1). This connects a live remote shell to the endpoint. Requires the "Real time response: Read" API scope.', version: '1.0.0', + oauth: CROWDSTRIKE_CREDENTIAL_OAUTH, params: { clientId: { @@ -33,6 +38,7 @@ export const crowdstrikeInitRtrSessionTool: InternalToolConfig< visibility: 'user-only', description: 'CrowdStrike Falcon cloud region', }, + accessToken: savedCredentialTokenParam('CrowdStrike'), deviceId: { type: 'string', required: true, @@ -58,6 +64,7 @@ export const crowdstrikeInitRtrSessionTool: InternalToolConfig< cloud: params.cloud, clientId: params.clientId, clientSecret: params.clientSecret, + accessToken: params.accessToken, deviceId: params.deviceId, operation: 'crowdstrike_init_rtr_session', origin: params.origin, diff --git a/apps/sim/tools/crowdstrike/perform_host_action.ts b/apps/sim/tools/crowdstrike/perform_host_action.ts index 75207fe7cbf..d44678da64e 100644 --- a/apps/sim/tools/crowdstrike/perform_host_action.ts +++ b/apps/sim/tools/crowdstrike/perform_host_action.ts @@ -2,6 +2,10 @@ import type { CrowdStrikePerformHostActionParams, CrowdStrikePerformHostActionResponse, } from '@/tools/crowdstrike/types' +import { + CROWDSTRIKE_CREDENTIAL_OAUTH, + savedCredentialTokenParam, +} from '@/tools/shared/saved-credentials' import type { InternalToolConfig } from '@/tools/types' export const crowdstrikePerformHostActionTool: InternalToolConfig< @@ -13,6 +17,7 @@ export const crowdstrikePerformHostActionTool: InternalToolConfig< description: 'Act on CrowdStrike Falcon hosts (POST /devices/entities/devices-actions/v2). Actions: contain, lift_containment, hide_host, unhide_host, detection_suppress, detection_unsuppress. contain network-isolates the host so it can only reach the Falcon cloud; hide_host removes the host record from the console. Both are immediately disruptive. Up to 100 host IDs per call. Requires the "Hosts: Write" API scope.', version: '1.0.0', + oauth: CROWDSTRIKE_CREDENTIAL_OAUTH, params: { clientId: { @@ -33,6 +38,7 @@ export const crowdstrikePerformHostActionTool: InternalToolConfig< visibility: 'user-only', description: 'CrowdStrike Falcon cloud region', }, + accessToken: savedCredentialTokenParam('CrowdStrike'), actionName: { type: 'string', required: true, @@ -54,6 +60,7 @@ export const crowdstrikePerformHostActionTool: InternalToolConfig< cloud: params.cloud, clientId: params.clientId, clientSecret: params.clientSecret, + accessToken: params.accessToken, deviceIds: params.deviceIds, operation: 'crowdstrike_perform_host_action', }), diff --git a/apps/sim/tools/crowdstrike/perform_host_group_action.ts b/apps/sim/tools/crowdstrike/perform_host_group_action.ts index 63af58d690f..281e2794a3e 100644 --- a/apps/sim/tools/crowdstrike/perform_host_group_action.ts +++ b/apps/sim/tools/crowdstrike/perform_host_group_action.ts @@ -2,6 +2,10 @@ import type { CrowdStrikePerformHostGroupActionParams, CrowdStrikePerformHostGroupActionResponse, } from '@/tools/crowdstrike/types' +import { + CROWDSTRIKE_CREDENTIAL_OAUTH, + savedCredentialTokenParam, +} from '@/tools/shared/saved-credentials' import type { InternalToolConfig } from '@/tools/types' export const crowdstrikePerformHostGroupActionTool: InternalToolConfig< @@ -13,6 +17,7 @@ export const crowdstrikePerformHostGroupActionTool: InternalToolConfig< description: 'Add hosts to or remove hosts from a CrowdStrike Falcon static host group (POST /devices/entities/host-group-actions/v1). Group membership drives policy assignment, so changing it changes which policies apply to those hosts. Requires the "Host groups: Write" API scope.', version: '1.0.0', + oauth: CROWDSTRIKE_CREDENTIAL_OAUTH, params: { clientId: { @@ -33,6 +38,7 @@ export const crowdstrikePerformHostGroupActionTool: InternalToolConfig< visibility: 'user-only', description: 'CrowdStrike Falcon cloud region', }, + accessToken: savedCredentialTokenParam('CrowdStrike'), actionName: { type: 'string', required: true, @@ -60,6 +66,7 @@ export const crowdstrikePerformHostGroupActionTool: InternalToolConfig< cloud: params.cloud, clientId: params.clientId, clientSecret: params.clientSecret, + accessToken: params.accessToken, deviceIds: params.deviceIds, hostGroupId: params.hostGroupId, operation: 'crowdstrike_perform_host_group_action', diff --git a/apps/sim/tools/crowdstrike/query_alerts.ts b/apps/sim/tools/crowdstrike/query_alerts.ts index 53c7ae73d2e..57f846a01cf 100644 --- a/apps/sim/tools/crowdstrike/query_alerts.ts +++ b/apps/sim/tools/crowdstrike/query_alerts.ts @@ -2,6 +2,10 @@ import type { CrowdStrikeQueryAlertsParams, CrowdStrikeQueryAlertsResponse, } from '@/tools/crowdstrike/types' +import { + CROWDSTRIKE_CREDENTIAL_OAUTH, + savedCredentialTokenParam, +} from '@/tools/shared/saved-credentials' import type { InternalToolConfig } from '@/tools/types' export const crowdstrikeQueryAlertsTool: InternalToolConfig< @@ -13,6 +17,7 @@ export const crowdstrikeQueryAlertsTool: InternalToolConfig< description: 'Search CrowdStrike Falcon alerts with a Falcon Query Language filter and return their composite IDs. Uses the current Alerts API (GET /alerts/queries/alerts/v2), which replaced the Detects API decommissioned on September 30, 2025. Requires the "Alerts: Read" API scope.', version: '1.0.0', + oauth: CROWDSTRIKE_CREDENTIAL_OAUTH, params: { clientId: { @@ -33,6 +38,7 @@ export const crowdstrikeQueryAlertsTool: InternalToolConfig< visibility: 'user-only', description: 'CrowdStrike Falcon cloud region', }, + accessToken: savedCredentialTokenParam('CrowdStrike'), filter: { type: 'string', required: false, @@ -76,6 +82,7 @@ export const crowdstrikeQueryAlertsTool: InternalToolConfig< cloud: params.cloud, clientId: params.clientId, clientSecret: params.clientSecret, + accessToken: params.accessToken, filter: params.filter, includeHidden: params.includeHidden, limit: params.limit, diff --git a/apps/sim/tools/crowdstrike/query_cases.ts b/apps/sim/tools/crowdstrike/query_cases.ts index 8947349b1d1..43896d7bc3c 100644 --- a/apps/sim/tools/crowdstrike/query_cases.ts +++ b/apps/sim/tools/crowdstrike/query_cases.ts @@ -2,6 +2,10 @@ import type { CrowdStrikeQueryCasesParams, CrowdStrikeQueryCasesResponse, } from '@/tools/crowdstrike/types' +import { + CROWDSTRIKE_CREDENTIAL_OAUTH, + savedCredentialTokenParam, +} from '@/tools/shared/saved-credentials' import type { InternalToolConfig } from '@/tools/types' export const crowdstrikeQueryCasesTool: InternalToolConfig< @@ -13,6 +17,7 @@ export const crowdstrikeQueryCasesTool: InternalToolConfig< description: 'Search CrowdStrike Falcon Case Management cases with a Falcon Query Language filter and return their IDs (GET /cases/queries/cases/v1). Case Management supersedes the CrowdScore Incidents API, which CrowdStrike has removed from its published API spec. Requires the "Cases: Read" API scope.', version: '1.0.0', + oauth: CROWDSTRIKE_CREDENTIAL_OAUTH, params: { clientId: { @@ -33,6 +38,7 @@ export const crowdstrikeQueryCasesTool: InternalToolConfig< visibility: 'user-only', description: 'CrowdStrike Falcon cloud region', }, + accessToken: savedCredentialTokenParam('CrowdStrike'), filter: { type: 'string', required: false, @@ -71,6 +77,7 @@ export const crowdstrikeQueryCasesTool: InternalToolConfig< cloud: params.cloud, clientId: params.clientId, clientSecret: params.clientSecret, + accessToken: params.accessToken, filter: params.filter, limit: params.limit, offset: params.offset, diff --git a/apps/sim/tools/crowdstrike/query_host_groups.ts b/apps/sim/tools/crowdstrike/query_host_groups.ts index 5fc76784fc8..350a54c46cf 100644 --- a/apps/sim/tools/crowdstrike/query_host_groups.ts +++ b/apps/sim/tools/crowdstrike/query_host_groups.ts @@ -2,6 +2,10 @@ import type { CrowdStrikeQueryHostGroupsParams, CrowdStrikeQueryHostGroupsResponse, } from '@/tools/crowdstrike/types' +import { + CROWDSTRIKE_CREDENTIAL_OAUTH, + savedCredentialTokenParam, +} from '@/tools/shared/saved-credentials' import type { InternalToolConfig } from '@/tools/types' export const crowdstrikeQueryHostGroupsTool: InternalToolConfig< @@ -13,6 +17,7 @@ export const crowdstrikeQueryHostGroupsTool: InternalToolConfig< description: 'Search CrowdStrike Falcon host groups with a Falcon Query Language filter and return their IDs (GET /devices/queries/host-groups/v1). Requires the "Host groups: Read" API scope.', version: '1.0.0', + oauth: CROWDSTRIKE_CREDENTIAL_OAUTH, params: { clientId: { @@ -33,6 +38,7 @@ export const crowdstrikeQueryHostGroupsTool: InternalToolConfig< visibility: 'user-only', description: 'CrowdStrike Falcon cloud region', }, + accessToken: savedCredentialTokenParam('CrowdStrike'), filter: { type: 'string', required: false, @@ -64,6 +70,7 @@ export const crowdstrikeQueryHostGroupsTool: InternalToolConfig< cloud: params.cloud, clientId: params.clientId, clientSecret: params.clientSecret, + accessToken: params.accessToken, filter: params.filter, limit: params.limit, offset: params.offset, diff --git a/apps/sim/tools/crowdstrike/query_indicators.ts b/apps/sim/tools/crowdstrike/query_indicators.ts index b9150ce2492..588e7f338c6 100644 --- a/apps/sim/tools/crowdstrike/query_indicators.ts +++ b/apps/sim/tools/crowdstrike/query_indicators.ts @@ -2,6 +2,10 @@ import type { CrowdStrikeQueryIndicatorsParams, CrowdStrikeQueryIndicatorsResponse, } from '@/tools/crowdstrike/types' +import { + CROWDSTRIKE_CREDENTIAL_OAUTH, + savedCredentialTokenParam, +} from '@/tools/shared/saved-credentials' import type { InternalToolConfig } from '@/tools/types' export const crowdstrikeQueryIndicatorsTool: InternalToolConfig< @@ -13,6 +17,7 @@ export const crowdstrikeQueryIndicatorsTool: InternalToolConfig< description: 'Search custom CrowdStrike Falcon indicators of compromise (IOCs) with a Falcon Query Language filter and return their IDs (GET /iocs/queries/indicators/v1). Requires the "IOC Management: Read" API scope.', version: '1.0.0', + oauth: CROWDSTRIKE_CREDENTIAL_OAUTH, params: { clientId: { @@ -33,6 +38,7 @@ export const crowdstrikeQueryIndicatorsTool: InternalToolConfig< visibility: 'user-only', description: 'CrowdStrike Falcon cloud region', }, + accessToken: savedCredentialTokenParam('CrowdStrike'), filter: { type: 'string', required: false, @@ -74,6 +80,7 @@ export const crowdstrikeQueryIndicatorsTool: InternalToolConfig< cloud: params.cloud, clientId: params.clientId, clientSecret: params.clientSecret, + accessToken: params.accessToken, filter: params.filter, limit: params.limit, offset: params.offset, diff --git a/apps/sim/tools/crowdstrike/query_sensors.ts b/apps/sim/tools/crowdstrike/query_sensors.ts index db35bef0a4c..45773ccbd4f 100644 --- a/apps/sim/tools/crowdstrike/query_sensors.ts +++ b/apps/sim/tools/crowdstrike/query_sensors.ts @@ -2,6 +2,10 @@ import type { CrowdStrikeQuerySensorsParams, CrowdStrikeQuerySensorsResponse, } from '@/tools/crowdstrike/types' +import { + CROWDSTRIKE_CREDENTIAL_OAUTH, + savedCredentialTokenParam, +} from '@/tools/shared/saved-credentials' import type { InternalToolConfig } from '@/tools/types' export const crowdstrikeQuerySensorsTool: InternalToolConfig< @@ -13,6 +17,7 @@ export const crowdstrikeQuerySensorsTool: InternalToolConfig< description: 'Search CrowdStrike Identity Protection sensors -- the domain controllers Falcon Identity Protection monitors, not Falcon endpoint sensors -- and return their device IDs (GET /identity-protection/queries/devices/v1). Sort uses the dot form, for example status.desc. Requires the "Identity Protection Entities: Read" API scope, a separate entitlement from Hosts and Alerts.', version: '1.0.0', + oauth: CROWDSTRIKE_CREDENTIAL_OAUTH, params: { clientId: { @@ -33,6 +38,7 @@ export const crowdstrikeQuerySensorsTool: InternalToolConfig< visibility: 'user-only', description: 'CrowdStrike Falcon cloud region', }, + accessToken: savedCredentialTokenParam('CrowdStrike'), filter: { type: 'string', required: false, @@ -64,6 +70,7 @@ export const crowdstrikeQuerySensorsTool: InternalToolConfig< cloud: params.cloud, clientId: params.clientId, clientSecret: params.clientSecret, + accessToken: params.accessToken, filter: params.filter, limit: params.limit, offset: params.offset, diff --git a/apps/sim/tools/crowdstrike/query_vulnerabilities.ts b/apps/sim/tools/crowdstrike/query_vulnerabilities.ts index 214225a95f4..ec7dc4494ec 100644 --- a/apps/sim/tools/crowdstrike/query_vulnerabilities.ts +++ b/apps/sim/tools/crowdstrike/query_vulnerabilities.ts @@ -2,6 +2,10 @@ import type { CrowdStrikeQueryVulnerabilitiesParams, CrowdStrikeQueryVulnerabilitiesResponse, } from '@/tools/crowdstrike/types' +import { + CROWDSTRIKE_CREDENTIAL_OAUTH, + savedCredentialTokenParam, +} from '@/tools/shared/saved-credentials' import type { InternalToolConfig } from '@/tools/types' export const crowdstrikeQueryVulnerabilitiesTool: InternalToolConfig< @@ -13,6 +17,7 @@ export const crowdstrikeQueryVulnerabilitiesTool: InternalToolConfig< description: 'Search CrowdStrike Falcon Spotlight vulnerabilities with a required Falcon Query Language filter and return their IDs (GET /spotlight/queries/vulnerabilities/v1). Requires the spotlight-vulnerabilities:read API scope, shown as "Vulnerabilities: Read" in the Falcon API client UI.', version: '1.0.0', + oauth: CROWDSTRIKE_CREDENTIAL_OAUTH, params: { clientId: { @@ -33,6 +38,7 @@ export const crowdstrikeQueryVulnerabilitiesTool: InternalToolConfig< visibility: 'user-only', description: 'CrowdStrike Falcon cloud region', }, + accessToken: savedCredentialTokenParam('CrowdStrike'), filter: { type: 'string', required: true, @@ -66,6 +72,7 @@ export const crowdstrikeQueryVulnerabilitiesTool: InternalToolConfig< cloud: params.cloud, clientId: params.clientId, clientSecret: params.clientSecret, + accessToken: params.accessToken, filter: params.filter, limit: params.limit, operation: 'crowdstrike_query_vulnerabilities', diff --git a/apps/sim/tools/crowdstrike/types.ts b/apps/sim/tools/crowdstrike/types.ts index 133532b3982..ae16605b7d5 100644 --- a/apps/sim/tools/crowdstrike/types.ts +++ b/apps/sim/tools/crowdstrike/types.ts @@ -1,13 +1,27 @@ import type { ToolResponse } from '@/tools/types' -export type CrowdStrikeCloud = 'us-1' | 'us-2' | 'us-3' | 'eu-1' | 'us-gov-1' | 'us-gov-2' +/** Falcon clouds Sim reaches; `lib/internal/crowdstrike/client` maps each to its API origin. */ +export const CROWDSTRIKE_CLOUDS = ['us-1', 'us-2', 'us-3', 'eu-1', 'us-gov-1', 'us-gov-2'] as const -export interface CrowdStrikeBaseParams { +export type CrowdStrikeCloud = (typeof CROWDSTRIKE_CLOUDS)[number] + +/** API client credentials typed inline on the legacy block. */ +export interface CrowdStrikeInlineCredentials { clientId: string clientSecret: string cloud: CrowdStrikeCloud } +/** + * Inline API client credentials, or the token and Falcon cloud the executor + * resolves from a saved CrowdStrike credential in their place. + */ +interface CrowdStrikeBaseParams + extends Partial> { + cloud: CrowdStrikeCloud + accessToken?: string +} + export interface CrowdStrikeQuerySensorsParams extends CrowdStrikeBaseParams { filter?: string limit?: number diff --git a/apps/sim/tools/crowdstrike/update_alerts.ts b/apps/sim/tools/crowdstrike/update_alerts.ts index 37299c31544..ec8b1e0b383 100644 --- a/apps/sim/tools/crowdstrike/update_alerts.ts +++ b/apps/sim/tools/crowdstrike/update_alerts.ts @@ -2,6 +2,10 @@ import type { CrowdStrikeUpdateAlertsParams, CrowdStrikeUpdateAlertsResponse, } from '@/tools/crowdstrike/types' +import { + CROWDSTRIKE_CREDENTIAL_OAUTH, + savedCredentialTokenParam, +} from '@/tools/shared/saved-credentials' import type { InternalToolConfig } from '@/tools/types' export const crowdstrikeUpdateAlertsTool: InternalToolConfig< @@ -13,6 +17,7 @@ export const crowdstrikeUpdateAlertsTool: InternalToolConfig< description: 'Update CrowdStrike Falcon alerts by composite ID: change status, assign or unassign an analyst, add or remove tags, append a comment, or toggle visibility (PATCH /alerts/entities/alerts/v3). This modifies live alerts in the Falcon console. Requires the "Alerts: Write" API scope.', version: '1.0.0', + oauth: CROWDSTRIKE_CREDENTIAL_OAUTH, params: { clientId: { @@ -33,6 +38,7 @@ export const crowdstrikeUpdateAlertsTool: InternalToolConfig< visibility: 'user-only', description: 'CrowdStrike Falcon cloud region', }, + accessToken: savedCredentialTokenParam('CrowdStrike'), compositeIds: { type: 'json', required: true, @@ -125,6 +131,7 @@ export const crowdstrikeUpdateAlertsTool: InternalToolConfig< cloud: params.cloud, clientId: params.clientId, clientSecret: params.clientSecret, + accessToken: params.accessToken, compositeIds: params.compositeIds, includeHidden: params.includeHidden, operation: 'crowdstrike_update_alerts', diff --git a/apps/sim/tools/crowdstrike/update_indicators.ts b/apps/sim/tools/crowdstrike/update_indicators.ts index 183830ddbe8..96a4e9faf3e 100644 --- a/apps/sim/tools/crowdstrike/update_indicators.ts +++ b/apps/sim/tools/crowdstrike/update_indicators.ts @@ -2,6 +2,10 @@ import type { CrowdStrikeUpdateIndicatorsParams, CrowdStrikeUpdateIndicatorsResponse, } from '@/tools/crowdstrike/types' +import { + CROWDSTRIKE_CREDENTIAL_OAUTH, + savedCredentialTokenParam, +} from '@/tools/shared/saved-credentials' import type { InternalToolConfig } from '@/tools/types' export const crowdstrikeUpdateIndicatorsTool: InternalToolConfig< @@ -13,6 +17,7 @@ export const crowdstrikeUpdateIndicatorsTool: InternalToolConfig< description: 'Update custom CrowdStrike Falcon indicators of compromise by ID (PATCH /iocs/entities/indicators/v1). DESTRUCTIVE: omitted fields may be cleared, so read each indicator with crowdstrike_get_indicator_details first and resend its full field set with your edits applied. Changing action or scope changes prevention behavior fleet-wide. type and value are immutable. Requires the "IOC Management: Write" API scope.', version: '1.0.0', + oauth: CROWDSTRIKE_CREDENTIAL_OAUTH, params: { clientId: { @@ -33,6 +38,7 @@ export const crowdstrikeUpdateIndicatorsTool: InternalToolConfig< visibility: 'user-only', description: 'CrowdStrike Falcon cloud region', }, + accessToken: savedCredentialTokenParam('CrowdStrike'), indicators: { type: 'json', required: true, @@ -65,6 +71,7 @@ export const crowdstrikeUpdateIndicatorsTool: InternalToolConfig< cloud: params.cloud, clientId: params.clientId, clientSecret: params.clientSecret, + accessToken: params.accessToken, comment: params.comment, ignoreWarnings: params.ignoreWarnings, indicators: params.indicators, diff --git a/apps/sim/tools/datadog/add_incident_todo.ts b/apps/sim/tools/datadog/add_incident_todo.ts index 85a41923c0e..e3443885e85 100644 --- a/apps/sim/tools/datadog/add_incident_todo.ts +++ b/apps/sim/tools/datadog/add_incident_todo.ts @@ -6,6 +6,7 @@ import { datadogPathSegment, splitCommaList, } from '@/tools/datadog/utils' +import { DATADOG_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' export const addIncidentTodoTool: ToolConfig = { @@ -14,6 +15,7 @@ export const addIncidentTodoTool: ToolConfig = { @@ -7,6 +8,7 @@ export const cancelDowntimeTool: ToolConfig = { @@ -13,6 +14,7 @@ export const createDashboardTool: ToolConfig = { @@ -16,6 +17,7 @@ export const createDowntimeTool: ToolConfig = { @@ -13,6 +14,7 @@ export const createEventTool: ToolConfig description: 'Post an event to the Datadog event stream. Use for deployment notifications, alerts, or any significant occurrences.', version: '1.0.0', + oauth: DATADOG_API_KEY_CREDENTIAL_OAUTH, params: { title: { diff --git a/apps/sim/tools/datadog/create_incident.ts b/apps/sim/tools/datadog/create_incident.ts index 5e6a2d71148..50daeaa917f 100644 --- a/apps/sim/tools/datadog/create_incident.ts +++ b/apps/sim/tools/datadog/create_incident.ts @@ -6,6 +6,7 @@ import { parseJsonParam, splitCommaList, } from '@/tools/datadog/utils' +import { DATADOG_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' export const createIncidentTool: ToolConfig = { @@ -14,6 +15,7 @@ export const createIncidentTool: ToolConfig = { @@ -8,6 +9,7 @@ export const createMonitorTool: ToolConfig = { @@ -13,6 +14,7 @@ export const createSloTool: ToolConfig = { description: 'Create a service level objective from a metric query, monitors, or a time-slice condition.', version: '1.0.0', + oauth: DATADOG_CREDENTIAL_OAUTH, params: { name: { diff --git a/apps/sim/tools/datadog/delete_dashboard.ts b/apps/sim/tools/datadog/delete_dashboard.ts index ca35597fd33..9297589ebb0 100644 --- a/apps/sim/tools/datadog/delete_dashboard.ts +++ b/apps/sim/tools/datadog/delete_dashboard.ts @@ -5,6 +5,7 @@ import { datadogHeaders, datadogPathSegment, } from '@/tools/datadog/utils' +import { DATADOG_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' export const deleteDashboardTool: ToolConfig = { @@ -12,6 +13,7 @@ export const deleteDashboardTool: ToolConfig = { @@ -13,6 +14,7 @@ export const deleteSloTool: ToolConfig = { description: 'Permanently delete a service level objective. Datadog returns a conflict when the SLO is still referenced by a dashboard.', version: '1.0.0', + oauth: DATADOG_CREDENTIAL_OAUTH, params: { sloId: { diff --git a/apps/sim/tools/datadog/get_browser_synthetics_results.ts b/apps/sim/tools/datadog/get_browser_synthetics_results.ts index afe8ad6521a..0c32409ae14 100644 --- a/apps/sim/tools/datadog/get_browser_synthetics_results.ts +++ b/apps/sim/tools/datadog/get_browser_synthetics_results.ts @@ -9,6 +9,7 @@ import { datadogPathSegment, splitCommaList, } from '@/tools/datadog/utils' +import { DATADOG_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' export const getBrowserSyntheticsResultsTool: ToolConfig< @@ -20,6 +21,7 @@ export const getBrowserSyntheticsResultsTool: ToolConfig< description: 'Get the latest result summaries (up to the last 150 runs) for a Synthetic browser test, including step counts and errors.', version: '1.0.0', + oauth: DATADOG_CREDENTIAL_OAUTH, params: { publicId: { diff --git a/apps/sim/tools/datadog/get_dashboard.ts b/apps/sim/tools/datadog/get_dashboard.ts index 9f94918077e..ca7c0899e37 100644 --- a/apps/sim/tools/datadog/get_dashboard.ts +++ b/apps/sim/tools/datadog/get_dashboard.ts @@ -5,6 +5,7 @@ import { datadogHeaders, datadogPathSegment, } from '@/tools/datadog/utils' +import { DATADOG_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' export const getDashboardTool: ToolConfig = { @@ -12,6 +13,7 @@ export const getDashboardTool: ToolConfig = { @@ -14,6 +15,7 @@ export const getIncidentTool: ToolConfig description: 'Get the details of a single incident by ID. Requires the Incident Management `incident_read` permission; the Incidents API is in public beta.', version: '1.0.0', + oauth: DATADOG_CREDENTIAL_OAUTH, params: { incidentId: { diff --git a/apps/sim/tools/datadog/get_monitor.ts b/apps/sim/tools/datadog/get_monitor.ts index 7d2ace45106..1414c4e1330 100644 --- a/apps/sim/tools/datadog/get_monitor.ts +++ b/apps/sim/tools/datadog/get_monitor.ts @@ -1,5 +1,6 @@ import type { GetMonitorParams, GetMonitorResponse } from '@/tools/datadog/types' import { datadogErrorMessage, datadogPathSegment, resolveDatadogSite } from '@/tools/datadog/utils' +import { DATADOG_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' export const getMonitorTool: ToolConfig = { @@ -7,6 +8,7 @@ export const getMonitorTool: ToolConfig = name: 'Datadog Get Monitor', description: 'Retrieve details of a specific monitor by ID.', version: '1.0.0', + oauth: DATADOG_CREDENTIAL_OAUTH, params: { monitorId: { diff --git a/apps/sim/tools/datadog/get_security_signal.ts b/apps/sim/tools/datadog/get_security_signal.ts index 3f8527445de..c3acf3d0254 100644 --- a/apps/sim/tools/datadog/get_security_signal.ts +++ b/apps/sim/tools/datadog/get_security_signal.ts @@ -5,6 +5,7 @@ import { datadogHeaders, datadogPathSegment, } from '@/tools/datadog/utils' +import { DATADOG_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' export const getSecuritySignalTool: ToolConfig = @@ -14,6 +15,7 @@ export const getSecuritySignalTool: ToolConfig = { @@ -12,6 +13,7 @@ export const getSloTool: ToolConfig = { name: 'Datadog Get SLO', description: 'Get the configuration of a single service level objective by ID.', version: '1.0.0', + oauth: DATADOG_CREDENTIAL_OAUTH, params: { sloId: { diff --git a/apps/sim/tools/datadog/get_slo_history.ts b/apps/sim/tools/datadog/get_slo_history.ts index a047d95d254..343902e356b 100644 --- a/apps/sim/tools/datadog/get_slo_history.ts +++ b/apps/sim/tools/datadog/get_slo_history.ts @@ -5,6 +5,7 @@ import { datadogHeaders, datadogPathSegment, } from '@/tools/datadog/utils' +import { DATADOG_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' export const getSloHistoryTool: ToolConfig = { @@ -13,6 +14,7 @@ export const getSloHistoryTool: ToolConfig = @@ -14,6 +15,7 @@ export const getSyntheticsTestTool: ToolConfig = { @@ -8,6 +9,7 @@ export const listDashboardsTool: ToolConfig = { @@ -12,6 +13,7 @@ export const listDowntimesTool: ToolConfig = { @@ -18,6 +19,7 @@ export const listIncidentsTool: ToolConfig = { @@ -7,6 +8,7 @@ export const listMonitorsTool: ToolConfig = @@ -13,6 +14,7 @@ export const listSecurityRulesTool: ToolConfig = { @@ -13,6 +14,7 @@ export const listServicesTool: ToolConfig = { @@ -8,6 +9,7 @@ export const listSlosTool: ToolConfig = { description: 'List service level objectives, optionally filtered by IDs, name, tags, or underlying metrics query.', version: '1.0.0', + oauth: DATADOG_CREDENTIAL_OAUTH, params: { ids: { diff --git a/apps/sim/tools/datadog/list_synthetics_tests.ts b/apps/sim/tools/datadog/list_synthetics_tests.ts index fef037aef29..4df58a5bb43 100644 --- a/apps/sim/tools/datadog/list_synthetics_tests.ts +++ b/apps/sim/tools/datadog/list_synthetics_tests.ts @@ -4,6 +4,7 @@ import type { SyntheticsTestData, } from '@/tools/datadog/types' import { datadogApiUrl, datadogErrorMessage, datadogHeaders } from '@/tools/datadog/utils' +import { DATADOG_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' export const listSyntheticsTestsTool: ToolConfig< @@ -14,6 +15,7 @@ export const listSyntheticsTestsTool: ToolConfig< name: 'Datadog List Synthetic Tests', description: 'List all Synthetic tests (API, browser, and mobile) with their current status.', version: '1.0.0', + oauth: DATADOG_CREDENTIAL_OAUTH, params: { pageSize: { diff --git a/apps/sim/tools/datadog/mute_monitor.ts b/apps/sim/tools/datadog/mute_monitor.ts index 04c212938d2..75417da13c1 100644 --- a/apps/sim/tools/datadog/mute_monitor.ts +++ b/apps/sim/tools/datadog/mute_monitor.ts @@ -5,6 +5,7 @@ import { datadogHeaders, datadogPathSegment, } from '@/tools/datadog/utils' +import { DATADOG_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' export const muteMonitorTool: ToolConfig = { @@ -13,6 +14,7 @@ export const muteMonitorTool: ToolConfig description: 'Mute a monitor to temporarily suppress its notifications. Use Unmute Monitor to reverse it, or schedule a downtime instead when you want a planned, auditable maintenance window.', version: '1.0.0', + oauth: DATADOG_CREDENTIAL_OAUTH, params: { monitorId: { diff --git a/apps/sim/tools/datadog/query_logs.ts b/apps/sim/tools/datadog/query_logs.ts index 60a02dc6898..9efef8bc125 100644 --- a/apps/sim/tools/datadog/query_logs.ts +++ b/apps/sim/tools/datadog/query_logs.ts @@ -5,6 +5,7 @@ import type { QueryLogsResponse, } from '@/tools/datadog/types' import { datadogErrorMessage, resolveDatadogSite } from '@/tools/datadog/utils' +import { DATADOG_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' export const queryLogsTool: ToolConfig = { @@ -13,6 +14,7 @@ export const queryLogsTool: ToolConfig = { description: 'Search and retrieve logs from Datadog. Use for troubleshooting, analysis, or monitoring.', version: '1.0.0', + oauth: DATADOG_CREDENTIAL_OAUTH, params: { query: { diff --git a/apps/sim/tools/datadog/query_timeseries.ts b/apps/sim/tools/datadog/query_timeseries.ts index 7ec5017ca2b..5a80464ecfe 100644 --- a/apps/sim/tools/datadog/query_timeseries.ts +++ b/apps/sim/tools/datadog/query_timeseries.ts @@ -4,6 +4,7 @@ import type { QueryTimeseriesResponse, } from '@/tools/datadog/types' import { datadogErrorMessage, resolveDatadogSite } from '@/tools/datadog/utils' +import { DATADOG_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' export const queryTimeseriesTool: ToolConfig = { @@ -12,6 +13,7 @@ export const queryTimeseriesTool: ToolConfig = { @@ -12,6 +13,7 @@ export const searchSpansTool: ToolConfig name: 'Datadog Search Spans', description: 'Search indexed APM spans using the span query syntax, with cursor pagination.', version: '1.0.0', + oauth: DATADOG_CREDENTIAL_OAUTH, params: { query: { diff --git a/apps/sim/tools/datadog/send_logs.ts b/apps/sim/tools/datadog/send_logs.ts index a31e56749c6..b1a0118fa78 100644 --- a/apps/sim/tools/datadog/send_logs.ts +++ b/apps/sim/tools/datadog/send_logs.ts @@ -1,6 +1,7 @@ import { filterUndefined } from '@sim/utils/object' import type { LogEntry, SendLogsParams, SendLogsResponse } from '@/tools/datadog/types' import { datadogErrorMessage, parseJsonParam, resolveDatadogSite } from '@/tools/datadog/utils' +import { DATADOG_API_KEY_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' export const sendLogsTool: ToolConfig = { @@ -8,6 +9,7 @@ export const sendLogsTool: ToolConfig = { name: 'Datadog Send Logs', description: 'Send log entries to Datadog for centralized logging and analysis.', version: '1.0.0', + oauth: DATADOG_API_KEY_CREDENTIAL_OAUTH, params: { logs: { diff --git a/apps/sim/tools/datadog/submit_metrics.ts b/apps/sim/tools/datadog/submit_metrics.ts index 39723fba90c..b6e33330635 100644 --- a/apps/sim/tools/datadog/submit_metrics.ts +++ b/apps/sim/tools/datadog/submit_metrics.ts @@ -5,6 +5,7 @@ import type { SubmitMetricsResponse, } from '@/tools/datadog/types' import { datadogErrorMessage, parseJsonParam, resolveDatadogSite } from '@/tools/datadog/utils' +import { DATADOG_API_KEY_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' /** @@ -24,6 +25,7 @@ export const submitMetricsTool: ToolConfig = { + 'datadoghq.com': 'US1 (datadoghq.com)', + 'us3.datadoghq.com': 'US3 (us3.datadoghq.com)', + 'us5.datadoghq.com': 'US5 (us5.datadoghq.com)', + 'datadoghq.eu': 'EU (datadoghq.eu)', + 'ap1.datadoghq.com': 'AP1 (ap1.datadoghq.com)', + 'ap2.datadoghq.com': 'AP2 (ap2.datadoghq.com)', + 'uk1.datadoghq.com': 'UK1 (uk1.datadoghq.com)', + 'ddog-gov.com': 'US1-FED (ddog-gov.com)', + 'us2.ddog-gov.com': 'US2-FED (us2.ddog-gov.com)', +} + /** Base parameters for write-only operations (only need API key) */ interface DatadogWriteOnlyParams { apiKey: string diff --git a/apps/sim/tools/datadog/unmute_monitor.ts b/apps/sim/tools/datadog/unmute_monitor.ts index 6c6b2cf1aa6..85166536b49 100644 --- a/apps/sim/tools/datadog/unmute_monitor.ts +++ b/apps/sim/tools/datadog/unmute_monitor.ts @@ -5,6 +5,7 @@ import { datadogHeaders, datadogPathSegment, } from '@/tools/datadog/utils' +import { DATADOG_CREDENTIAL_OAUTH } from '@/tools/shared/saved-credentials' import type { ToolConfig } from '@/tools/types' export const unmuteMonitorTool: ToolConfig = { @@ -13,6 +14,7 @@ export const unmuteMonitorTool: ToolConfig = { @@ -15,6 +16,7 @@ export const updateIncidentTool: ToolConfig = { @@ -8,6 +9,7 @@ export const updateSloTool: InternalToolConfig = JSON.parse( - '{"a2a_cancel_task":{"id":"a2a_cancel_task","name":"A2A Cancel Task","description":"Request cancellation of an in-progress A2A task.","version":"1.0.0","params":{"agentUrl":{"type":"string","required":true,"visibility":"user-only","description":"The A2A agent endpoint URL"},"taskId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The task ID to cancel"},"apiKey":{"type":"string","required":false,"visibility":"user-only","description":"API key for authentication (if required)"}},"hostedApiKey":"none"},"a2a_get_agent_card":{"id":"a2a_get_agent_card","name":"A2A Get Agent Card","description":"Fetch the Agent Card (discovery document) for an external A2A agent.","version":"1.0.0","params":{"agentUrl":{"type":"string","required":true,"visibility":"user-only","description":"The A2A agent endpoint URL"},"apiKey":{"type":"string","required":false,"visibility":"user-only","description":"API key for authentication (if required)"}},"hostedApiKey":"none"},"a2a_get_task":{"id":"a2a_get_task","name":"A2A Get Task","description":"Retrieve the current state and result of an A2A task.","version":"1.0.0","params":{"agentUrl":{"type":"string","required":true,"visibility":"user-only","description":"The A2A agent endpoint URL"},"taskId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The task ID to retrieve"},"historyLength":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of history messages to include"},"apiKey":{"type":"string","required":false,"visibility":"user-only","description":"API key for authentication (if required)"}},"hostedApiKey":"none"},"a2a_send_message":{"id":"a2a_send_message","name":"A2A Send Message","description":"Send a message to an external A2A agent and return its response.","version":"1.0.0","params":{"agentUrl":{"type":"string","required":true,"visibility":"user-only","description":"The A2A agent endpoint URL"},"message":{"type":"string","required":true,"visibility":"user-or-llm","description":"The message text to send"},"data":{"type":"json","required":false,"visibility":"user-or-llm","description":"Optional structured JSON data to attach"},"files":{"type":"json","required":false,"visibility":"user-or-llm","description":"Optional files to attach"},"taskId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Existing task ID to continue"},"contextId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Conversation context ID to continue"},"apiKey":{"type":"string","required":false,"visibility":"user-only","description":"API key for authentication (if required)"}},"hostedApiKey":"none"},"affinity_batch_update_entity_fields":{"id":"affinity_batch_update_entity_fields","name":"Affinity Batch Update Entity Fields","description":"Write up to 100 non-list field values on one company or person in a single request.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Which entity to write the fields on: companies or persons"},"entityId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of that company or person"},"updates":{"type":"json","required":true,"visibility":"user-or-llm","description":"Up to 100 field updates as [{\\"id\\":\\"\\",\\"value\\":{\\"type\\":\\"…\\",\\"data\\":…}}], using the same value shapes as a single field update"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_batch_update_list_entry_fields":{"id":"affinity_batch_update_list_entry_fields","name":"Affinity Batch Update List Entry Fields","description":"Write up to 100 field values on one list row in a single request. Requires the \\"Export data from Lists\\" permission.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"listEntryId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list entry ID"},"updates":{"type":"json","required":true,"visibility":"user-or-llm","description":"Up to 100 field updates as [{\\"id\\":\\"\\",\\"value\\":{\\"type\\":\\"…\\",\\"data\\":…}}], using the same value shapes as a single field update"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_create_list":{"id":"affinity_create_list","name":"Affinity Create List","description":"Create a list. Its type fixes which entities it can hold, and the API key holder becomes its creator and owner.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the new list"},"type":{"type":"string","required":true,"visibility":"user-or-llm","description":"Entity kind the list holds: company, opportunity, or person"},"isPublic":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Whether everyone in the organization can see the list"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_create_list_field_dropdown_option":{"id":"affinity_create_list_field_dropdown_option","name":"Affinity Create List Field Dropdown Option","description":"Add a selectable option to a dropdown field on a list. A ranked or status option also needs a rank and a color.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"fieldId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The dropdown field ID on that list"},"type":{"type":"string","required":true,"visibility":"user-or-llm","description":"Kind of option to create, matching the field. dropdown takes only a label; ranked-dropdown also requires rank and color; status-dropdown additionally requires a status category. Sending a field the kind does not accept is rejected"},"text":{"type":"string","required":true,"visibility":"user-or-llm","description":"The option label"},"rank":{"type":"number","required":false,"visibility":"user-or-llm","description":"Sort order. Required on a ranked-dropdown or status-dropdown option"},"color":{"type":"string","required":false,"visibility":"user-or-llm","description":"Option color: white, gray, blue, green, purple, orange, or red. Required on a ranked-dropdown or status-dropdown option"},"statusCategory":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pipeline meaning of the option: open, won, lost, or on-hold. Status-dropdown options only"},"winRate":{"type":"number","required":false,"visibility":"user-or-llm","description":"Expected win rate of the status. Status-dropdown options only"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_create_merge":{"id":"affinity_create_merge","name":"Affinity Create Merge","description":"Fold a duplicate company or person into the record you are keeping. The merge runs asynchronously — poll the returned task to see it finish. Requires the \\"Manage duplicates\\" permission and an admin role.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"What to merge: companies or persons"},"primaryId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the record to keep"},"duplicateId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the duplicate record to fold in"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_create_note":{"id":"affinity_create_note","name":"Affinity Create Note","description":"Write a note — attached to companies, persons, and opportunities, anchored to a meeting, call, or chat message, or posted as a reply to an existing note.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"type":{"type":"string","required":true,"visibility":"user-or-llm","description":"Note shape: entities to attach it to records, interaction to anchor it to a meeting, call, or chat message, or user-reply to reply to a note"},"html":{"type":"string","required":true,"visibility":"user-or-llm","description":"The note body as HTML"},"companyIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Companies to attach the note to, e.g. [1, 2]. Not used on a reply"},"personIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Persons to attach the note to, e.g. [1, 2]. Not used on a reply"},"opportunityIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Opportunities to attach the note to, e.g. [1, 2]. Not used on a reply"},"interactionId":{"type":"string","required":false,"visibility":"user-or-llm","description":"The interaction to anchor the note to. Required for an interaction note"},"interactionType":{"type":"string","required":false,"visibility":"user-or-llm","description":"Kind of the anchoring interaction: meeting, call, or chat-message. Required for an interaction note"},"parentId":{"type":"string","required":false,"visibility":"user-or-llm","description":"The note being replied to. Required for a user-reply note"},"creatorId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Attribute the note to another internal person. Defaults to the API key holder"},"createdAt":{"type":"string","required":false,"visibility":"user-or-llm","description":"Backdate the note to this ISO 8601 timestamp"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_create_reminder":{"id":"affinity_create_reminder","name":"Affinity Create Reminder","description":"Create a reminder on one company, person, or opportunity. A recurring reminder resets whenever the chosen signal happens instead of firing once.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"type":{"type":"string","required":true,"visibility":"user-or-llm","description":"one-time to fire once, or recurring to reset on a signal"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"What the reminder is about: company, person, or opportunity"},"entityId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of that company, person, or opportunity"},"dueDate":{"type":"string","required":false,"visibility":"user-or-llm","description":"When the reminder is due, as an ISO 8601 timestamp. Required for a one-time reminder; on a recurring one Affinity computes it from the period when omitted"},"content":{"type":"string","required":false,"visibility":"user-or-llm","description":"What the reminder says"},"ownerId":{"type":"string","required":true,"visibility":"user-or-llm","description":"User the reminder is assigned to. Must be an internal user. The API key holder is recorded as the creator, which is a separate field"},"resetTrigger":{"type":"string","required":false,"visibility":"user-or-llm","description":"What restarts a recurring reminder: interaction, email, or event. Required when the type is recurring"},"periodDays":{"type":"number","required":false,"visibility":"user-or-llm","description":"Days between firings of a recurring reminder. Required when the type is recurring"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_delete_list_field_dropdown_option":{"id":"affinity_delete_list_field_dropdown_option","name":"Affinity Delete List Field Dropdown Option","description":"Permanently delete a dropdown option on a list field. Every list entry currently set to it is cleared, and those values cannot be recovered.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"fieldId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The dropdown field ID on that list"},"dropdownOptionId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The dropdown option ID to delete"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_delete_note":{"id":"affinity_delete_note","name":"Affinity Delete Note","description":"Delete a note you created. Deleting a root note also deletes its replies; deleting a reply removes only that reply.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"noteId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The note ID to delete"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_get_company":{"id":"affinity_get_company","name":"Affinity Get Company","description":"Look up one company by ID. Field data is returned only for the Field IDs or Field Types asked for.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"companyId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The company ID"},"fieldIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field IDs to return values for, e.g. [\\"affinity-data-location\\"]. Mutually exclusive with Field Types"},"fieldTypes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field categories to return values for: enriched, global, or relationship-intelligence. Mutually exclusive with Field IDs"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_get_current_user":{"id":"affinity_get_current_user","name":"Affinity Get Current User","description":"Verify an Affinity API key and return the tenant, the user behind the key, and the scopes the grant carries.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_get_entity_field_value":{"id":"affinity_get_entity_field_value","name":"Affinity Get Entity Field Value","description":"Read one non-list field value from a company or person.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Which entity to read the field from: companies or persons"},"entityId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of that company or person"},"fieldId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The field ID to read"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_get_list":{"id":"affinity_get_list","name":"Affinity Get List","description":"Read one list — its name, type, owner, and privacy setting.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_get_list_entry":{"id":"affinity_get_list_entry","name":"Affinity Get List Entry","description":"Read one row of a list with its entity. Field data is returned only for the Field IDs or Field Types asked for.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"listEntryId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list entry ID"},"fieldIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field IDs to return values for, e.g. [\\"affinity-data-location\\"]. Mutually exclusive with Field Types"},"fieldTypes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field categories to return values for: enriched, global, list, or relationship-intelligence. Mutually exclusive with Field IDs"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_get_list_entry_field":{"id":"affinity_get_list_entry_field","name":"Affinity Get List Entry Field","description":"Read one field value on a list row.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"listEntryId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list entry ID"},"fieldId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The field ID to read"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_get_list_field_dropdown_option":{"id":"affinity_get_list_field_dropdown_option","name":"Affinity Get List Field Dropdown Option","description":"Read one dropdown option on a list field.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"fieldId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The dropdown field ID on that list"},"dropdownOptionId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The dropdown option ID"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_get_merge":{"id":"affinity_get_merge","name":"Affinity Get Merge","description":"Read the status of one company or person merge, including why it failed if it did.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Which merge to read: companies or persons"},"mergeId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The merge ID"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_get_merge_task":{"id":"affinity_get_merge_task","name":"Affinity Get Merge Task","description":"Read one merge task and how its merges are progressing. Poll this after starting a merge.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Which merge task to read: companies or persons"},"taskId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The merge task ID"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_get_note":{"id":"affinity_get_note","name":"Affinity Get Note","description":"Read one note with its body, author, mentions, and attached records.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"noteId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The note ID"},"includes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Extra properties to return, e.g. [\\"repliesCount\\",\\"personsPreview\\",\\"companiesPreview\\",\\"opportunitiesPreview\\"]. Those four fields are omitted unless requested here"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_get_opportunity":{"id":"affinity_get_opportunity","name":"Affinity Get Opportunity","description":"Read one opportunity and the list it belongs to. Its field data lives on the list entry.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"opportunityId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The opportunity ID"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_get_person":{"id":"affinity_get_person","name":"Affinity Get Person","description":"Look up one person by ID. Field data is returned only for the Field IDs or Field Types asked for.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"personId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The person ID"},"fieldIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field IDs to return values for, e.g. [\\"affinity-data-location\\"]. Mutually exclusive with Field Types"},"fieldTypes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field categories to return values for: enriched, global, or relationship-intelligence. Mutually exclusive with Field IDs"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_get_saved_view":{"id":"affinity_get_saved_view","name":"Affinity Get Saved View","description":"Read one saved view — its name, kind, and creation date.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"viewId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The saved view ID"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_get_transcript":{"id":"affinity_get_transcript","name":"Affinity Get Transcript","description":"Read one transcript with its first 100 fragments. Page the fragments endpoint for a longer meeting.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"transcriptId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The transcript ID"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_get_user":{"id":"affinity_get_user","name":"Affinity Get User","description":"Read one internal user. A user and their person record share the same numeric ID, so a person ID works here.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"userId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The user ID, which is also their person ID"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_list_calls":{"id":"affinity_list_calls","name":"Affinity List Calls","description":"Page through logged calls and their participants. Only calls the API key holder can see are returned.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression, e.g. \\"createdAt>=2026-01-01\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_list_chat_messages":{"id":"affinity_list_chat_messages","name":"Affinity List Chat Messages","description":"Page through logged chat messages and their participants. Only messages the API key holder can see are returned.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression, e.g. \\"createdAt>=2026-01-01\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_list_companies":{"id":"affinity_list_companies","name":"Affinity List Companies","description":"Page through companies. Companies come back without field data unless Field IDs or Field Types asks for it.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"ids":{"type":"json","required":false,"visibility":"user-or-llm","description":"Restrict the page to these company IDs, e.g. [1, 2, 3]"},"fieldIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field IDs to return values for, e.g. [\\"affinity-data-location\\"]. Mutually exclusive with Field Types"},"fieldTypes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field categories to return values for: enriched, global, or relationship-intelligence. Mutually exclusive with Field IDs"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_list_coworker_connections":{"id":"affinity_list_coworker_connections","name":"Affinity List Coworker Connections","description":"Find warm paths into a company through shared work history: who in your Affinity data once worked alongside the people you want to reach. Grouped by target, strongest first.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"filter":{"type":"string","required":true,"visibility":"user-or-llm","description":"Required scope. The only supported filter is target.currentCompany.id, e.g. \\"target.currentCompany.id=123\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of targets to return per page, 1-50. Defaults to 20"},"totalCount":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the total size of the collection. Costs an extra query"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_list_emails":{"id":"affinity_list_emails","name":"Affinity List Emails","description":"Page through email metadata — subject, participants, and timestamps. Affinity never exposes email bodies through the API.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression, e.g. \\"createdAt>=2026-01-01\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_list_entity_field_values":{"id":"affinity_list_entity_field_values","name":"Affinity List Entity Field Values","description":"Page through a company\'s or person\'s non-list field values. List fields are not returned here — read those through the list entry.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Which entity to read field values from: companies or persons"},"entityId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of that company or person"},"ids":{"type":"json","required":false,"visibility":"user-or-llm","description":"Restrict to these field IDs. Mutually exclusive with Field Types"},"types":{"type":"json","required":false,"visibility":"user-or-llm","description":"Restrict to these field categories: enriched, global, relationship-intelligence. Mutually exclusive with Field IDs"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 20"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_list_entity_list_entries":{"id":"affinity_list_entity_list_entries","name":"Affinity List Entity List Entries","description":"Page through a company\'s or person\'s rows across every list, each carrying that list\'s field values and when the entity was added.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Which entity to look up the rows of: companies or persons"},"entityId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of that company or person"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_list_entity_lists":{"id":"affinity_list_entity_lists","name":"Affinity List Entity Lists","description":"List every list a company or person appears on that the caller can view.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Which entity to look up the lists of: companies or persons"},"entityId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of that company or person"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_list_entity_notes":{"id":"affinity_list_entity_notes","name":"Affinity List Entity Notes","description":"List the notes relevant to one company, person, or opportunity — directly attached notes plus notes reaching it through its people and meetings.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Which entity the notes hang off: companies, persons, or opportunities"},"entityId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of that company, person, or opportunity"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression, e.g. \\"createdAt>=2026-01-01\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"},"totalCount":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the total size of the collection. Costs an extra query"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_list_entity_relationships":{"id":"affinity_list_entity_relationships","name":"Affinity List Entity Relationships","description":"List who knows a company or person, scored 0.0 to 1.0 by how much the two actually interact. Strongest first by default.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Which entity to look up relationships for: companies or persons"},"entityId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of that company or person"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression. This endpoint filters on interactionScore only, e.g. \\"interactionScore>=0.5\\""},"orderBy":{"type":"json","required":false,"visibility":"user-or-llm","description":"Sort order: [\\"interactionScore\\"] for weakest first, [\\"-interactionScore\\"] for strongest first (the default)"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"},"totalCount":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the total size of the collection. Costs an extra query"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_list_field_dropdown_options":{"id":"affinity_list_field_dropdown_options","name":"Affinity List Field Dropdown Options","description":"List the selectable options on a dropdown or ranked-dropdown company or person field. Writing such a field needs the option ID, not its text.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Which field family the field belongs to: companies or persons"},"fieldId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The dropdown or ranked-dropdown field ID"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_list_field_metadata":{"id":"affinity_list_field_metadata","name":"Affinity List Field Metadata","description":"List the non-list company or person fields, with the value type, filter operators, and sort support of each. Start here to find the Field IDs the read and write tools take.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Which fields to describe: companies or persons"},"includes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Extra properties to return: [\\"filterability\\",\\"sortability\\"]. Both are omitted unless requested here"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression. This endpoint filters on name only, e.g. \\"name=~Status\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_list_field_value_changes":{"id":"affinity_list_field_value_changes","name":"Affinity List Field Value Changes","description":"Page through field value changes across the whole workspace. Built for delta sync: follow nextCursor to the end of a run, then resume from the last cursor next time.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression over field.id, listEntry.id, changer.id, changedAt, or actionType. Resume a sync with e.g. \\"changedAt>2026-06-01T12:00:00Z\\""},"orderBy":{"type":"json","required":false,"visibility":"user-or-llm","description":"Sort order: [\\"changedAt\\"] for oldest first (the default), [\\"-changedAt\\"] for newest first"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_list_investor_executive_connections":{"id":"affinity_list_investor_executive_connections","name":"Affinity List Investor Executive Connections","description":"Find warm paths into a company through investment history: which investors in your Affinity data backed a company the people you want to reach once led. Grouped by target, strongest first.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"filter":{"type":"string","required":true,"visibility":"user-or-llm","description":"Required scope. The only supported filter is target.currentCompany.id, e.g. \\"target.currentCompany.id=123\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of targets to return per page, 1-50. Defaults to 20"},"totalCount":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the total size of the collection. Costs an extra query"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_list_list_entries":{"id":"affinity_list_list_entries","name":"Affinity List List Entries","description":"Page through the rows of a list. Rows come back without field data unless Field IDs or Field Types asks for it.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"fieldIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field IDs to return values for, e.g. [\\"affinity-data-location\\"]. Mutually exclusive with Field Types"},"fieldTypes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field categories to return values for: enriched, global, list, or relationship-intelligence. Mutually exclusive with Field IDs"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_list_list_entry_field_value_changes":{"id":"affinity_list_list_entry_field_value_changes","name":"Affinity List List Entry Field Value Changes","description":"Page through the history of one list row — who changed which field, when, and to what.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"listEntryId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list entry ID"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression over field.id, changer.id, changedAt, or actionType, e.g. \\"field.id=field-1234\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_list_list_entry_fields":{"id":"affinity_list_list_entry_fields","name":"Affinity List List Entry Fields","description":"Page through every field value on one list row, including the list-specific columns. All fields are returned unless narrowed.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"listEntryId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list entry ID"},"ids":{"type":"json","required":false,"visibility":"user-or-llm","description":"Restrict to these field IDs. Mutually exclusive with Field Types"},"types":{"type":"json","required":false,"visibility":"user-or-llm","description":"Restrict to these field categories: enriched, global, list, relationship-intelligence. Mutually exclusive with Field IDs"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 20"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_list_list_field_dropdown_options":{"id":"affinity_list_list_field_dropdown_options","name":"Affinity List List Field Dropdown Options","description":"List the selectable options on a dropdown, ranked-dropdown, or status-dropdown field of a list.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"fieldId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The dropdown field ID on that list"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_list_list_fields":{"id":"affinity_list_list_fields","name":"Affinity List List Fields","description":"List the fields available on one list, including its list-specific columns. Use these Field IDs when reading or writing list entries.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"includes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Extra properties to return: [\\"filterability\\",\\"sortability\\"]. Both are omitted unless requested here"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression. This endpoint filters on name only, e.g. \\"name=~Stage\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_list_lists":{"id":"affinity_list_lists","name":"Affinity List Lists","description":"Page through the lists in the organization that the caller can view.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"term":{"type":"string","required":false,"visibility":"user-or-llm","description":"Case-insensitive substring match on the list name"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_list_meetings":{"id":"affinity_list_meetings","name":"Affinity List Meetings","description":"Page through past and upcoming meetings with their organizer and attendees.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression, e.g. \\"createdAt>=2026-01-01\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_list_merge_tasks":{"id":"affinity_list_merge_tasks","name":"Affinity List Merge Tasks","description":"Page through merge tasks, each summarizing how many of its merges are in progress, succeeded, or failed.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Which merge tasks to list: companies or persons"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression. This endpoint filters on status only, e.g. \\"status=in-progress\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_list_merges":{"id":"affinity_list_merges","name":"Affinity List Merges","description":"Page through the company or person merges the organization has run, with the status and the records involved in each.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Which merges to list: companies or persons"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression over status or taskId, e.g. \\"status=failed\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_list_note_attached_companies":{"id":"affinity_list_note_attached_companies","name":"Affinity List Note Attached Companies","description":"List the companies directly attached to one note.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"noteId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The note ID"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"},"totalCount":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the total size of the collection. Costs an extra query"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_list_note_attached_opportunities":{"id":"affinity_list_note_attached_opportunities","name":"Affinity List Note Attached Opportunities","description":"List the opportunities directly attached to one note.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"noteId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The note ID"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"},"totalCount":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the total size of the collection. Costs an extra query"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_list_note_attached_persons":{"id":"affinity_list_note_attached_persons","name":"Affinity List Note Attached Persons","description":"List the persons directly attached to one note.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"noteId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The note ID"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"},"totalCount":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the total size of the collection. Costs an extra query"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_list_note_replies":{"id":"affinity_list_note_replies","name":"Affinity List Note Replies","description":"Page through the replies on one note, including AI Notetaker replies.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"noteId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The note ID whose replies to read"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression, e.g. \\"createdAt>=2026-01-01\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"},"totalCount":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the total size of the collection. Costs an extra query"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_list_notes":{"id":"affinity_list_notes","name":"Affinity List Notes","description":"Page through every note the caller can see. Replies are excluded.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"includes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Extra properties to return, e.g. [\\"repliesCount\\",\\"personsPreview\\",\\"companiesPreview\\",\\"opportunitiesPreview\\"]. Those four fields are omitted unless requested here"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression, e.g. \\"createdAt>=2026-01-01\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"},"totalCount":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the total size of the collection. Costs an extra query"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_list_opportunities":{"id":"affinity_list_opportunities","name":"Affinity List Opportunities","description":"Page through opportunities. Field data lives on the list entry, not here — read it through the list or saved view the opportunity belongs to.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"ids":{"type":"json","required":false,"visibility":"user-or-llm","description":"Restrict the page to these opportunity IDs, e.g. [1, 2, 3]"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_list_persons":{"id":"affinity_list_persons","name":"Affinity List Persons","description":"Page through persons. Persons come back without field data unless Field IDs or Field Types asks for it.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"ids":{"type":"json","required":false,"visibility":"user-or-llm","description":"Restrict the page to these person IDs, e.g. [1, 2, 3]"},"fieldIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field IDs to return values for, e.g. [\\"affinity-data-location\\"]. Mutually exclusive with Field Types"},"fieldTypes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field categories to return values for: enriched, global, or relationship-intelligence. Mutually exclusive with Field IDs"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_list_reminders":{"id":"affinity_list_reminders","name":"Affinity List Reminders","description":"Page through the reminders the caller can see. Filter by status to surface what is overdue.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression, e.g. \\"createdAt>=2026-01-01\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"},"totalCount":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the total size of the collection. Costs an extra query"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_list_saved_view_entries":{"id":"affinity_list_saved_view_entries","name":"Affinity List Saved View Entries","description":"Page through the rows of a saved view. The view\'s own filters and columns decide which rows and which field data come back.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"viewId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The saved view ID"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_list_saved_views":{"id":"affinity_list_saved_views","name":"Affinity List Saved Views","description":"List the saved views on a list that the caller can view.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_list_transcript_fragments":{"id":"affinity_list_transcript_fragments","name":"Affinity List Transcript Fragments","description":"Page through everything said in a meeting, segment by segment with the speaker.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"transcriptId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The transcript ID"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"},"totalCount":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the total size of the collection. Costs an extra query"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_list_transcripts":{"id":"affinity_list_transcripts","name":"Affinity List Transcripts","description":"Page through meeting transcript metadata. Read one transcript to get what was actually said.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression, e.g. \\"createdAt>=2026-01-01\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"},"totalCount":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the total size of the collection. Costs an extra query"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_list_users":{"id":"affinity_list_users","name":"Affinity List Users","description":"Page through the internal users in the organization. Email addresses and roles are returned only to callers with the \\"Manage Users\\" permission.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"term":{"type":"string","required":false,"visibility":"user-or-llm","description":"Case-insensitive match across first name, last name, and primary email"},"filter":{"type":"string","required":false,"visibility":"user-or-llm","description":"Affinity Filtering Language expression over id or status, e.g. \\"status=active\\""},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_search_companies":{"id":"affinity_search_companies","name":"Affinity Search Companies","description":"Search companies by filters, sorts, and a free-text term. Requires the \\"Export All Organizations directory\\" permission.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"filters":{"type":"json","required":false,"visibility":"user-or-llm","description":"Filter group as {operator: \\"and\\"|\\"or\\", filters: [...]}, at most 50 leaves. Each leaf is {valueType, fieldId, operator, value}, and a leaf may itself be a nested group"},"searchTerm":{"type":"string","required":false,"visibility":"user-or-llm","description":"Free-text term matched against the searchable fields. At least 3 characters"},"searchFieldIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field IDs the search term is matched against. Defaults to the searchable fields"},"sorts":{"type":"json","required":false,"visibility":"user-or-llm","description":"Sort order as [{fieldId, direction: \\"asc\\"|\\"desc\\", attributeId?}], up to 5, applied in order"},"fieldIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field IDs to return values for, e.g. [\\"affinity-data-location\\"]. Mutually exclusive with Field Types"},"fieldTypes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field categories to return values for: enriched, global, or relationship-intelligence. Mutually exclusive with Field IDs"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"},"totalCount":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the total size of the collection. Costs an extra query"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_search_files":{"id":"affinity_search_files","name":"Affinity Search Files","description":"Search files by keyword, ordered by relevance. Narrow to specific files or to one company, or leave both unset to search the whole account.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"prompt":{"type":"string","required":true,"visibility":"user-or-llm","description":"What to search for. Between 3 and 500 characters"},"ids":{"type":"json","required":false,"visibility":"user-or-llm","description":"Restrict the search to these file IDs. Cannot be combined with Company ID"},"companyId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Restrict the search to one company\'s files. Cannot be combined with file IDs"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of files to return, 1-100. Defaults to 20"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_search_list_entries":{"id":"affinity_search_list_entries","name":"Affinity Search List Entries","description":"Search the rows of one list by filters, sorts, and a free-text term. Requires the \\"Export data from Lists\\" permission.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID to search"},"filters":{"type":"json","required":false,"visibility":"user-or-llm","description":"Filter group as {operator: \\"and\\"|\\"or\\", filters: [...]}, at most 50 leaves. Each leaf is {valueType, fieldId, operator, value}, and a leaf may itself be a nested group"},"searchTerm":{"type":"string","required":false,"visibility":"user-or-llm","description":"Free-text term matched against the searchable fields. At least 3 characters"},"searchFieldIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field IDs the search term is matched against. Defaults to the searchable fields"},"sorts":{"type":"json","required":false,"visibility":"user-or-llm","description":"Sort order as [{fieldId, direction: \\"asc\\"|\\"desc\\", attributeId?}], up to 5, applied in order"},"fieldIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field IDs to return values for, e.g. [\\"affinity-data-location\\"]. Mutually exclusive with Field Types"},"fieldTypes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field categories to return values for: enriched, global, list, or relationship-intelligence. Mutually exclusive with Field IDs"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"},"totalCount":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the total size of the collection. Costs an extra query"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_search_notes":{"id":"affinity_search_notes","name":"Affinity Search Notes","description":"Search notes by keyword, ordered by relevance. Narrow to specific notes or to one company, or leave both unset to search the whole account.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"prompt":{"type":"string","required":true,"visibility":"user-or-llm","description":"What to search for. Between 3 and 500 characters"},"ids":{"type":"json","required":false,"visibility":"user-or-llm","description":"Restrict the search to these note IDs. Cannot be combined with Company ID"},"companyId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Restrict the search to one company\'s notes. Cannot be combined with note IDs"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of notes to return, 1-100. Defaults to 20"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_search_persons":{"id":"affinity_search_persons","name":"Affinity Search Persons","description":"Search persons by filters, sorts, and a free-text term. Requires the \\"Export All People directory\\" permission.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"filters":{"type":"json","required":false,"visibility":"user-or-llm","description":"Filter group as {operator: \\"and\\"|\\"or\\", filters: [...]}, at most 50 leaves. Each leaf is {valueType, fieldId, operator, value}, and a leaf may itself be a nested group"},"searchTerm":{"type":"string","required":false,"visibility":"user-or-llm","description":"Free-text term matched against the searchable fields. At least 3 characters"},"searchFieldIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field IDs the search term is matched against. Defaults to the searchable fields"},"sorts":{"type":"json","required":false,"visibility":"user-or-llm","description":"Sort order as [{fieldId, direction: \\"asc\\"|\\"desc\\", attributeId?}], up to 5, applied in order"},"fieldIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field IDs to return values for, e.g. [\\"affinity-data-location\\"]. Mutually exclusive with Field Types"},"fieldTypes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Field categories to return values for: enriched, global, or relationship-intelligence. Mutually exclusive with Field IDs"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous page, returned as nextCursor or prevCursor"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to return per page, 1-100. Defaults to 100"},"totalCount":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the total size of the collection. Costs an extra query"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_semantic_search":{"id":"affinity_semantic_search","name":"Affinity Semantic Search","description":"Find companies from a description in plain language — industry, technology, stage, or business model. Currently searches companies only.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"prompt":{"type":"string","required":true,"visibility":"user-or-llm","description":"What to look for, in plain language, e.g. \\"climate tech companies in our pipeline\\". Up to 500 characters"},"listIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Restrict the search to companies on these lists, e.g. [1, 2]"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of companies to return, 1-100. Defaults to 100"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_update_entity_field_value":{"id":"affinity_update_entity_field_value","name":"Affinity Update Entity Field Value","description":"Write one non-list field value on a company or person. The value type must match how the field is defined.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"entityType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Which entity to write the field on: companies or persons"},"entityId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of that company or person"},"fieldId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The field ID to write"},"value":{"type":"json","required":true,"visibility":"user-or-llm","description":"The new value as {type, data}, where type matches the field\'s value type. Examples: {\\"type\\":\\"text\\",\\"data\\":\\"Series B\\"}, {\\"type\\":\\"number\\",\\"data\\":42}, {\\"type\\":\\"dropdown\\",\\"data\\":{\\"dropdownOptionId\\":7}}, {\\"type\\":\\"person\\",\\"data\\":{\\"id\\":123}}, {\\"type\\":\\"person-multi\\",\\"data\\":[{\\"id\\":123}]}. Pass data as null to clear the field"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_update_list_entry_field":{"id":"affinity_update_list_entry_field","name":"Affinity Update List Entry Field","description":"Write one field value on a list row. Requires the \\"Export data from Lists\\" permission.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"listEntryId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list entry ID"},"fieldId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The field ID to write"},"value":{"type":"json","required":true,"visibility":"user-or-llm","description":"The new value as {type, data}, where type matches the field\'s value type. Examples: {\\"type\\":\\"text\\",\\"data\\":\\"Series B\\"}, {\\"type\\":\\"number\\",\\"data\\":42}, {\\"type\\":\\"dropdown\\",\\"data\\":{\\"dropdownOptionId\\":7}}, {\\"type\\":\\"person\\",\\"data\\":{\\"id\\":123}}, {\\"type\\":\\"person-multi\\",\\"data\\":[{\\"id\\":123}]}. Pass data as null to clear the field"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_update_list_field_dropdown_option":{"id":"affinity_update_list_field_dropdown_option","name":"Affinity Update List Field Dropdown Option","description":"Change a dropdown option on a list field. Every field is optional — supply only what should change, and only fields the option\'s kind actually has.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"listId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The list ID"},"fieldId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The dropdown field ID on that list"},"dropdownOptionId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The dropdown option ID to update"},"text":{"type":"string","required":false,"visibility":"user-or-llm","description":"Replacement option label. Supply at least one field to change"},"rank":{"type":"number","required":false,"visibility":"user-or-llm","description":"Sort order. Required on a ranked-dropdown or status-dropdown option"},"color":{"type":"string","required":false,"visibility":"user-or-llm","description":"Option color: white, gray, blue, green, purple, orange, or red. Required on a ranked-dropdown or status-dropdown option"},"statusCategory":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pipeline meaning of the option: open, won, lost, or on-hold. Status-dropdown options only"},"winRate":{"type":"number","required":false,"visibility":"user-or-llm","description":"Expected win rate of the status. Status-dropdown options only"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"affinity_update_note":{"id":"affinity_update_note","name":"Affinity Update Note","description":"Rewrite a note\'s body or replace which records it is attached to. Each list of IDs replaces that association wholesale, an empty list clears it, and omitting one leaves it untouched. A note\'s type cannot be changed.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Affinity API key, sent as a bearer token"},"noteId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The note ID to update"},"html":{"type":"string","required":false,"visibility":"user-or-llm","description":"Replacement note body as HTML"},"companyIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Replacement set of attached companies, e.g. [1, 2]. Send [] to detach every company; omit to leave them unchanged"},"personIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Replacement set of attached persons, e.g. [1, 2]. Send [] to detach every person; omit to leave them unchanged"},"opportunityIds":{"type":"json","required":false,"visibility":"user-or-llm","description":"Replacement set of attached opportunities, e.g. [1, 2]. Send [] to detach every opportunity; omit to leave them unchanged"}},"oauth":{"required":false,"provider":"affinity","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentmail_create_draft":{"id":"agentmail_create_draft","name":"Create Draft","description":"Create a new email draft in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox to create the draft in"},"to":{"type":"string","required":false,"visibility":"user-or-llm","description":"Recipient email addresses (comma-separated)"},"subject":{"type":"string","required":false,"visibility":"user-or-llm","description":"Draft subject line"},"text":{"type":"string","required":false,"visibility":"user-or-llm","description":"Plain text draft body"},"html":{"type":"string","required":false,"visibility":"user-or-llm","description":"HTML draft body"},"cc":{"type":"string","required":false,"visibility":"user-or-llm","description":"CC recipient email addresses (comma-separated)"},"bcc":{"type":"string","required":false,"visibility":"user-or-llm","description":"BCC recipient email addresses (comma-separated)"},"inReplyTo":{"type":"string","required":false,"visibility":"user-or-llm","description":"ID of message being replied to"},"sendAt":{"type":"string","required":false,"visibility":"user-or-llm","description":"ISO 8601 timestamp to schedule sending"}},"oauth":{"required":false,"provider":"agentmail","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentmail_create_inbox":{"id":"agentmail_create_inbox","name":"Create Inbox","description":"Create a new email inbox with AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"username":{"type":"string","required":false,"visibility":"user-or-llm","description":"Username for the inbox email address"},"domain":{"type":"string","required":false,"visibility":"user-or-llm","description":"Domain for the inbox email address"},"displayName":{"type":"string","required":false,"visibility":"user-or-llm","description":"Display name for the inbox"}},"oauth":{"required":false,"provider":"agentmail","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentmail_delete_draft":{"id":"agentmail_delete_draft","name":"Delete Draft","description":"Delete an email draft in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox containing the draft"},"draftId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the draft to delete"}},"oauth":{"required":false,"provider":"agentmail","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentmail_delete_inbox":{"id":"agentmail_delete_inbox","name":"Delete Inbox","description":"Delete an email inbox in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox to delete"}},"oauth":{"required":false,"provider":"agentmail","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentmail_delete_thread":{"id":"agentmail_delete_thread","name":"Delete Thread","description":"Delete an email thread in AgentMail (moves to trash, or permanently deletes if already in trash)","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox containing the thread"},"threadId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the thread to delete"},"permanent":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Force permanent deletion instead of moving to trash"}},"oauth":{"required":false,"provider":"agentmail","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentmail_forward_message":{"id":"agentmail_forward_message","name":"Forward Message","description":"Forward an email message to new recipients in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox containing the message"},"messageId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the message to forward"},"to":{"type":"string","required":true,"visibility":"user-or-llm","description":"Recipient email addresses (comma-separated)"},"subject":{"type":"string","required":false,"visibility":"user-or-llm","description":"Override subject line"},"text":{"type":"string","required":false,"visibility":"user-or-llm","description":"Additional plain text to prepend"},"html":{"type":"string","required":false,"visibility":"user-or-llm","description":"Additional HTML to prepend"},"cc":{"type":"string","required":false,"visibility":"user-or-llm","description":"CC recipient email addresses (comma-separated)"},"bcc":{"type":"string","required":false,"visibility":"user-or-llm","description":"BCC recipient email addresses (comma-separated)"}},"oauth":{"required":false,"provider":"agentmail","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentmail_get_draft":{"id":"agentmail_get_draft","name":"Get Draft","description":"Get details of a specific email draft in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox the draft belongs to"},"draftId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the draft to retrieve"}},"oauth":{"required":false,"provider":"agentmail","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentmail_get_inbox":{"id":"agentmail_get_inbox","name":"Get Inbox","description":"Get details of a specific email inbox in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox to retrieve"}},"oauth":{"required":false,"provider":"agentmail","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentmail_get_message":{"id":"agentmail_get_message","name":"Get Message","description":"Get details of a specific email message in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox containing the message"},"messageId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the message to retrieve"}},"oauth":{"required":false,"provider":"agentmail","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentmail_get_thread":{"id":"agentmail_get_thread","name":"Get Thread","description":"Get details of a specific email thread including messages in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox containing the thread"},"threadId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the thread to retrieve"}},"oauth":{"required":false,"provider":"agentmail","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentmail_list_drafts":{"id":"agentmail_list_drafts","name":"List Drafts","description":"List email drafts in an inbox in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox to list drafts from"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of drafts to return"},"pageToken":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination token for next page of results"}},"oauth":{"required":false,"provider":"agentmail","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentmail_list_inboxes":{"id":"agentmail_list_inboxes","name":"List Inboxes","description":"List all email inboxes in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of inboxes to return"},"pageToken":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination token for next page of results"}},"oauth":{"required":false,"provider":"agentmail","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentmail_list_messages":{"id":"agentmail_list_messages","name":"List Messages","description":"List messages in an inbox in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox to list messages from"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of messages to return"},"pageToken":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination token for next page of results"}},"oauth":{"required":false,"provider":"agentmail","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentmail_list_threads":{"id":"agentmail_list_threads","name":"List Threads","description":"List email threads in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox to list threads from"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of threads to return"},"pageToken":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination token for next page of results"},"labels":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated labels to filter threads by"},"before":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter threads before this ISO 8601 timestamp"},"after":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter threads after this ISO 8601 timestamp"}},"oauth":{"required":false,"provider":"agentmail","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentmail_reply_message":{"id":"agentmail_reply_message","name":"Reply to Message","description":"Reply to an existing email message in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox to reply from"},"messageId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the message to reply to"},"text":{"type":"string","required":false,"visibility":"user-or-llm","description":"Plain text reply body"},"html":{"type":"string","required":false,"visibility":"user-or-llm","description":"HTML reply body"},"to":{"type":"string","required":false,"visibility":"user-or-llm","description":"Override recipient email addresses (comma-separated)"},"cc":{"type":"string","required":false,"visibility":"user-or-llm","description":"CC email addresses (comma-separated)"},"bcc":{"type":"string","required":false,"visibility":"user-or-llm","description":"BCC email addresses (comma-separated)"},"replyAll":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Reply to all recipients of the original message"}},"oauth":{"required":false,"provider":"agentmail","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentmail_send_draft":{"id":"agentmail_send_draft","name":"Send Draft","description":"Send an existing email draft in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox containing the draft"},"draftId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the draft to send"}},"oauth":{"required":false,"provider":"agentmail","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentmail_send_message":{"id":"agentmail_send_message","name":"Send Message","description":"Send an email message from an AgentMail inbox","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox to send from"},"to":{"type":"string","required":true,"visibility":"user-or-llm","description":"Recipient email address (comma-separated for multiple)"},"subject":{"type":"string","required":true,"visibility":"user-or-llm","description":"Email subject line"},"text":{"type":"string","required":false,"visibility":"user-or-llm","description":"Plain text email body"},"html":{"type":"string","required":false,"visibility":"user-or-llm","description":"HTML email body"},"cc":{"type":"string","required":false,"visibility":"user-or-llm","description":"CC recipient email addresses (comma-separated)"},"bcc":{"type":"string","required":false,"visibility":"user-or-llm","description":"BCC recipient email addresses (comma-separated)"}},"oauth":{"required":false,"provider":"agentmail","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentmail_update_draft":{"id":"agentmail_update_draft","name":"Update Draft","description":"Update an existing email draft in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox containing the draft"},"draftId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the draft to update"},"to":{"type":"string","required":false,"visibility":"user-or-llm","description":"Recipient email addresses (comma-separated)"},"subject":{"type":"string","required":false,"visibility":"user-or-llm","description":"Draft subject line"},"text":{"type":"string","required":false,"visibility":"user-or-llm","description":"Plain text draft body"},"html":{"type":"string","required":false,"visibility":"user-or-llm","description":"HTML draft body"},"cc":{"type":"string","required":false,"visibility":"user-or-llm","description":"CC recipient email addresses (comma-separated)"},"bcc":{"type":"string","required":false,"visibility":"user-or-llm","description":"BCC recipient email addresses (comma-separated)"},"sendAt":{"type":"string","required":false,"visibility":"user-or-llm","description":"ISO 8601 timestamp to schedule sending"}},"oauth":{"required":false,"provider":"agentmail","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentmail_update_inbox":{"id":"agentmail_update_inbox","name":"Update Inbox","description":"Update the display name of an email inbox in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox to update"},"displayName":{"type":"string","required":true,"visibility":"user-or-llm","description":"New display name for the inbox"}},"oauth":{"required":false,"provider":"agentmail","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentmail_update_message":{"id":"agentmail_update_message","name":"Update Message","description":"Add or remove labels on an email message in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox containing the message"},"messageId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the message to update"},"addLabels":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated labels to add to the message"},"removeLabels":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated labels to remove from the message"}},"oauth":{"required":false,"provider":"agentmail","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentmail_update_thread":{"id":"agentmail_update_thread","name":"Update Thread Labels","description":"Add or remove labels on an email thread in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox containing the thread"},"threadId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the thread to update"},"addLabels":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated labels to add to the thread"},"removeLabels":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated labels to remove from the thread"}},"oauth":{"required":false,"provider":"agentmail","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentphone_create_call":{"id":"agentphone_create_call","name":"Create Outbound Call","description":"Initiate an outbound voice call from an AgentPhone agent","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"agentId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Agent that will handle the call"},"toNumber":{"type":"string","required":true,"visibility":"user-or-llm","description":"Phone number to call in E.164 format (e.g. +14155551234)"},"fromNumberId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Phone number ID to use as caller ID. Must belong to the agent. If omitted, the agent\'s first assigned number is used."},"initialGreeting":{"type":"string","required":false,"visibility":"user-or-llm","description":"Optional greeting spoken when the recipient answers"},"voice":{"type":"string","required":false,"visibility":"user-or-llm","description":"Voice ID override for this call (defaults to the agent\'s configured voice)"},"systemPrompt":{"type":"string","required":false,"visibility":"user-or-llm","description":"When provided, uses a built-in LLM for the conversation instead of forwarding to your webhook"}},"oauth":{"required":false,"provider":"agentphone","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentphone_create_contact":{"id":"agentphone_create_contact","name":"Create Contact","description":"Create a new contact in AgentPhone","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"phoneNumber":{"type":"string","required":true,"visibility":"user-or-llm","description":"Phone number in E.164 format (e.g. +14155551234)"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Contact\'s full name"},"email":{"type":"string","required":false,"visibility":"user-or-llm","description":"Contact\'s email address"},"notes":{"type":"string","required":false,"visibility":"user-or-llm","description":"Freeform notes stored on the contact"}},"oauth":{"required":false,"provider":"agentphone","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentphone_create_number":{"id":"agentphone_create_number","name":"Create Phone Number","description":"Provision a new SMS- and voice-enabled phone number","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Two-letter country code (e.g. US, CA). Defaults to US."},"areaCode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Preferred area code (US/CA only, e.g. \\"415\\"). Best-effort — may be ignored if unavailable."},"agentId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Optionally attach the number to an agent immediately"}},"oauth":{"required":false,"provider":"agentphone","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentphone_delete_contact":{"id":"agentphone_delete_contact","name":"Delete Contact","description":"Delete a contact by ID","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"contactId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Contact ID"}},"oauth":{"required":false,"provider":"agentphone","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentphone_get_call":{"id":"agentphone_get_call","name":"Get Call","description":"Fetch a call and its full transcript","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"callId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the call to retrieve"}},"oauth":{"required":false,"provider":"agentphone","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentphone_get_call_transcript":{"id":"agentphone_get_call_transcript","name":"Get Call Transcript","description":"Get the full ordered transcript for a call","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"callId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the call to retrieve the transcript for"}},"oauth":{"required":false,"provider":"agentphone","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentphone_get_contact":{"id":"agentphone_get_contact","name":"Get Contact","description":"Fetch a single contact by ID","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"contactId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Contact ID"}},"oauth":{"required":false,"provider":"agentphone","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentphone_get_conversation":{"id":"agentphone_get_conversation","name":"Get Conversation","description":"Get a conversation along with its recent messages","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"conversationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Conversation ID"},"messageLimit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of recent messages to include (default 50, max 100)"}},"oauth":{"required":false,"provider":"agentphone","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentphone_get_conversation_messages":{"id":"agentphone_get_conversation_messages","name":"Get Conversation Messages","description":"Get paginated messages for a conversation","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"conversationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Conversation ID"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of messages to return (default 50, max 200)"},"before":{"type":"string","required":false,"visibility":"user-or-llm","description":"Return messages received before this ISO 8601 timestamp"},"after":{"type":"string","required":false,"visibility":"user-or-llm","description":"Return messages received after this ISO 8601 timestamp"}},"oauth":{"required":false,"provider":"agentphone","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentphone_get_number_messages":{"id":"agentphone_get_number_messages","name":"Get Phone Number Messages","description":"Fetch messages received on a specific phone number","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"numberId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the phone number"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of messages to return (default 50, max 200)"},"before":{"type":"string","required":false,"visibility":"user-or-llm","description":"Return messages received before this ISO 8601 timestamp"},"after":{"type":"string","required":false,"visibility":"user-or-llm","description":"Return messages received after this ISO 8601 timestamp"}},"oauth":{"required":false,"provider":"agentphone","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentphone_get_usage":{"id":"agentphone_get_usage","name":"Get Usage","description":"Retrieve current usage statistics for the AgentPhone account","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"}},"oauth":{"required":false,"provider":"agentphone","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentphone_get_usage_daily":{"id":"agentphone_get_usage_daily","name":"Get Daily Usage","description":"Get a daily breakdown of usage (messages, calls, webhooks) for the last N days","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"days":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of days to return (1-365, default 30)"}},"oauth":{"required":false,"provider":"agentphone","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentphone_get_usage_monthly":{"id":"agentphone_get_usage_monthly","name":"Get Monthly Usage","description":"Get monthly usage aggregation (messages, calls, webhooks) for the last N months","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"months":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of months to return (1-24, default 6)"}},"oauth":{"required":false,"provider":"agentphone","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentphone_list_calls":{"id":"agentphone_list_calls","name":"List Calls","description":"List voice calls for this AgentPhone account","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of results to return (default 20, max 100)"},"offset":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of results to skip (min 0)"},"status":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter by status (completed, in-progress, failed)"},"direction":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter by direction (inbound, outbound)"},"type":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter by call type (pstn, web)"},"search":{"type":"string","required":false,"visibility":"user-or-llm","description":"Search by phone number (matches fromNumber or toNumber)"}},"oauth":{"required":false,"provider":"agentphone","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentphone_list_contacts":{"id":"agentphone_list_contacts","name":"List Contacts","description":"List contacts for this AgentPhone account","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"search":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter by name or phone number (case-insensitive contains)"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of results to return (default 50, max 200)"},"offset":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of results to skip (min 0)"}},"oauth":{"required":false,"provider":"agentphone","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentphone_list_conversations":{"id":"agentphone_list_conversations","name":"List Conversations","description":"List conversations (message threads) for this AgentPhone account","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of results to return (default 20, max 100)"},"offset":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of results to skip (min 0)"}},"oauth":{"required":false,"provider":"agentphone","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentphone_list_numbers":{"id":"agentphone_list_numbers","name":"List Phone Numbers","description":"List all phone numbers provisioned for this AgentPhone account","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of results to return (default 20, max 100)"},"offset":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of results to skip (min 0)"}},"oauth":{"required":false,"provider":"agentphone","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentphone_react_to_message":{"id":"agentphone_react_to_message","name":"React to Message","description":"Send an iMessage tapback reaction to a message (iMessage only)","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"messageId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the message to react to"},"reaction":{"type":"string","required":true,"visibility":"user-or-llm","description":"Reaction type: love, like, dislike, laugh, emphasize, or question"}},"oauth":{"required":false,"provider":"agentphone","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentphone_release_number":{"id":"agentphone_release_number","name":"Release Phone Number","description":"Release (delete) a phone number. This action is irreversible.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"numberId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the phone number to release"}},"oauth":{"required":false,"provider":"agentphone","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentphone_send_message":{"id":"agentphone_send_message","name":"Send Message","description":"Send an outbound SMS or iMessage from an AgentPhone agent","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"agentId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Agent sending the message"},"toNumber":{"type":"string","required":true,"visibility":"user-or-llm","description":"Recipient phone number in E.164 format (e.g. +14155551234)"},"body":{"type":"string","required":true,"visibility":"user-or-llm","description":"Message text to send"},"mediaUrl":{"type":"string","required":false,"visibility":"user-or-llm","description":"Optional URL of an image, video, or file to attach"},"numberId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Phone number ID to send from. If omitted, the agent\'s first assigned number is used."}},"oauth":{"required":false,"provider":"agentphone","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentphone_update_contact":{"id":"agentphone_update_contact","name":"Update Contact","description":"Update a contact\'s fields","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"contactId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Contact ID"},"phoneNumber":{"type":"string","required":false,"visibility":"user-or-llm","description":"New phone number in E.164 format"},"name":{"type":"string","required":false,"visibility":"user-or-llm","description":"New contact name"},"email":{"type":"string","required":false,"visibility":"user-or-llm","description":"New email address"},"notes":{"type":"string","required":false,"visibility":"user-or-llm","description":"New freeform notes"}},"oauth":{"required":false,"provider":"agentphone","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agentphone_update_conversation":{"id":"agentphone_update_conversation","name":"Update Conversation","description":"Update conversation metadata (stored state). Pass null to clear existing metadata.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"conversationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Conversation ID"},"metadata":{"type":"json","required":false,"visibility":"user-or-llm","description":"Custom key-value metadata to store on the conversation. Pass null to clear existing metadata."}},"oauth":{"required":false,"provider":"agentphone","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"agiloft_async_status":{"id":"agiloft_async_status","name":"Agiloft Async Status","description":"Check whether an asynchronous Agiloft call, such as a run action button, has completed.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table the asynchronous call was made against"},"callbackId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Callback ID returned by the asynchronous call, e.g. from Run Action Button"}},"hostedApiKey":"none"},"agiloft_attach_file":{"id":"agiloft_attach_file","name":"Agiloft Attach File","description":"Attach a file to a field in an Agiloft record.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\")"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the record to attach the file to"},"fieldName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the attachment field"},"file":{"type":"file","required":true,"visibility":"user-or-llm","description":"File to attach"},"fileName":{"type":"string","required":false,"visibility":"user-or-llm","description":"Name to assign to the file (defaults to original file name)"},"overwrite":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Replace the contents of the field instead of adding another file to it"}},"hostedApiKey":"none"},"agiloft_attachment_info":{"id":"agiloft_attachment_info","name":"Agiloft Attachment Info","description":"Get information about file attachments on a record field.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\")"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the record to check attachments on"},"fieldName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the attachment field to inspect"}},"hostedApiKey":"none"},"agiloft_create_record":{"id":"agiloft_create_record","name":"Agiloft Create Record","description":"Create a new record in an Agiloft table.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\", \\"contacts.employees\\")"},"data":{"type":"string","required":true,"visibility":"user-or-llm","description":"Record field values as a JSON object (e.g., {\\"first_name\\": \\"John\\", \\"status\\": \\"Active\\"})"}},"hostedApiKey":"none"},"agiloft_delete_record":{"id":"agiloft_delete_record","name":"Agiloft Delete Record","description":"Delete a record from an Agiloft table.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\", \\"contacts.employees\\")"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the record to delete"},"substituteIds":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated IDs of records that adopt the dependants of the deleted record. Read only when the delete rule is REPLACE_WITH_ANOTHER."},"deleteRule":{"type":"string","required":false,"visibility":"user-or-llm","description":"How to treat records that depend on this one: ERROR_IF_DEPENDANTS (default — fails rather than cascading), APPLY_DELETE_WHERE_POSSIBLE, DELETE_WHERE_POSSIBLE_OTHERWISE_UNLINK, APPLY_UNLINK, UNLINK_WHERE_POSSIBLE_OTHERWISE_DELETE, or REPLACE_WITH_ANOTHER"}},"hostedApiKey":"none"},"agiloft_get_choice_line_id":{"id":"agiloft_get_choice_line_id","name":"Agiloft Get Choice Line ID","description":"Resolve the internal numeric ID of a choice-list value, for use in EWSelect WHERE clauses against choice fields.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"case\\", \\"contracts\\")"},"fieldName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Choice field name (e.g., \\"priority\\", \\"status\\")"},"value":{"type":"string","required":true,"visibility":"user-or-llm","description":"Choice display value to resolve (e.g., \\"High\\", \\"Active\\")"}},"hostedApiKey":"none"},"agiloft_list_tables":{"id":"agiloft_list_tables","name":"Agiloft List Tables","description":"List the tables and fields in an Agiloft knowledge base, to discover the logical names other operations need.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":false,"visibility":"user-or-llm","description":"Logical name of a single table to describe (e.g., \\"contacts\\"). Leave empty to list every table in the knowledge base."},"includeLinkedInfo":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the source table and column behind each linked field"},"skipColumnsInfo":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Return table names only, omitting field details, for a much smaller response"}},"hostedApiKey":"none"},"agiloft_lock_record":{"id":"agiloft_lock_record","name":"Agiloft Lock Record","description":"Lock, unlock, or check the lock status of an Agiloft record.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\")"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the record to lock, unlock, or check"},"lockAction":{"type":"string","required":true,"visibility":"user-or-llm","description":"Action to perform: \\"lock\\", \\"unlock\\", or \\"check\\""},"force":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Unlock only: release a lock held by another user."}},"hostedApiKey":"none"},"agiloft_nlp_search":{"id":"agiloft_nlp_search","name":"Agiloft Natural Language Search","description":"Search Agiloft records by describing what you want in plain language, such as \\"active NDAs submitted last month\\".","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"nlpQuery":{"type":"string","required":true,"visibility":"user-or-llm","description":"The request in plain language, e.g. \\"Show me open, high-priority contracts\\". Structured field filters are not accepted — use Search Records for those."},"fields":{"type":"string","required":true,"visibility":"user-or-llm","description":"Comma-separated field names to return, e.g. \\"id, contract_title1, company_name\\""},"page":{"type":"string","required":false,"visibility":"user-or-llm","description":"Page number, starting from 0"},"limit":{"type":"string","required":false,"visibility":"user-or-llm","description":"Records per page"}},"hostedApiKey":"none"},"agiloft_read_record":{"id":"agiloft_read_record","name":"Agiloft Read Record","description":"Read a record by ID from an Agiloft table.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\", \\"contacts.employees\\")"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the record to read"},"fields":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated list of field names to include in the response"}},"hostedApiKey":"none"},"agiloft_remove_attachment":{"id":"agiloft_remove_attachment","name":"Agiloft Remove Attachment","description":"Remove an attached file from a field in an Agiloft record.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\")"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the record containing the attachment"},"fieldName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the attachment field"},"position":{"type":"string","required":true,"visibility":"user-or-llm","description":"Position index of the file to remove (starting from 0)"}},"hostedApiKey":"none"},"agiloft_retrieve_attachment":{"id":"agiloft_retrieve_attachment","name":"Agiloft Retrieve Attachment","description":"Download an attached file from an Agiloft record field.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\")"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the record containing the attachment"},"fieldName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the attachment field"},"position":{"type":"string","required":true,"visibility":"user-or-llm","description":"Position index of the file in the field (starting from 0)"}},"hostedApiKey":"none"},"agiloft_run_action_button":{"id":"agiloft_run_action_button","name":"Agiloft Run Action Button","description":"Run an action button on an Agiloft record, such as an approval or send-for-signature step.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\", \\"case\\")"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the record to run the action button on"},"actionButtonField":{"type":"string","required":true,"visibility":"user-or-llm","description":"Logical name of the field holding the action button (e.g., \\"ab_field\\")"}},"hostedApiKey":"none"},"agiloft_saved_search":{"id":"agiloft_saved_search","name":"Agiloft Saved Search","description":"List the saved searches defined for an Agiloft table.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Logical table name to list saved searches for (e.g., \\"contract\\")"}},"hostedApiKey":"none"},"agiloft_search_records":{"id":"agiloft_search_records","name":"Agiloft Search Records","description":"Search for records in an Agiloft table using a query.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name to search in (e.g., \\"contracts\\", \\"contacts.employees\\")"},"query":{"type":"string","required":false,"visibility":"user-or-llm","description":"Ad hoc EWSearch query. Combine conditions with && (and) or || (or) and quote every value — e.g. \\"summary~=\'test\'&&priority=\'High\'\\". Required unless a saved search is given."},"search":{"type":"string","required":false,"visibility":"user-or-llm","description":"Label of a saved search defined on the table (e.g., \\"C: Status is Closed\\"). Can be combined with a query to narrow it further."},"fields":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated list of field names to include in the results"},"page":{"type":"string","required":false,"visibility":"user-or-llm","description":"Page number for paginated results (starting from 0)"},"limit":{"type":"string","required":false,"visibility":"user-or-llm","description":"Maximum number of records to return per page. Agiloft treats 0 as \\"all records\\", so leave it unset or use a positive value to keep result sizes bounded."}},"hostedApiKey":"none"},"agiloft_select_records":{"id":"agiloft_select_records","name":"Agiloft Select Records","description":"Select record IDs matching a SQL WHERE clause from an Agiloft table.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\", \\"contacts.employees\\")"},"where":{"type":"string","required":true,"visibility":"user-or-llm","description":"SQL WHERE clause using database column names (e.g., \\"summary like \'%new%\'\\" or \\"assigned_person=\'John Doe\'\\"). EWSelect has no page size and returns every matching ID, so append a database limit such as \\"limit 0,200\\" to bound the result."}},"hostedApiKey":"none"},"agiloft_update_record":{"id":"agiloft_update_record","name":"Agiloft Update Record","description":"Update an existing record in an Agiloft table.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\", \\"contacts.employees\\")"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the record to update"},"data":{"type":"string","required":true,"visibility":"user-or-llm","description":"Updated field values as a JSON object (e.g., {\\"status\\": \\"Active\\", \\"priority\\": \\"High\\"})"}},"hostedApiKey":"none"},"agiloft_upsert_record":{"id":"agiloft_upsert_record","name":"Agiloft Upsert Record","description":"Create an Agiloft record, or update it when a record already matches the given fields.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\", \\"contacts.employees\\")"},"match":{"type":"string","required":true,"visibility":"user-or-llm","description":"Field used to find an existing record (e.g., \\"ext_id\\"). Pick something that identifies a record uniquely — if more than one record matches, Agiloft writes nothing and returns a conflict."},"async":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Queue the write instead of waiting for it. Returns a callback ID instead of a record ID; pass that to Async Status to poll the result."},"data":{"type":"string","required":true,"visibility":"user-or-llm","description":"Field values as a JSON object. On create these populate the new record; on update only the supplied fields change."}},"hostedApiKey":"none"},"ahrefs_anchors":{"id":"ahrefs_anchors","name":"Ahrefs Anchors","description":"Get the anchor text distribution for a target domain or URL\'s backlinks, showing how many links and referring domains use each anchor text.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\" or \\"https://example.com/page\\""},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match)"},"history":{"type":"string","required":false,"visibility":"user-or-llm","description":"Historical scope: \\"live\\" (currently live), \\"all_time\\" (default, includes lost backlinks), or \\"since:YYYY-MM-DD\\" (backlinks found since a date)"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"oauth":{"required":false,"provider":"ahrefs","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"ahrefs_backlinks":{"id":"ahrefs_backlinks","name":"Ahrefs Backlinks","description":"Get a list of backlinks pointing to a target domain or URL. Returns details about each backlink including source URL, anchor text, and domain rating.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\" or \\"https://example.com/page\\""},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match). Example: \\"domain\\""},"history":{"type":"string","required":false,"visibility":"user-or-llm","description":"Historical scope: \\"live\\" (currently live backlinks), \\"all_time\\" (default, includes lost backlinks), or \\"since:YYYY-MM-DD\\" (backlinks found since a date)."},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"oauth":{"required":false,"provider":"ahrefs","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"ahrefs_backlinks_stats":{"id":"ahrefs_backlinks_stats","name":"Ahrefs Backlinks Stats","description":"Get backlink and referring domain totals for a target domain or URL, both currently live and across all time.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\" or \\"https://example.com/page\\""},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match). Example: \\"domain\\""},"date":{"type":"string","required":false,"visibility":"user-only","description":"Date to report metrics on, in YYYY-MM-DD format (defaults to today)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"oauth":{"required":false,"provider":"ahrefs","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"ahrefs_batch_analysis":{"id":"ahrefs_batch_analysis","name":"Ahrefs Batch Analysis","description":"Get bulk SEO metrics (Domain Rating, backlinks, referring domains, organic traffic, and more) for multiple domains or URLs in a single request. Useful for comparing many competitors at once.","version":"1.0.0","params":{"targets":{"type":"string","required":true,"visibility":"user-or-llm","description":"Comma-separated list of domains or URLs to analyze. Example: \\"example.com,competitor.com\\""},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode applied to every target: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match)"},"protocol":{"type":"string","required":false,"visibility":"user-or-llm","description":"Protocol applied to every target: \\"both\\" (default), \\"http\\", or \\"https\\""},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Country code for traffic data. Example: \\"us\\", \\"gb\\", \\"de\\" (default: \\"us\\")"},"volumeMode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Search volume calculation: \\"monthly\\" or \\"average\\" (default: \\"monthly\\")"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"oauth":{"required":false,"provider":"ahrefs","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"ahrefs_broken_backlinks":{"id":"ahrefs_broken_backlinks","name":"Ahrefs Broken Backlinks","description":"Get a list of broken backlinks pointing to a target domain or URL. Useful for identifying link reclamation opportunities.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\" or \\"https://example.com/page\\""},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match). Example: \\"domain\\""},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"oauth":{"required":false,"provider":"ahrefs","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"ahrefs_domain_rating":{"id":"ahrefs_domain_rating","name":"Ahrefs Domain Rating","description":"Get the Domain Rating (DR) and Ahrefs Rank for a target domain. Domain Rating shows the strength of a website\'s backlink profile on a scale from 0 to 100.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain to analyze (e.g., example.com)"},"date":{"type":"string","required":false,"visibility":"user-only","description":"Date for historical data in YYYY-MM-DD format (defaults to today)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"oauth":{"required":false,"provider":"ahrefs","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"ahrefs_domain_rating_history":{"id":"ahrefs_domain_rating_history","name":"Ahrefs Domain Rating History","description":"Get the historical Domain Rating (DR) trend for a target domain or URL over a date range, grouped daily, weekly, or monthly.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\""},"dateFrom":{"type":"string","required":true,"visibility":"user-only","description":"Start date of the historical period, in YYYY-MM-DD format"},"dateTo":{"type":"string","required":false,"visibility":"user-only","description":"End date of the historical period, in YYYY-MM-DD format (defaults to today)"},"historyGrouping":{"type":"string","required":false,"visibility":"user-or-llm","description":"Time interval for grouping data points: \\"daily\\", \\"weekly\\", or \\"monthly\\" (default: \\"monthly\\")"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"oauth":{"required":false,"provider":"ahrefs","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"ahrefs_keyword_overview":{"id":"ahrefs_keyword_overview","name":"Ahrefs Keyword Overview","description":"Get detailed metrics for a keyword including search volume, keyword difficulty, CPC, clicks, and traffic potential.","version":"1.0.0","params":{"keyword":{"type":"string","required":true,"visibility":"user-or-llm","description":"The keyword to analyze"},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Country code for keyword data. Example: \\"us\\", \\"gb\\", \\"de\\" (default: \\"us\\")"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"oauth":{"required":false,"provider":"ahrefs","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"ahrefs_keywords_history":{"id":"ahrefs_keywords_history","name":"Ahrefs Keywords History","description":"Get the historical organic keyword ranking distribution for a target domain or URL over a date range: how many keywords rank in each position bucket at each point in time.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\""},"dateFrom":{"type":"string","required":true,"visibility":"user-only","description":"Start date of the historical period, in YYYY-MM-DD format"},"dateTo":{"type":"string","required":false,"visibility":"user-only","description":"End date of the historical period, in YYYY-MM-DD format (defaults to today)"},"historyGrouping":{"type":"string","required":false,"visibility":"user-or-llm","description":"Time interval for grouping data points: \\"daily\\", \\"weekly\\", or \\"monthly\\" (default: \\"monthly\\")"},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Country code for search results. Example: \\"us\\", \\"gb\\", \\"de\\" (default: \\"us\\")"},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"oauth":{"required":false,"provider":"ahrefs","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"ahrefs_metrics":{"id":"ahrefs_metrics","name":"Ahrefs Metrics","description":"Get a one-call organic and paid search overview for a target domain or URL: organic traffic, organic keywords, paid traffic, paid keywords, and estimated traffic cost.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\""},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Country code for traffic data. Example: \\"us\\", \\"gb\\", \\"de\\""},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match). Example: \\"domain\\""},"date":{"type":"string","required":false,"visibility":"user-only","description":"Date to report metrics on, in YYYY-MM-DD format (defaults to today)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"oauth":{"required":false,"provider":"ahrefs","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"ahrefs_metrics_history":{"id":"ahrefs_metrics_history","name":"Ahrefs Metrics History","description":"Get the historical organic and paid traffic trend for a target domain or URL over a date range: organic traffic/cost and paid traffic/cost at each point in time.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\""},"dateFrom":{"type":"string","required":true,"visibility":"user-only","description":"Start date of the historical period, in YYYY-MM-DD format"},"dateTo":{"type":"string","required":false,"visibility":"user-only","description":"End date of the historical period, in YYYY-MM-DD format (defaults to today)"},"volumeMode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Search volume calculation: \\"monthly\\" or \\"average\\" (default: \\"monthly\\")"},"historyGrouping":{"type":"string","required":false,"visibility":"user-or-llm","description":"Time interval for grouping data points: \\"daily\\", \\"weekly\\", or \\"monthly\\" (default: \\"monthly\\")"},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Country code for traffic data. Example: \\"us\\", \\"gb\\", \\"de\\" (default: \\"us\\")"},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"oauth":{"required":false,"provider":"ahrefs","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"ahrefs_organic_competitors":{"id":"ahrefs_organic_competitors","name":"Ahrefs Organic Competitors","description":"Get domains that compete with a target domain or URL for the same organic keywords, ranked by keyword overlap.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\""},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Country code for search results. Example: \\"us\\", \\"gb\\", \\"de\\" (default: \\"us\\")"},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match). Example: \\"domain\\""},"date":{"type":"string","required":false,"visibility":"user-only","description":"Date to report metrics on, in YYYY-MM-DD format (defaults to today)"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"oauth":{"required":false,"provider":"ahrefs","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"ahrefs_organic_keywords":{"id":"ahrefs_organic_keywords","name":"Ahrefs Organic Keywords","description":"Get organic keywords that a target domain or URL ranks for in Google search results. Returns keyword details including search volume, ranking position, and estimated traffic.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\" or \\"https://example.com/page\\""},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Country code for search results. Example: \\"us\\", \\"gb\\", \\"de\\" (default: \\"us\\")"},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match). Example: \\"domain\\""},"date":{"type":"string","required":false,"visibility":"user-only","description":"Date to report metrics on, in YYYY-MM-DD format (defaults to today)"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"oauth":{"required":false,"provider":"ahrefs","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"ahrefs_paid_pages":{"id":"ahrefs_paid_pages","name":"Ahrefs Paid Pages","description":"Get a target domain\'s pages that receive paid search traffic, sorted by estimated paid traffic. Returns page URLs with their paid traffic, keyword counts, and estimated spend.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\""},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Country code for traffic data. Example: \\"us\\", \\"gb\\", \\"de\\" (default: \\"us\\")"},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match)"},"date":{"type":"string","required":false,"visibility":"user-only","description":"Date to report metrics on, in YYYY-MM-DD format (defaults to today)"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"oauth":{"required":false,"provider":"ahrefs","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"ahrefs_rank_tracker_competitors_overview":{"id":"ahrefs_rank_tracker_competitors_overview","name":"Ahrefs Rank Tracker Competitors Overview","description":"Get competitor rankings for the keywords tracked in an Ahrefs Rank Tracker project: each tracked keyword\'s volume and difficulty alongside every competitor\'s position, traffic, and traffic value. This endpoint is free and does not consume API units.","version":"1.0.0","params":{"projectId":{"type":"number","required":true,"visibility":"user-or-llm","description":"The Rank Tracker project ID (found in the project URL in Ahrefs)"},"date":{"type":"string","required":true,"visibility":"user-only","description":"Date to report rankings for, in YYYY-MM-DD format"},"device":{"type":"string","required":true,"visibility":"user-or-llm","description":"Rankings device type: \\"desktop\\" or \\"mobile\\""},"dateCompared":{"type":"string","required":false,"visibility":"user-only","description":"Comparison date in YYYY-MM-DD format, to compute position/traffic deltas"},"volumeMode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Search volume calculation: \\"monthly\\" or \\"average\\" (default: \\"monthly\\")"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"oauth":{"required":false,"provider":"ahrefs","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"ahrefs_rank_tracker_competitors_stats":{"id":"ahrefs_rank_tracker_competitors_stats","name":"Ahrefs Rank Tracker Competitors Stats","description":"Get aggregate competitor stats for an Ahrefs Rank Tracker project: each competitor\'s traffic, traffic value, average position, and share of voice across all tracked keywords. This endpoint is free and does not consume API units.","version":"1.0.0","params":{"projectId":{"type":"number","required":true,"visibility":"user-or-llm","description":"The Rank Tracker project ID (found in the project URL in Ahrefs)"},"date":{"type":"string","required":true,"visibility":"user-only","description":"Date to report metrics for, in YYYY-MM-DD format"},"device":{"type":"string","required":true,"visibility":"user-or-llm","description":"Rankings device type: \\"desktop\\" or \\"mobile\\""},"volumeMode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Search volume calculation: \\"monthly\\" or \\"average\\" (default: \\"monthly\\")"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"oauth":{"required":false,"provider":"ahrefs","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"ahrefs_rank_tracker_overview":{"id":"ahrefs_rank_tracker_overview","name":"Ahrefs Rank Tracker Overview","description":"Get ranking overview metrics for the keywords tracked in an Ahrefs Rank Tracker project: position, search volume, keyword difficulty, and estimated traffic. This endpoint is free and does not consume API units.","version":"1.0.0","params":{"projectId":{"type":"number","required":true,"visibility":"user-or-llm","description":"The Rank Tracker project ID (found in the project URL in Ahrefs)"},"date":{"type":"string","required":true,"visibility":"user-only","description":"Date to report rankings for, in YYYY-MM-DD format"},"device":{"type":"string","required":true,"visibility":"user-or-llm","description":"Rankings device type: \\"desktop\\" or \\"mobile\\""},"dateCompared":{"type":"string","required":false,"visibility":"user-only","description":"Comparison date in YYYY-MM-DD format, to compute position/traffic deltas"},"volumeMode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Search volume calculation: \\"monthly\\" or \\"average\\" (default: \\"monthly\\")"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"oauth":{"required":false,"provider":"ahrefs","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"ahrefs_rank_tracker_serp_overview":{"id":"ahrefs_rank_tracker_serp_overview","name":"Ahrefs Rank Tracker SERP Overview","description":"Get the full SERP (search engine results page) for a keyword tracked in an Ahrefs Rank Tracker project, including every ranking URL with its position, title, and authority metrics. This endpoint is free and does not consume API units.","version":"1.0.0","params":{"projectId":{"type":"number","required":true,"visibility":"user-or-llm","description":"The Rank Tracker project ID (found in the project URL in Ahrefs)"},"keyword":{"type":"string","required":true,"visibility":"user-or-llm","description":"The tracked keyword to retrieve SERP data for"},"country":{"type":"string","required":true,"visibility":"user-or-llm","description":"Country code for the tracked keyword. Example: \\"us\\", \\"gb\\", \\"de\\""},"device":{"type":"string","required":true,"visibility":"user-or-llm","description":"Rankings device type: \\"desktop\\" or \\"mobile\\""},"topPositions":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of top organic positions to return (defaults to all available)"},"date":{"type":"string","required":false,"visibility":"user-only","description":"Timestamp to return the last available SERP Overview at, in YYYY-MM-DDThh:mm:ss format"},"locationId":{"type":"number","required":false,"visibility":"user-or-llm","description":"Location ID of the tracked keyword, if tracked at a specific location"},"languageCode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Language code of the tracked keyword"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"oauth":{"required":false,"provider":"ahrefs","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"ahrefs_refdomains_history":{"id":"ahrefs_refdomains_history","name":"Ahrefs Referring Domains History","description":"Get the historical referring domains trend for a target domain or URL over a date range, grouped daily, weekly, or monthly.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\""},"dateFrom":{"type":"string","required":true,"visibility":"user-only","description":"Start date of the historical period, in YYYY-MM-DD format"},"dateTo":{"type":"string","required":false,"visibility":"user-only","description":"End date of the historical period, in YYYY-MM-DD format (defaults to today)"},"historyGrouping":{"type":"string","required":false,"visibility":"user-or-llm","description":"Time interval for grouping data points: \\"daily\\", \\"weekly\\", or \\"monthly\\" (default: \\"monthly\\")"},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"oauth":{"required":false,"provider":"ahrefs","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"ahrefs_referring_domains":{"id":"ahrefs_referring_domains","name":"Ahrefs Referring Domains","description":"Get a list of domains that link to a target domain or URL. Returns unique referring domains with their domain rating, backlink counts, and discovery dates.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\" or \\"https://example.com/page\\""},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match). Example: \\"domain\\""},"history":{"type":"string","required":false,"visibility":"user-or-llm","description":"Historical scope: \\"live\\" (currently live), \\"all_time\\" (default, includes lost domains), or \\"since:YYYY-MM-DD\\" (domains found since a date)."},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"oauth":{"required":false,"provider":"ahrefs","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"ahrefs_related_terms":{"id":"ahrefs_related_terms","name":"Ahrefs Related Terms","description":"Get keyword ideas related to a seed keyword: terms the same top-ranking pages also rank for (\\"also rank for\\") or also discuss (\\"also talk about\\"), with volume, difficulty, and CPC.","version":"1.0.0","params":{"keyword":{"type":"string","required":true,"visibility":"user-or-llm","description":"The seed keyword to find related terms for"},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Country code for keyword data. Example: \\"us\\", \\"gb\\", \\"de\\" (default: \\"us\\")"},"terms":{"type":"string","required":false,"visibility":"user-or-llm","description":"Type of related keywords to return: \\"also_rank_for\\", \\"also_talk_about\\", or \\"all\\" (default: \\"all\\")"},"viewFor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Whether to derive related terms from the top 10 or top 100 ranking pages (default: \\"top_10\\")"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"oauth":{"required":false,"provider":"ahrefs","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"ahrefs_site_audit_page_explorer":{"id":"ahrefs_site_audit_page_explorer","name":"Ahrefs Site Audit Page Explorer","description":"Get crawled pages from an Ahrefs Site Audit project with health and SEO metrics: HTTP status, title, link counts, backlinks, indexability, and traffic. Optionally filter to pages affected by a specific issue.","version":"1.0.0","params":{"projectId":{"type":"number","required":true,"visibility":"user-or-llm","description":"The Site Audit project ID (found in the project URL in Ahrefs)"},"date":{"type":"string","required":false,"visibility":"user-only","description":"Crawl date in YYYY-MM-DDThh:mm:ss format (defaults to the most recent crawl)"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"offset":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of results to skip, for pagination"},"issueId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Only return pages affected by this issue ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"oauth":{"required":false,"provider":"ahrefs","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"ahrefs_top_pages":{"id":"ahrefs_top_pages","name":"Ahrefs Top Pages","description":"Get the top pages of a target domain sorted by organic traffic. Returns page URLs with their traffic, keyword counts, and estimated traffic value.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain to analyze. Example: \\"example.com\\""},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Country code for traffic data. Example: \\"us\\", \\"gb\\", \\"de\\" (default: \\"us\\")"},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match). Example: \\"domain\\""},"date":{"type":"string","required":false,"visibility":"user-only","description":"Date to report metrics on, in YYYY-MM-DD format (defaults to today)"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}},"oauth":{"required":false,"provider":"ahrefs","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"airtable_create_records":{"id":"airtable_create_records","name":"Airtable Create Records","description":"Write new records to an Airtable table","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token"},"baseId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Airtable base ID (starts with \\"app\\", e.g., \\"appXXXXXXXXXXXXXX\\")"},"tableId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table ID (starts with \\"tbl\\") or table name"},"records":{"type":"json","required":true,"visibility":"user-or-llm","description":"Array of records to create, each with a `fields` object"},"typecast":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"When true, Airtable automatically converts string values to the field type"}},"oauth":{"required":true,"provider":"airtable"},"hostedApiKey":"none"},"airtable_delete_records":{"id":"airtable_delete_records","name":"Airtable Delete Records","description":"Delete one or more records from an Airtable table by ID","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token"},"baseId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Airtable base ID (starts with \\"app\\", e.g., \\"appXXXXXXXXXXXXXX\\")"},"tableId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table ID (starts with \\"tbl\\") or table name"},"recordIds":{"type":"json","required":true,"visibility":"user-or-llm","description":"Array of record IDs to delete (each starts with \\"rec\\", e.g., [\\"recXXXXXXXXXXXXXX\\"]). Pass a single-element array to delete one record."}},"oauth":{"required":true,"provider":"airtable"},"hostedApiKey":"none"},"airtable_get_base_schema":{"id":"airtable_get_base_schema","name":"Airtable Get Base Schema","description":"Get the schema of all tables, fields, and views in an Airtable base","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token"},"baseId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Airtable base ID (starts with \\"app\\", e.g., \\"appXXXXXXXXXXXXXX\\")"}},"oauth":{"required":true,"provider":"airtable"},"hostedApiKey":"none"},"airtable_get_record":{"id":"airtable_get_record","name":"Airtable Get Record","description":"Retrieve a single record from an Airtable table by its ID","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token"},"baseId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Airtable base ID (starts with \\"app\\", e.g., \\"appXXXXXXXXXXXXXX\\")"},"tableId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table ID (starts with \\"tbl\\") or table name"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Record ID to retrieve (starts with \\"rec\\", e.g., \\"recXXXXXXXXXXXXXX\\")"}},"oauth":{"required":true,"provider":"airtable"},"hostedApiKey":"none"},"airtable_list_bases":{"id":"airtable_list_bases","name":"Airtable List Bases","description":"List all bases the authenticated user has access to","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token"},"offset":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination offset for retrieving additional bases"}},"oauth":{"required":true,"provider":"airtable"},"hostedApiKey":"none"},"airtable_list_records":{"id":"airtable_list_records","name":"Airtable List Records","description":"Read records from an Airtable table","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token"},"baseId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Airtable base ID (starts with \\"app\\", e.g., \\"appXXXXXXXXXXXXXX\\")"},"tableId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table ID (starts with \\"tbl\\") or table name"},"maxRecords":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of records to return (default: all records)"},"filterFormula":{"type":"string","required":false,"visibility":"user-or-llm","description":"Formula to filter records (e.g., \\"({Field Name} = \'Value\')\\")"}},"oauth":{"required":true,"provider":"airtable"},"hostedApiKey":"none"},"airtable_list_tables":{"id":"airtable_list_tables","name":"Airtable List Tables","description":"List all tables and their schema in an Airtable base","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token"},"baseId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Airtable base ID (starts with \\"app\\", e.g., \\"appXXXXXXXXXXXXXX\\")"}},"oauth":{"required":true,"provider":"airtable"},"hostedApiKey":"none"},"airtable_update_multiple_records":{"id":"airtable_update_multiple_records","name":"Airtable Update Multiple Records","description":"Update multiple existing records in an Airtable table","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token"},"baseId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Airtable base ID (starts with \\"app\\", e.g., \\"appXXXXXXXXXXXXXX\\")"},"tableId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table ID (starts with \\"tbl\\") or table name"},"records":{"type":"json","required":true,"visibility":"user-or-llm","description":"Array of records to update, each with an `id` and a `fields` object"},"typecast":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"When true, Airtable automatically converts string values to the field type"}},"oauth":{"required":true,"provider":"airtable"},"hostedApiKey":"none"},"airtable_update_record":{"id":"airtable_update_record","name":"Airtable Update Record","description":"Update an existing record in an Airtable table by ID","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token"},"baseId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Airtable base ID (starts with \\"app\\", e.g., \\"appXXXXXXXXXXXXXX\\")"},"tableId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table ID (starts with \\"tbl\\") or table name"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Record ID to update (starts with \\"rec\\", e.g., \\"recXXXXXXXXXXXXXX\\")"},"fields":{"type":"json","required":true,"visibility":"user-or-llm","description":"An object containing the field names and their new values"},"typecast":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"When true, Airtable automatically converts string values to the field type"}},"oauth":{"required":true,"provider":"airtable"},"hostedApiKey":"none"},"airtable_upsert_records":{"id":"airtable_upsert_records","name":"Airtable Upsert Records","description":"Update existing records or create new ones in an Airtable table, matching on the specified merge fields","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token"},"baseId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Airtable base ID (starts with \\"app\\", e.g., \\"appXXXXXXXXXXXXXX\\")"},"tableId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table ID (starts with \\"tbl\\") or table name"},"records":{"type":"json","required":true,"visibility":"user-or-llm","description":"Array of records to upsert, each with a `fields` object"},"fieldsToMergeOn":{"type":"json","required":true,"visibility":"user-or-llm","description":"Array of field names used to match existing records (max 3). A record is updated when all merge fields match, otherwise it is created. Example: [\\"Name\\"]"},"typecast":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"When true, Airtable automatically converts string values to the field type"}},"oauth":{"required":true,"provider":"airtable"},"hostedApiKey":"none"},"algolia_add_record":{"id":"algolia_add_record","name":"Algolia Add Record","description":"Add or replace a record in an Algolia index","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Admin API Key"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index"},"objectID":{"type":"string","required":false,"visibility":"user-or-llm","description":"Object ID for the record (auto-generated if not provided)"},"record":{"type":"json","required":true,"visibility":"user-or-llm","description":"JSON object representing the record to add"}},"oauth":{"required":false,"provider":"algolia","credentialKind":"service-account","credentialParams":{"applicationId":"providerWorkspaceId","apiKey":"accessToken"},"credentialSuppliedParams":["applicationId","apiKey"]},"hostedApiKey":"none"},"algolia_batch_operations":{"id":"algolia_batch_operations","name":"Algolia Batch Operations","description":"Perform batch add, update, partial update, or delete operations on records in an Algolia index","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Admin API Key"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index"},"requests":{"type":"json","required":true,"visibility":"user-or-llm","description":"Array of batch operations. Each item has \\"action\\" (addObject, updateObject, partialUpdateObject, partialUpdateObjectNoCreate, deleteObject, delete, clear) and \\"body\\" (the record data; must include objectID for update/delete; use an empty object {} for the index-level delete/clear actions)"}},"oauth":{"required":false,"provider":"algolia","credentialKind":"service-account","credentialParams":{"applicationId":"providerWorkspaceId","apiKey":"accessToken"},"credentialSuppliedParams":["applicationId","apiKey"]},"hostedApiKey":"none"},"algolia_browse_records":{"id":"algolia_browse_records","name":"Algolia Browse Records","description":"Browse and iterate over all records in an Algolia index using cursor pagination","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia API Key (must have browse ACL)"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index to browse"},"query":{"type":"string","required":false,"visibility":"user-or-llm","description":"Search query to filter browsed records"},"filters":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter string to narrow down results"},"attributesToRetrieve":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated list of attributes to retrieve"},"hitsPerPage":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of hits per page (default: 1000, max: 1000)"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous browse response for pagination"},"aroundLatLng":{"type":"string","required":false,"visibility":"user-or-llm","description":"Coordinates for geo-search (e.g., \\"40.71,-74.01\\")"},"aroundRadius":{"type":"string","required":false,"visibility":"user-or-llm","description":"Maximum radius in meters for geo-search, or \\"all\\" for unlimited"},"insideBoundingBox":{"type":"json","required":false,"visibility":"user-or-llm","description":"Bounding box coordinates as [[lat1, lng1, lat2, lng2]] for geo-search"},"insidePolygon":{"type":"json","required":false,"visibility":"user-or-llm","description":"Polygon coordinates as [[lat1, lng1, lat2, lng2, lat3, lng3, ...]] for geo-search"}},"oauth":{"required":false,"provider":"algolia","credentialKind":"service-account","credentialParams":{"applicationId":"providerWorkspaceId","apiKey":"accessToken"},"credentialSuppliedParams":["applicationId","apiKey"]},"hostedApiKey":"none"},"algolia_clear_records":{"id":"algolia_clear_records","name":"Algolia Clear Records","description":"Clear all records from an Algolia index while keeping settings, synonyms, and rules","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Admin API Key (must have deleteIndex ACL)"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index to clear"}},"oauth":{"required":false,"provider":"algolia","credentialKind":"service-account","credentialParams":{"applicationId":"providerWorkspaceId","apiKey":"accessToken"},"credentialSuppliedParams":["applicationId","apiKey"]},"hostedApiKey":"none"},"algolia_copy_move_index":{"id":"algolia_copy_move_index","name":"Algolia Copy/Move Index","description":"Copy or move an Algolia index to a new destination","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Admin API Key"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the source index"},"operation":{"type":"string","required":true,"visibility":"user-or-llm","description":"Operation to perform: \\"copy\\" or \\"move\\""},"destination":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the destination index"},"scope":{"type":"json","required":false,"visibility":"user-or-llm","description":"Array of scopes to copy (only for \\"copy\\" operation): [\\"settings\\", \\"synonyms\\", \\"rules\\"]. Omit to copy everything including records."}},"oauth":{"required":false,"provider":"algolia","credentialKind":"service-account","credentialParams":{"applicationId":"providerWorkspaceId","apiKey":"accessToken"},"credentialSuppliedParams":["applicationId","apiKey"]},"hostedApiKey":"none"},"algolia_delete_by_filter":{"id":"algolia_delete_by_filter","name":"Algolia Delete By Filter","description":"Delete all records matching a filter from an Algolia index","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Admin API Key (must have deleteIndex ACL)"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index"},"filters":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter expression to match records for deletion (e.g., \\"category:outdated\\")"},"facetFilters":{"type":"json","required":false,"visibility":"user-or-llm","description":"Array of facet filters (e.g., [\\"brand:Acme\\"])"},"numericFilters":{"type":"json","required":false,"visibility":"user-or-llm","description":"Array of numeric filters (e.g., [\\"price > 100\\"])"},"tagFilters":{"type":"json","required":false,"visibility":"user-or-llm","description":"Array of tag filters using the _tags attribute (e.g., [\\"published\\"])"},"aroundLatLng":{"type":"string","required":false,"visibility":"user-or-llm","description":"Coordinates for geo-search filter (e.g., \\"40.71,-74.01\\")"},"aroundRadius":{"type":"string","required":false,"visibility":"user-or-llm","description":"Maximum radius in meters for geo-search, or \\"all\\" for unlimited"},"insideBoundingBox":{"type":"json","required":false,"visibility":"user-or-llm","description":"Bounding box coordinates as [[lat1, lng1, lat2, lng2]] for geo-search filter"},"insidePolygon":{"type":"json","required":false,"visibility":"user-or-llm","description":"Polygon coordinates as [[lat1, lng1, lat2, lng2, lat3, lng3, ...]] for geo-search filter"}},"oauth":{"required":false,"provider":"algolia","credentialKind":"service-account","credentialParams":{"applicationId":"providerWorkspaceId","apiKey":"accessToken"},"credentialSuppliedParams":["applicationId","apiKey"]},"hostedApiKey":"none"},"algolia_delete_index":{"id":"algolia_delete_index","name":"Algolia Delete Index","description":"Delete an entire Algolia index and all its records","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Admin API Key (must have deleteIndex ACL)"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index to delete"}},"oauth":{"required":false,"provider":"algolia","credentialKind":"service-account","credentialParams":{"applicationId":"providerWorkspaceId","apiKey":"accessToken"},"credentialSuppliedParams":["applicationId","apiKey"]},"hostedApiKey":"none"},"algolia_delete_record":{"id":"algolia_delete_record","name":"Algolia Delete Record","description":"Delete a record by objectID from an Algolia index","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Admin API Key"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index"},"objectID":{"type":"string","required":true,"visibility":"user-or-llm","description":"The objectID of the record to delete"}},"oauth":{"required":false,"provider":"algolia","credentialKind":"service-account","credentialParams":{"applicationId":"providerWorkspaceId","apiKey":"accessToken"},"credentialSuppliedParams":["applicationId","apiKey"]},"hostedApiKey":"none"},"algolia_get_record":{"id":"algolia_get_record","name":"Algolia Get Record","description":"Get a record by objectID from an Algolia index","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia API Key"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index"},"objectID":{"type":"string","required":true,"visibility":"user-or-llm","description":"The objectID of the record to retrieve"},"attributesToRetrieve":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated list of attributes to retrieve"}},"oauth":{"required":false,"provider":"algolia","credentialKind":"service-account","credentialParams":{"applicationId":"providerWorkspaceId","apiKey":"accessToken"},"credentialSuppliedParams":["applicationId","apiKey"]},"hostedApiKey":"none"},"algolia_get_records":{"id":"algolia_get_records","name":"Algolia Get Records","description":"Retrieve multiple records by objectID from one or more Algolia indices","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia API Key"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Default index name for all requests"},"requests":{"type":"json","required":true,"visibility":"user-or-llm","description":"Array of objects specifying records to retrieve. Each must have \\"objectID\\" and optionally \\"indexName\\" and \\"attributesToRetrieve\\"."}},"oauth":{"required":false,"provider":"algolia","credentialKind":"service-account","credentialParams":{"applicationId":"providerWorkspaceId","apiKey":"accessToken"},"credentialSuppliedParams":["applicationId","apiKey"]},"hostedApiKey":"none"},"algolia_get_settings":{"id":"algolia_get_settings","name":"Algolia Get Settings","description":"Retrieve the settings of an Algolia index","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia API Key"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index"}},"oauth":{"required":false,"provider":"algolia","credentialKind":"service-account","credentialParams":{"applicationId":"providerWorkspaceId","apiKey":"accessToken"},"credentialSuppliedParams":["applicationId","apiKey"]},"hostedApiKey":"none"},"algolia_get_task_status":{"id":"algolia_get_task_status","name":"Algolia Get Task Status","description":"Check whether an Algolia indexing task has finished publishing","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia API Key"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index the task ran against"},"taskID":{"type":"number","required":true,"visibility":"user-or-llm","description":"The taskID returned by a previous write operation"}},"oauth":{"required":false,"provider":"algolia","credentialKind":"service-account","credentialParams":{"applicationId":"providerWorkspaceId","apiKey":"accessToken"},"credentialSuppliedParams":["applicationId","apiKey"]},"hostedApiKey":"none"},"algolia_list_indices":{"id":"algolia_list_indices","name":"Algolia List Indices","description":"List all indices in an Algolia application","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia API Key"},"page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Page number for paginating indices (default: not paginated)"},"hitsPerPage":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of indices per page (default: 100)"}},"oauth":{"required":false,"provider":"algolia","credentialKind":"service-account","credentialParams":{"applicationId":"providerWorkspaceId","apiKey":"accessToken"},"credentialSuppliedParams":["applicationId","apiKey"]},"hostedApiKey":"none"},"algolia_partial_update_record":{"id":"algolia_partial_update_record","name":"Algolia Partial Update Record","description":"Partially update a record in an Algolia index without replacing it entirely","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Admin API Key"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index"},"objectID":{"type":"string","required":true,"visibility":"user-or-llm","description":"The objectID of the record to update"},"attributes":{"type":"json","required":true,"visibility":"user-or-llm","description":"JSON object with attributes to update. Supports built-in operations like {\\"stock\\": {\\"_operation\\": \\"Decrement\\", \\"value\\": 1}}"},"createIfNotExists":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Whether to create the record if it does not exist (default: true)"}},"oauth":{"required":false,"provider":"algolia","credentialKind":"service-account","credentialParams":{"applicationId":"providerWorkspaceId","apiKey":"accessToken"},"credentialSuppliedParams":["applicationId","apiKey"]},"hostedApiKey":"none"},"algolia_search":{"id":"algolia_search","name":"Algolia Search","description":"Search an Algolia index","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia API Key"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index to search"},"query":{"type":"string","required":true,"visibility":"user-or-llm","description":"Search query text"},"hitsPerPage":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of hits per page (default: 20)"},"page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Page number to retrieve (default: 0)"},"filters":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter string (e.g., \\"category:electronics AND price < 100\\")"},"attributesToRetrieve":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated list of attributes to retrieve"},"facets":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated list of facet attribute names to retrieve counts for (use \\"*\\" for all)"},"getRankingInfo":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Whether to include detailed ranking information in each hit"},"aroundLatLng":{"type":"string","required":false,"visibility":"user-or-llm","description":"Coordinates for geo-search (e.g., \\"40.71,-74.01\\")"},"aroundRadius":{"type":"string","required":false,"visibility":"user-or-llm","description":"Maximum radius in meters for geo-search, or \\"all\\" for unlimited"},"insideBoundingBox":{"type":"json","required":false,"visibility":"user-or-llm","description":"Bounding box coordinates as [[lat1, lng1, lat2, lng2]] for geo-search"},"insidePolygon":{"type":"json","required":false,"visibility":"user-or-llm","description":"Polygon coordinates as [[lat1, lng1, lat2, lng2, lat3, lng3, ...]] for geo-search"}},"oauth":{"required":false,"provider":"algolia","credentialKind":"service-account","credentialParams":{"applicationId":"providerWorkspaceId","apiKey":"accessToken"},"credentialSuppliedParams":["applicationId","apiKey"]},"hostedApiKey":"none"},"algolia_update_settings":{"id":"algolia_update_settings","name":"Algolia Update Settings","description":"Update the settings of an Algolia index","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Admin API Key (must have editSettings ACL)"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index"},"settings":{"type":"json","required":true,"visibility":"user-or-llm","description":"JSON object with settings to update (e.g., {\\"searchableAttributes\\": [\\"name\\", \\"description\\"], \\"customRanking\\": [\\"desc(popularity)\\"]})"},"forwardToReplicas":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Whether to apply changes to replica indices (default: false)"}},"oauth":{"required":false,"provider":"algolia","credentialKind":"service-account","credentialParams":{"applicationId":"providerWorkspaceId","apiKey":"accessToken"},"credentialSuppliedParams":["applicationId","apiKey"]},"hostedApiKey":"none"},"amplitude_event_segmentation":{"id":"amplitude_event_segmentation","name":"Amplitude Event Segmentation","description":"Query event analytics data with segmentation. Get event counts, uniques, averages, and more.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"secretKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude Secret Key"},"eventType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Event type name to analyze"},"start":{"type":"string","required":true,"visibility":"user-or-llm","description":"Start date in YYYYMMDD format"},"end":{"type":"string","required":true,"visibility":"user-or-llm","description":"End date in YYYYMMDD format"},"metric":{"type":"string","required":false,"visibility":"user-or-llm","description":"Metric type: uniques, totals, pct_dau, average, histogram, sums, value_avg, or formula (default: uniques)"},"interval":{"type":"string","required":false,"visibility":"user-or-llm","description":"Time interval: 1 (daily), 7 (weekly), or 30 (monthly)"},"groupBy":{"type":"string","required":false,"visibility":"user-or-llm","description":"Property name to group by (prefix custom user properties with \\"gp:\\")"},"groupBy2":{"type":"string","required":false,"visibility":"user-or-llm","description":"Second property name to group by (prefix custom user properties with \\"gp:\\")"},"limit":{"type":"string","required":false,"visibility":"user-or-llm","description":"Maximum number of group-by values (max 1000)"},"filters":{"type":"string","required":false,"visibility":"user-or-llm","description":"JSON array of filter objects applied to the event, e.g. [{\\"subprop_type\\":\\"event\\",\\"subprop_key\\":\\"city\\",\\"subprop_op\\":\\"is\\",\\"subprop_value\\":[\\"San Francisco\\"]}]"},"formula":{"type":"string","required":false,"visibility":"user-or-llm","description":"Required when metric is \\"formula\\", e.g. \\"UNIQUES(A)/UNIQUES(B)\\""},"segment":{"type":"string","required":false,"visibility":"user-or-llm","description":"JSON segment definition(s) applied to the query"},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}},"hostedApiKey":"none"},"amplitude_funnels":{"id":"amplitude_funnels","name":"Amplitude Funnels","description":"Analyze conversion rates and drop-off between a sequence of events.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"secretKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude Secret Key"},"events":{"type":"string","required":true,"visibility":"user-or-llm","description":"JSON array of event objects, one per funnel step in order, e.g. [{\\"event_type\\":\\"signup\\"},{\\"event_type\\":\\"purchase\\"}]"},"start":{"type":"string","required":true,"visibility":"user-or-llm","description":"Start date in YYYYMMDD format"},"end":{"type":"string","required":true,"visibility":"user-or-llm","description":"End date in YYYYMMDD format"},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Funnel ordering: \\"ordered\\", \\"unordered\\", or \\"sequential\\" (default: ordered)"},"userType":{"type":"string","required":false,"visibility":"user-or-llm","description":"User type: \\"new\\" or \\"active\\" (default: active)"},"interval":{"type":"string","required":false,"visibility":"user-or-llm","description":"Time interval: -300000 (real-time), -3600000 (hourly), 1 (daily), 7 (weekly), or 30 (monthly)"},"conversionWindowSeconds":{"type":"string","required":false,"visibility":"user-or-llm","description":"Conversion window in seconds (default: 2592000, i.e. 30 days)"},"groupBy":{"type":"string","required":false,"visibility":"user-or-llm","description":"Property to group by (limit: one; prefix custom properties with \\"gp:\\")"},"limit":{"type":"string","required":false,"visibility":"user-or-llm","description":"Maximum number of group-by values (default: 100, max: 1000)"},"segment":{"type":"string","required":false,"visibility":"user-or-llm","description":"JSON segment definition(s) applied to the query"},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}},"hostedApiKey":"none"},"amplitude_get_active_users":{"id":"amplitude_get_active_users","name":"Amplitude Get Active Users","description":"Get active or new user counts over a date range from the Dashboard REST API.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"secretKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude Secret Key"},"start":{"type":"string","required":true,"visibility":"user-or-llm","description":"Start date in YYYYMMDD format"},"end":{"type":"string","required":true,"visibility":"user-or-llm","description":"End date in YYYYMMDD format"},"metric":{"type":"string","required":false,"visibility":"user-or-llm","description":"Metric type: \\"active\\" or \\"new\\" (default: active)"},"interval":{"type":"string","required":false,"visibility":"user-or-llm","description":"Time interval: 1 (daily), 7 (weekly), or 30 (monthly)"},"groupBy":{"type":"string","required":false,"visibility":"user-or-llm","description":"Property name to group by"},"segment":{"type":"string","required":false,"visibility":"user-or-llm","description":"JSON segment definition(s) applied to the query"},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}},"hostedApiKey":"none"},"amplitude_get_revenue":{"id":"amplitude_get_revenue","name":"Amplitude Get Revenue","description":"Get revenue LTV data including ARPU, ARPPU, total revenue, and paying user counts.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"secretKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude Secret Key"},"start":{"type":"string","required":true,"visibility":"user-or-llm","description":"Start date in YYYYMMDD format"},"end":{"type":"string","required":true,"visibility":"user-or-llm","description":"End date in YYYYMMDD format"},"metric":{"type":"string","required":false,"visibility":"user-or-llm","description":"Metric: 0 (ARPU), 1 (ARPPU), 2 (Total Revenue), 3 (Paying Users)"},"interval":{"type":"string","required":false,"visibility":"user-or-llm","description":"Time interval: 1 (daily), 7 (weekly), or 30 (monthly)"},"groupBy":{"type":"string","required":false,"visibility":"user-or-llm","description":"Property name to group by (limit: one)"},"segment":{"type":"string","required":false,"visibility":"user-or-llm","description":"JSON segment definition(s) applied to the query"},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}},"hostedApiKey":"none"},"amplitude_group_identify":{"id":"amplitude_group_identify","name":"Amplitude Group Identify","description":"Set group-level properties in Amplitude. Supports $set, $setOnce, $add, $append, $unset operations.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"groupType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Group classification (e.g., \\"company\\", \\"org_id\\")"},"groupValue":{"type":"string","required":true,"visibility":"user-or-llm","description":"Specific group identifier (e.g., \\"Acme Corp\\")"},"groupProperties":{"type":"string","required":true,"visibility":"user-or-llm","description":"JSON object of group properties. Use operations like $set, $setOnce, $add, $append, $unset."},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}},"hostedApiKey":"none"},"amplitude_identify_user":{"id":"amplitude_identify_user","name":"Amplitude Identify User","description":"Set user properties in Amplitude using the Identify API. Supports $set, $setOnce, $add, $append, $unset operations.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"userId":{"type":"string","required":false,"visibility":"user-or-llm","description":"User ID (required if no device_id)"},"deviceId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Device ID (required if no user_id)"},"userProperties":{"type":"string","required":true,"visibility":"user-or-llm","description":"JSON object of user properties. Use operations like $set, $setOnce, $add, $append, $unset."},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}},"hostedApiKey":"none"},"amplitude_list_events":{"id":"amplitude_list_events","name":"Amplitude List Events","description":"List all event types in the Amplitude project with their weekly totals and unique counts.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"secretKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude Secret Key"},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}},"hostedApiKey":"none"},"amplitude_realtime_active_users":{"id":"amplitude_realtime_active_users","name":"Amplitude Real-time Active Users","description":"Get real-time active user counts at 5-minute granularity for the last 2 days.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"secretKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude Secret Key"},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}},"hostedApiKey":"none"},"amplitude_retention":{"id":"amplitude_retention","name":"Amplitude Retention","description":"Measure how many users return to perform an action after a starting action.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"secretKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude Secret Key"},"startEvent":{"type":"string","required":true,"visibility":"user-or-llm","description":"JSON starting event object, e.g. {\\"event_type\\":\\"_new\\"} or {\\"event_type\\":\\"_active\\"}"},"returnEvent":{"type":"string","required":true,"visibility":"user-or-llm","description":"JSON returning event object, e.g. {\\"event_type\\":\\"_all\\"} or {\\"event_type\\":\\"_active\\"}"},"start":{"type":"string","required":true,"visibility":"user-or-llm","description":"Start date in YYYYMMDD format"},"end":{"type":"string","required":true,"visibility":"user-or-llm","description":"End date in YYYYMMDD format"},"retentionMode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Retention type: \\"bracket\\", \\"rolling\\", or \\"n-day\\" (default: n-day)"},"retentionBrackets":{"type":"string","required":false,"visibility":"user-or-llm","description":"Required when Retention Mode is \\"bracket\\". Day ranges, e.g. [[0,4]]"},"interval":{"type":"string","required":false,"visibility":"user-or-llm","description":"Time interval: 1 (daily), 7 (weekly), or 30 (monthly)"},"groupBy":{"type":"string","required":false,"visibility":"user-or-llm","description":"Property to group by (limit: one; prefix custom properties with \\"gp:\\")"},"segment":{"type":"string","required":false,"visibility":"user-or-llm","description":"JSON segment definition(s) applied to the query"},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}},"hostedApiKey":"none"},"amplitude_send_event":{"id":"amplitude_send_event","name":"Amplitude Send Event","description":"Track an event in Amplitude using the HTTP V2 API.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"userId":{"type":"string","required":false,"visibility":"user-or-llm","description":"User ID (required if no device_id)"},"deviceId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Device ID (required if no user_id)"},"eventType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the event (e.g., \\"page_view\\", \\"purchase\\")"},"eventProperties":{"type":"string","required":false,"visibility":"user-or-llm","description":"JSON object of custom event properties"},"userProperties":{"type":"string","required":false,"visibility":"user-or-llm","description":"JSON object of user properties to set (supports $set, $setOnce, $add, $append, $unset)"},"time":{"type":"string","required":false,"visibility":"user-or-llm","description":"Event timestamp in milliseconds since epoch"},"sessionId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Session start time in milliseconds since epoch"},"insertId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Unique ID for deduplication (within 7-day window)"},"appVersion":{"type":"string","required":false,"visibility":"user-or-llm","description":"Application version string"},"platform":{"type":"string","required":false,"visibility":"user-or-llm","description":"Platform (e.g., \\"Web\\", \\"iOS\\", \\"Android\\")"},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Two-letter country code"},"language":{"type":"string","required":false,"visibility":"user-or-llm","description":"Language code (e.g., \\"en\\")"},"ip":{"type":"string","required":false,"visibility":"user-or-llm","description":"IP address for geo-location"},"price":{"type":"string","required":false,"visibility":"user-or-llm","description":"Price of the item purchased"},"quantity":{"type":"string","required":false,"visibility":"user-or-llm","description":"Quantity of items purchased"},"revenue":{"type":"string","required":false,"visibility":"user-or-llm","description":"Revenue amount"},"productId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Product identifier"},"revenueType":{"type":"string","required":false,"visibility":"user-or-llm","description":"Revenue type (e.g., \\"purchase\\", \\"refund\\")"},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}},"hostedApiKey":"none"},"amplitude_user_activity":{"id":"amplitude_user_activity","name":"Amplitude User Activity","description":"Get the event stream for a specific user by their Amplitude ID.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"secretKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude Secret Key"},"amplitudeId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Amplitude internal user ID"},"offset":{"type":"string","required":false,"visibility":"user-or-llm","description":"Offset for pagination (default 0)"},"limit":{"type":"string","required":false,"visibility":"user-or-llm","description":"Maximum number of events to return (default 1000, max 1000)"},"direction":{"type":"string","required":false,"visibility":"user-or-llm","description":"Sort direction: \\"latest\\" or \\"earliest\\" (default: latest)"},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}},"hostedApiKey":"none"},"amplitude_user_profile":{"id":"amplitude_user_profile","name":"Amplitude User Profile","description":"Get a user profile including properties, cohort memberships, and computed properties. Not available for EU data-residency projects.","version":"1.0.0","params":{"secretKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude Secret Key"},"userId":{"type":"string","required":false,"visibility":"user-or-llm","description":"External user ID (required if no device_id)"},"deviceId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Device ID (required if no user_id)"},"getAmpProps":{"type":"string","required":false,"visibility":"user-or-llm","description":"Include Amplitude user properties (true/false, default: false)"},"getCohortIds":{"type":"string","required":false,"visibility":"user-or-llm","description":"Include cohort IDs the user belongs to (true/false, default: false)"},"getComputations":{"type":"string","required":false,"visibility":"user-or-llm","description":"Include computed user properties (true/false, default: false)"}},"hostedApiKey":"none"},"amplitude_user_search":{"id":"amplitude_user_search","name":"Amplitude User Search","description":"Search for a user by User ID, Device ID, or Amplitude ID using the Dashboard REST API.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"secretKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude Secret Key"},"user":{"type":"string","required":true,"visibility":"user-or-llm","description":"User ID, Device ID, or Amplitude ID to search for"},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}},"hostedApiKey":"none"},"apify_get_dataset_items":{"id":"apify_get_dataset_items","name":"APIFY Get Dataset Items","description":"Retrieve items stored in an APIFY dataset","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"APIFY API token from console.apify.com/account#/integrations"},"datasetId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Dataset ID to read items from. Example: \\"9RnD3Pql2vGZkc5H5\\""},"itemLimit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Max items to return (1-250000). Default: all items. Example: 500"},"offset":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to skip at the start. Default: 0"},"fields":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated list of fields to include. Example: \\"title,url,price\\""}},"oauth":{"required":false,"provider":"apify","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"apify_get_run":{"id":"apify_get_run","name":"APIFY Get Run","description":"Get the status and details of an APIFY actor run","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"APIFY API token from console.apify.com/account#/integrations"},"runId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Actor run ID to fetch. Example: \\"HG7ML7M8z78YcAPEB\\""}},"oauth":{"required":false,"provider":"apify","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"apify_run_actor_async":{"id":"apify_run_actor_async","name":"APIFY Run Actor (Async)","description":"Start an APIFY Actor run and poll it within the same workflow step until completion or the polling deadline","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"APIFY API token from console.apify.com/account#/integrations"},"actorId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Actor ID or username/actor-name. Examples: \\"apify/web-scraper\\", \\"janedoe/my-actor\\", \\"moJRLRc85AitArpNN\\""},"input":{"type":"string","required":false,"visibility":"user-or-llm","description":"Actor input as JSON string. Example: { \\"startUrls\\": [ { \\"url\\": \\"https://example.com\\" } ], \\"maxPages\\": 10 }"},"waitForFinish":{"type":"number","required":false,"visibility":"user-or-llm","description":"Initial wait time in seconds (0-60) before polling starts. Example: 30"},"itemLimit":{"type":"number","required":false,"default":100,"visibility":"user-or-llm","description":"Max dataset items to fetch (1-250000). Default: 100. Example: 500"},"memory":{"type":"number","required":false,"visibility":"user-or-llm","description":"Memory in megabytes allocated for the actor run (128-32768). Example: 1024 for 1GB, 2048 for 2GB"},"timeout":{"type":"number","required":false,"visibility":"user-or-llm","description":"Timeout in seconds for the actor run. Example: 300 for 5 minutes, 3600 for 1 hour"},"build":{"type":"string","required":false,"visibility":"user-or-llm","description":"Actor build to run. Examples: \\"latest\\", \\"beta\\", \\"1.2.3\\", \\"build-tag-name\\""}},"oauth":{"required":false,"provider":"apify","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"apify_run_actor_sync":{"id":"apify_run_actor_sync","name":"APIFY Run Actor (Sync)","description":"Run an APIFY actor synchronously and get results (max 5 minutes)","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"APIFY API token from console.apify.com/account#/integrations"},"actorId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Actor ID or username/actor-name. Examples: \\"apify/web-scraper\\", \\"janedoe/my-actor\\", \\"moJRLRc85AitArpNN\\""},"input":{"type":"string","required":false,"visibility":"user-or-llm","description":"Actor input as JSON string. Example: { \\"startUrls\\": [ { \\"url\\": \\"https://example.com\\" } ], \\"maxPages\\": 10 }"},"memory":{"type":"number","required":false,"visibility":"user-or-llm","description":"Memory in megabytes allocated for the actor run (128-32768). Example: 1024 for 1GB, 2048 for 2GB"},"timeout":{"type":"number","required":false,"visibility":"user-or-llm","description":"Timeout in seconds for the actor run. Example: 300 for 5 minutes, 3600 for 1 hour"},"build":{"type":"string","required":false,"visibility":"user-or-llm","description":"Actor build to run. Examples: \\"latest\\", \\"beta\\", \\"1.2.3\\", \\"build-tag-name\\""}},"oauth":{"required":false,"provider":"apify","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"apify_run_task":{"id":"apify_run_task","name":"APIFY Run Task","description":"Run a saved APIFY actor task synchronously and get dataset items (max 5 minutes)","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"APIFY API token from console.apify.com/account#/integrations"},"taskId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Task ID or username/task-name. Examples: \\"janedoe/my-task\\", \\"moJRLRc85AitArpNN\\""},"input":{"type":"string","required":false,"visibility":"user-or-llm","description":"JSON string that overrides the task\'s saved input. Example: { \\"startUrls\\": [ { \\"url\\": \\"https://example.com\\" } ] }"},"itemLimit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Max dataset items to return (1-250000). Example: 500"},"memory":{"type":"number","required":false,"visibility":"user-or-llm","description":"Memory in megabytes allocated for the run (128-32768). Example: 1024 for 1GB"},"timeout":{"type":"number","required":false,"visibility":"user-or-llm","description":"Timeout in seconds for the run. Example: 300 for 5 minutes"},"build":{"type":"string","required":false,"visibility":"user-or-llm","description":"Actor build to run. Examples: \\"latest\\", \\"beta\\", \\"1.2.3\\""}},"oauth":{"required":false,"provider":"apify","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"apollo_account_bulk_create":{"id":"apollo_account_bulk_create","name":"Apollo Bulk Create Accounts","description":"Create up to 100 accounts at once in your Apollo database. Set run_dedupe=true to deduplicate by domain, organization_id, and name. Master key required.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"accounts":{"type":"array","required":true,"visibility":"user-or-llm","description":"Array of accounts to create (max 100). Each account should include a name, and may optionally include domain, phone, phone_status_cd, raw_address, owner_id, linkedin_url, facebook_url, twitter_url, salesforce_id, and hubspot_id."},"append_label_names":{"type":"array","required":false,"visibility":"user-only","description":"Array of label names to add to ALL accounts in this request"},"run_dedupe":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"When true, performs aggressive deduplication by domain, organization_id, and name (defaults to false)"}},"oauth":{"required":false,"provider":"apollo","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"apollo_account_bulk_update":{"id":"apollo_account_bulk_update","name":"Apollo Bulk Update Accounts","description":"Update up to 1000 existing accounts at once in your Apollo database (higher limit than contacts!). Each account must include an id field. Master key required.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"account_ids":{"type":"array","required":false,"visibility":"user-or-llm","description":"Array of account IDs to update with the same values (max 1000). Use with name/owner_id for uniform updates. Use either this OR account_attributes."},"name":{"type":"string","required":false,"visibility":"user-or-llm","description":"When using account_ids, apply this name to all accounts"},"owner_id":{"type":"string","required":false,"visibility":"user-or-llm","description":"When using account_ids, apply this owner to all accounts"},"account_stage_id":{"type":"string","required":false,"visibility":"user-or-llm","description":"When using account_ids, apply this account stage to all accounts"},"account_attributes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Array of account objects with individual updates (each must include id). Example: [{\\"id\\": \\"acc1\\", \\"name\\": \\"Acme\\", \\"owner_id\\": \\"u1\\", \\"account_stage_id\\": \\"s1\\", \\"typed_custom_fields\\": {\\"field_id\\": \\"value\\"}}]"},"async":{"type":"boolean","required":false,"visibility":"user-only","description":"When true, processes the update asynchronously. Only supported when using account_ids; returns 422 if used with account_attributes."}},"oauth":{"required":false,"provider":"apollo","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"apollo_account_create":{"id":"apollo_account_create","name":"Apollo Create Account","description":"Create a new account (company) in your Apollo database","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Company name (e.g., \\"Acme Corporation\\")"},"domain":{"type":"string","required":false,"visibility":"user-or-llm","description":"Company domain without www. prefix (e.g., \\"acme.com\\")"},"phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Primary phone number for the account"},"owner_id":{"type":"string","required":false,"visibility":"user-only","description":"Apollo user ID of the account owner"},"account_stage_id":{"type":"string","required":false,"visibility":"user-only","description":"Apollo ID for the account stage to assign this account to"},"raw_address":{"type":"string","required":false,"visibility":"user-or-llm","description":"Corporate location (e.g., \\"San Francisco, CA, USA\\")"},"typed_custom_fields":{"type":"json","required":false,"visibility":"user-only","description":"Custom field values as { custom_field_id: value } map"}},"oauth":{"required":false,"provider":"apollo","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"apollo_account_search":{"id":"apollo_account_search","name":"Apollo Search Accounts","description":"Search your team\'s accounts in Apollo. Display limit: 50,000 records (100 records per page, 500 pages max). Use filters to narrow results. Master key required.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"q_organization_name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter accounts by organization name (partial-match search)"},"account_stage_ids":{"type":"array","required":false,"visibility":"user-only","description":"Filter by account stage IDs"},"account_label_ids":{"type":"array","required":false,"visibility":"user-only","description":"Filter by account label IDs"},"sort_by_field":{"type":"string","required":false,"visibility":"user-or-llm","description":"Sort field: \\"account_last_activity_date\\", \\"account_created_at\\", or \\"account_updated_at\\""},"sort_ascending":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Sort ascending when true. Defaults to descending."},"page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Page number for pagination (e.g., 1, 2, 3)"},"per_page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Results per page, max 100 (e.g., 25, 50, 100)"}},"oauth":{"required":false,"provider":"apollo","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"apollo_account_update":{"id":"apollo_account_update","name":"Apollo Update Account","description":"Update an existing account in your Apollo database","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"account_id":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the account to update (e.g., \\"acc_abc123\\")"},"name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Company name (e.g., \\"Acme Corporation\\")"},"domain":{"type":"string","required":false,"visibility":"user-or-llm","description":"Company domain (e.g., \\"acme.com\\")"},"phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Company phone number"},"owner_id":{"type":"string","required":false,"visibility":"user-only","description":"Apollo user ID of the account owner"},"account_stage_id":{"type":"string","required":false,"visibility":"user-only","description":"Apollo ID for the account stage to assign this account to"},"raw_address":{"type":"string","required":false,"visibility":"user-or-llm","description":"Corporate location (e.g., \\"San Francisco, CA, USA\\")"},"typed_custom_fields":{"type":"json","required":false,"visibility":"user-only","description":"Custom field values as { custom_field_id: value } map"}},"oauth":{"required":false,"provider":"apollo","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"apollo_contact_bulk_create":{"id":"apollo_contact_bulk_create","name":"Apollo Bulk Create Contacts","description":"Create up to 100 contacts at once in your Apollo database. Supports deduplication to prevent creating duplicate contacts. Master key required.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"contacts":{"type":"array","required":true,"visibility":"user-or-llm","description":"Array of contacts to create (max 100). Each contact may include first_name, last_name, email, title, organization_name, account_id, owner_id, contact_stage_id, linkedin_url, phone (single string) or phone_numbers (array of {raw_number, position}), contact_emails, typed_custom_fields, and CRM IDs (salesforce_contact_id, hubspot_id, team_id) for cross-system matching"},"append_label_names":{"type":"array","required":false,"visibility":"user-or-llm","description":"Label names to add to all contacts in this request (e.g., [\\"Hot Lead\\"])"},"run_dedupe":{"type":"boolean","required":false,"visibility":"user-only","description":"Enable deduplication to prevent creating duplicate contacts. When true, existing contacts are returned without modification"}},"oauth":{"required":false,"provider":"apollo","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"apollo_contact_bulk_update":{"id":"apollo_contact_bulk_update","name":"Apollo Bulk Update Contacts","description":"Update up to 100 existing contacts at once in your Apollo database. Each contact must include an id field. Master key required.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"contact_ids":{"type":"array","required":false,"visibility":"user-or-llm","description":"Array of contact IDs to update. Must be paired with an object-form contact_attributes specifying the fields to apply uniformly to all listed contacts."},"contact_attributes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Required. Either an array of per-contact updates (each with id) — used standalone — or a single object of attributes to apply to all contact_ids. Supported fields: owner_id, email, organization_name, title, first_name, last_name, account_id, present_raw_address, linkedin_url, typed_custom_fields"},"async":{"type":"boolean","required":false,"visibility":"user-only","description":"Force asynchronous processing. Automatically enabled for >100 contacts"}},"oauth":{"required":false,"provider":"apollo","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"apollo_contact_create":{"id":"apollo_contact_create","name":"Apollo Create Contact","description":"Create a new contact in your Apollo database","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"first_name":{"type":"string","required":true,"visibility":"user-or-llm","description":"First name of the contact"},"last_name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Last name of the contact"},"email":{"type":"string","required":false,"visibility":"user-or-llm","description":"Email address of the contact"},"title":{"type":"string","required":false,"visibility":"user-or-llm","description":"Job title (e.g., \\"VP of Sales\\", \\"Software Engineer\\")"},"account_id":{"type":"string","required":false,"visibility":"user-or-llm","description":"Apollo account ID to associate with (e.g., \\"acc_abc123\\")"},"owner_id":{"type":"string","required":false,"visibility":"user-only","description":"User ID of the contact owner (accepted by Apollo but not officially documented for POST /contacts)"},"organization_name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Name of the contact\'s employer (e.g., \\"Apollo\\")"},"website_url":{"type":"string","required":false,"visibility":"user-or-llm","description":"Corporate website URL (e.g., \\"https://www.apollo.io/\\")"},"label_names":{"type":"array","required":false,"visibility":"user-or-llm","description":"Lists/labels to add the contact to (e.g., [\\"Prospects\\"])"},"contact_stage_id":{"type":"string","required":false,"visibility":"user-or-llm","description":"Apollo ID for the contact stage"},"present_raw_address":{"type":"string","required":false,"visibility":"user-or-llm","description":"Personal location for the contact (e.g., \\"Atlanta, United States\\")"},"direct_phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Primary phone number"},"corporate_phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Work/office phone number"},"mobile_phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Mobile phone number"},"home_phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Home phone number"},"other_phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Alternative phone number"},"typed_custom_fields":{"type":"json","required":false,"visibility":"user-or-llm","description":"Custom field values keyed by custom field ID"},"run_dedupe":{"type":"boolean","required":false,"visibility":"user-only","description":"When true, Apollo deduplicates against existing contacts"}},"oauth":{"required":false,"provider":"apollo","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"apollo_contact_search":{"id":"apollo_contact_search","name":"Apollo Search Contacts","description":"Search your team\'s contacts in Apollo","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"q_keywords":{"type":"string","required":false,"visibility":"user-or-llm","description":"Keywords to search for"},"contact_stage_ids":{"type":"array","required":false,"visibility":"user-only","description":"Filter by contact stage IDs"},"contact_label_ids":{"type":"array","required":false,"visibility":"user-only","description":"Filter by Apollo label IDs (lists)"},"sort_by_field":{"type":"string","required":false,"visibility":"user-only","description":"Sort field: contact_last_activity_date, contact_email_last_opened_at, contact_email_last_clicked_at, contact_created_at, or contact_updated_at"},"sort_ascending":{"type":"boolean","required":false,"visibility":"user-only","description":"When true, sort ascending. Must be used together with sort_by_field"},"page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Page number for pagination (e.g., 1, 2, 3)"},"per_page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Results per page, max 100 (e.g., 25, 50, 100)"}},"oauth":{"required":false,"provider":"apollo","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"apollo_contact_update":{"id":"apollo_contact_update","name":"Apollo Update Contact","description":"Update an existing contact in your Apollo database","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"contact_id":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the contact to update (e.g., \\"con_abc123\\")"},"first_name":{"type":"string","required":false,"visibility":"user-or-llm","description":"First name of the contact"},"last_name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Last name of the contact"},"email":{"type":"string","required":false,"visibility":"user-or-llm","description":"Email address"},"title":{"type":"string","required":false,"visibility":"user-or-llm","description":"Job title (e.g., \\"VP of Sales\\", \\"Software Engineer\\")"},"account_id":{"type":"string","required":false,"visibility":"user-or-llm","description":"Apollo account ID (e.g., \\"acc_abc123\\")"},"owner_id":{"type":"string","required":false,"visibility":"user-only","description":"User ID of the contact owner (accepted by Apollo but not officially documented for PATCH /contacts/{id})"},"organization_name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Name of the contact\'s employer (e.g., \\"Apollo\\")"},"website_url":{"type":"string","required":false,"visibility":"user-or-llm","description":"Corporate website URL (e.g., \\"https://www.apollo.io/\\")"},"label_names":{"type":"array","required":false,"visibility":"user-or-llm","description":"Lists/labels to add the contact to (e.g., [\\"Prospects\\"])"},"contact_stage_id":{"type":"string","required":false,"visibility":"user-or-llm","description":"Apollo ID for the contact stage"},"present_raw_address":{"type":"string","required":false,"visibility":"user-or-llm","description":"Personal location for the contact (e.g., \\"Atlanta, United States\\")"},"direct_phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Primary phone number"},"corporate_phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Work/office phone number"},"mobile_phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Mobile phone number"},"home_phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Home phone number"},"other_phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Alternative phone number"},"typed_custom_fields":{"type":"json","required":false,"visibility":"user-or-llm","description":"Custom field values keyed by custom field ID"}},"oauth":{"required":false,"provider":"apollo","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"apollo_email_accounts":{"id":"apollo_email_accounts","name":"Apollo Get Email Accounts","description":"Get list of team\'s linked email accounts in Apollo","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"}},"oauth":{"required":false,"provider":"apollo","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"apollo_opportunity_create":{"id":"apollo_opportunity_create","name":"Apollo Create Opportunity","description":"Create a new deal for an account in your Apollo database (master key required)","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the opportunity/deal (e.g., \\"Enterprise License - Q1\\")"},"account_id":{"type":"string","required":false,"visibility":"user-or-llm","description":"ID of the account this opportunity belongs to (e.g., \\"acc_abc123\\")"},"amount":{"type":"string","required":false,"visibility":"user-or-llm","description":"Monetary value as a plain number string with no commas or currency symbols"},"opportunity_stage_id":{"type":"string","required":false,"visibility":"user-only","description":"ID of the opportunity stage"},"owner_id":{"type":"string","required":false,"visibility":"user-only","description":"User ID of the opportunity owner"},"closed_date":{"type":"string","required":false,"visibility":"user-or-llm","description":"Expected close date in YYYY-MM-DD format"},"typed_custom_fields":{"type":"json","required":false,"visibility":"user-only","description":"Custom field values as { custom_field_id: value } map"}},"oauth":{"required":false,"provider":"apollo","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"apollo_opportunity_get":{"id":"apollo_opportunity_get","name":"Apollo Get Opportunity","description":"Retrieve complete details of a specific deal/opportunity by ID","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"opportunity_id":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the opportunity to retrieve (e.g., \\"opp_abc123\\")"}},"oauth":{"required":false,"provider":"apollo","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"apollo_opportunity_search":{"id":"apollo_opportunity_search","name":"Apollo Search Opportunities","description":"Search and list all deals/opportunities in your team\'s Apollo account","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"sort_by_field":{"type":"string","required":false,"visibility":"user-or-llm","description":"Sort field: \\"amount\\", \\"is_closed\\", or \\"is_won\\""},"page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Page number for pagination (e.g., 1, 2, 3)"},"per_page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Results per page, max 100 (e.g., 25, 50, 100)"}},"oauth":{"required":false,"provider":"apollo","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"apollo_opportunity_update":{"id":"apollo_opportunity_update","name":"Apollo Update Opportunity","description":"Update an existing deal/opportunity in your Apollo database","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"opportunity_id":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the opportunity to update (e.g., \\"opp_abc123\\")"},"name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Name of the opportunity/deal (e.g., \\"Enterprise License - Q1\\")"},"amount":{"type":"string","required":false,"visibility":"user-or-llm","description":"Monetary value as a plain number string with no commas or currency symbols"},"opportunity_stage_id":{"type":"string","required":false,"visibility":"user-only","description":"ID of the opportunity stage"},"owner_id":{"type":"string","required":false,"visibility":"user-only","description":"User ID of the opportunity owner"},"closed_date":{"type":"string","required":false,"visibility":"user-or-llm","description":"Expected close date in YYYY-MM-DD format"},"typed_custom_fields":{"type":"json","required":false,"visibility":"user-only","description":"Custom field values as { custom_field_id: value } map"}},"oauth":{"required":false,"provider":"apollo","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"apollo_organization_bulk_enrich":{"id":"apollo_organization_bulk_enrich","name":"Apollo Bulk Organization Enrichment","description":"Enrich data for up to 10 organizations at once using Apollo","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"domains":{"type":"array","required":true,"visibility":"user-or-llm","description":"Array of company domains to enrich (max 10, no www. or @, e.g., [\\"apollo.io\\", \\"stripe.com\\"])"}},"oauth":{"required":false,"provider":"apollo","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"apollo_organization_enrich":{"id":"apollo_organization_enrich","name":"Apollo Organization Enrichment","description":"Enrich data for a single organization using Apollo","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"domain":{"type":"string","required":true,"visibility":"user-or-llm","description":"Company domain (e.g., \\"apollo.io\\", \\"acme.com\\")"}},"oauth":{"required":false,"provider":"apollo","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"apollo_organization_search":{"id":"apollo_organization_search","name":"Apollo Organization Search","description":"Search Apollo\'s database for companies using filters","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"organization_locations":{"type":"array","required":false,"visibility":"user-or-llm","description":"Company HQ locations (cities, US states, or countries)"},"organization_not_locations":{"type":"array","required":false,"visibility":"user-or-llm","description":"Exclude companies whose HQ is in these locations"},"organization_num_employees_ranges":{"type":"array","required":false,"visibility":"user-or-llm","description":"Employee count ranges as \\"min,max\\" strings (e.g., [\\"1,10\\", \\"250,500\\", \\"10000,20000\\"])"},"q_organization_keyword_tags":{"type":"array","required":false,"visibility":"user-or-llm","description":"Industry or keyword tags"},"q_organization_name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Organization name to search for (e.g., \\"Acme\\", \\"TechCorp\\")"},"organization_ids":{"type":"array","required":false,"visibility":"user-or-llm","description":"Apollo organization IDs to include (e.g., [\\"5e66b6381e05b4008c8331b8\\"])"},"q_organization_domains_list":{"type":"array","required":false,"visibility":"user-or-llm","description":"Domain names to filter by (no www. or @, up to 1,000)"},"page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Page number for pagination (e.g., 1, 2, 3)"},"per_page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Results per page, max 100 (e.g., 25, 50, 100)"}},"oauth":{"required":false,"provider":"apollo","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"apollo_people_bulk_enrich":{"id":"apollo_people_bulk_enrich","name":"Apollo Bulk People Enrichment","description":"Enrich data for up to 10 people at once using Apollo","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"people":{"type":"array","required":true,"visibility":"user-or-llm","description":"Array of people to enrich (max 10)"},"reveal_personal_emails":{"type":"boolean","required":false,"visibility":"user-only","description":"Reveal personal email addresses (uses credits)"},"reveal_phone_number":{"type":"boolean","required":false,"visibility":"user-only","description":"Reveal phone numbers (uses credits, requires webhook_url)"},"webhook_url":{"type":"string","required":false,"visibility":"user-only","description":"Webhook URL for async phone number delivery (required when reveal_phone_number is true)"}},"oauth":{"required":false,"provider":"apollo","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"apollo_people_enrich":{"id":"apollo_people_enrich","name":"Apollo People Enrichment","description":"Enrich data for a single person using Apollo","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"first_name":{"type":"string","required":false,"visibility":"user-or-llm","description":"First name of the person"},"last_name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Last name of the person"},"name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Full name of the person (alternative to first_name/last_name)"},"id":{"type":"string","required":false,"visibility":"user-or-llm","description":"Apollo ID for the person"},"hashed_email":{"type":"string","required":false,"visibility":"user-or-llm","description":"MD5 or SHA-256 hashed email"},"email":{"type":"string","required":false,"visibility":"user-or-llm","description":"Email address of the person"},"organization_name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Company name where the person works"},"domain":{"type":"string","required":false,"visibility":"user-or-llm","description":"Company domain (e.g., \\"apollo.io\\", \\"acme.com\\")"},"linkedin_url":{"type":"string","required":false,"visibility":"user-or-llm","description":"LinkedIn profile URL"},"reveal_personal_emails":{"type":"boolean","required":false,"visibility":"user-only","description":"Reveal personal email addresses (uses credits)"},"reveal_phone_number":{"type":"boolean","required":false,"visibility":"user-only","description":"Reveal phone numbers (uses credits, requires webhook_url)"},"webhook_url":{"type":"string","required":false,"visibility":"user-only","description":"Webhook URL for async phone number delivery (required when reveal_phone_number is true)"}},"oauth":{"required":false,"provider":"apollo","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"apollo_people_search":{"id":"apollo_people_search","name":"Apollo People Search","description":"Search Apollo\'s database for people using demographic filters","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"person_titles":{"type":"array","required":false,"visibility":"user-or-llm","description":"Job titles to search for (e.g., [\\"CEO\\", \\"VP of Sales\\"])"},"include_similar_titles":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Whether to return people with job titles similar to person_titles"},"person_locations":{"type":"array","required":false,"visibility":"user-or-llm","description":"Locations to search in (e.g., [\\"San Francisco, CA\\", \\"New York, NY\\"])"},"person_seniorities":{"type":"array","required":false,"visibility":"user-or-llm","description":"Seniority levels (one of: owner, founder, c_suite, partner, vp, head, director, manager, senior, entry, intern)"},"organization_ids":{"type":"array","required":false,"visibility":"user-or-llm","description":"Apollo organization IDs to filter by (e.g., [\\"5e66b6381e05b4008c8331b8\\"])"},"organization_names":{"type":"array","required":false,"visibility":"user-or-llm","description":"Company names to search within (legacy filter)"},"organization_locations":{"type":"array","required":false,"visibility":"user-or-llm","description":"Headquarters locations of the people\'s current employer (e.g., [\'texas\', \'tokyo\', \'spain\'])"},"q_organization_domains_list":{"type":"array","required":false,"visibility":"user-or-llm","description":"Employer domain names (e.g., [\\"apollo.io\\", \\"microsoft.com\\"]) — up to 1,000, no www. or @"},"organization_num_employees_ranges":{"type":"array","required":false,"visibility":"user-or-llm","description":"Employee count ranges for the person\'s current employer. Each entry is \\"min,max\\" (e.g., [\\"1,10\\", \\"250,500\\", \\"10000,20000\\"])"},"contact_email_status":{"type":"array","required":false,"visibility":"user-or-llm","description":"Email statuses to filter by: \\"verified\\", \\"unverified\\", \\"likely to engage\\", \\"unavailable\\""},"q_keywords":{"type":"string","required":false,"visibility":"user-or-llm","description":"Keywords to search for"},"page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Page number for pagination, default 1 (e.g., 1, 2, 3)"},"per_page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Results per page, default 25, max 100 (e.g., 25, 50, 100)"}},"oauth":{"required":false,"provider":"apollo","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"apollo_sequence_add_contacts":{"id":"apollo_sequence_add_contacts","name":"Apollo Add Contacts to Sequence","description":"Add contacts to an Apollo sequence","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"sequence_id":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the sequence to add contacts to (e.g., \\"seq_abc123\\")"},"contact_ids":{"type":"array","required":false,"visibility":"user-or-llm","description":"Array of contact IDs to add to the sequence (e.g., [\\"con_abc123\\", \\"con_def456\\"]). Either contact_ids or label_names must be provided."},"label_names":{"type":"array","required":false,"visibility":"user-or-llm","description":"Array of label names to identify contacts to add to the sequence. Either contact_ids or label_names must be provided."},"send_email_from_email_account_id":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the email account to send from. Use the Get Email Accounts operation to look this up."},"send_email_from_email_address":{"type":"string","required":false,"visibility":"user-only","description":"Specific email address to send from within the email account."},"sequence_no_email":{"type":"boolean","required":false,"visibility":"user-only","description":"Add contacts even if they have no email address"},"sequence_unverified_email":{"type":"boolean","required":false,"visibility":"user-only","description":"Add contacts with unverified email addresses"},"sequence_job_change":{"type":"boolean","required":false,"visibility":"user-only","description":"Add contacts who recently changed jobs"},"sequence_active_in_other_campaigns":{"type":"boolean","required":false,"visibility":"user-only","description":"Add contacts active in other campaigns"},"sequence_finished_in_other_campaigns":{"type":"boolean","required":false,"visibility":"user-only","description":"Add contacts who finished other campaigns"},"sequence_same_company_in_same_campaign":{"type":"boolean","required":false,"visibility":"user-only","description":"Add contacts even if others from the same company are in the sequence"},"contacts_without_ownership_permission":{"type":"boolean","required":false,"visibility":"user-only","description":"Add contacts without ownership permission"},"add_if_in_queue":{"type":"boolean","required":false,"visibility":"user-only","description":"Add contacts even if they are in the queue"},"contact_verification_skipped":{"type":"boolean","required":false,"visibility":"user-only","description":"Skip contact verification when adding"},"user_id":{"type":"string","required":false,"visibility":"user-only","description":"ID of the user performing the action"},"status":{"type":"string","required":false,"visibility":"user-only","description":"Initial status for added contacts: \\"active\\" or \\"paused\\""},"auto_unpause_at":{"type":"string","required":false,"visibility":"user-only","description":"ISO 8601 datetime to automatically unpause contacts"}},"oauth":{"required":false,"provider":"apollo","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"apollo_sequence_search":{"id":"apollo_sequence_search","name":"Apollo Search Sequences","description":"Search for sequences/campaigns in your team\'s Apollo account (master key required)","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"q_name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Search sequences by name (e.g., \\"Outbound Q1\\", \\"Follow-up\\")"},"page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Page number for pagination (e.g., 1, 2, 3)"},"per_page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Results per page, max 100 (e.g., 25, 50, 100)"}},"oauth":{"required":false,"provider":"apollo","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"apollo_task_create":{"id":"apollo_task_create","name":"Apollo Create Task","description":"Create one or more tasks in Apollo (one task per contact_id, master key required)","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"user_id":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the Apollo user the task is assigned to"},"contact_ids":{"type":"array","required":true,"visibility":"user-or-llm","description":"Array of contact IDs. One task is created per contact."},"priority":{"type":"string","required":false,"visibility":"user-or-llm","description":"Task priority: \\"high\\", \\"medium\\", or \\"low\\" (defaults to \\"medium\\")"},"due_at":{"type":"string","required":true,"visibility":"user-or-llm","description":"Due date/time in ISO 8601 format (e.g., \\"2024-12-31T23:59:59Z\\")"},"type":{"type":"string","required":true,"visibility":"user-or-llm","description":"Task type: \\"call\\", \\"outreach_manual_email\\", \\"linkedin_step_connect\\", \\"linkedin_step_message\\", \\"linkedin_step_view_profile\\", \\"linkedin_step_interact_post\\", or \\"action_item\\""},"status":{"type":"string","required":true,"visibility":"user-or-llm","description":"Task status: \\"scheduled\\", \\"completed\\", or \\"skipped\\""},"note":{"type":"string","required":false,"visibility":"user-or-llm","description":"Free-form note providing context for the task"}},"oauth":{"required":false,"provider":"apollo","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"apollo_task_search":{"id":"apollo_task_search","name":"Apollo Search Tasks","description":"Search for tasks in Apollo","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"sort_by_field":{"type":"string","required":false,"visibility":"user-or-llm","description":"Sort field: \\"task_due_at\\" or \\"task_priority\\""},"open_factor_names":{"type":"array","required":false,"visibility":"user-or-llm","description":"Filter by status. Common values: [\\"task_types\\"] for open tasks, [\\"task_completed_at\\"] for completed tasks."},"page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Page number for pagination (e.g., 1, 2, 3)"},"per_page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Results per page, max 100 (e.g., 25, 50, 100)"}},"oauth":{"required":false,"provider":"apollo","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"appconfig_create_application":{"id":"appconfig_create_application","name":"AppConfig Create Application","description":"Create an application in AWS AppConfig","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the application to create"},"description":{"type":"string","required":false,"visibility":"user-or-llm","description":"Description of the application"}},"oauth":{"required":false,"provider":"aws","credentialKind":"service-account","credentialSuppliedParams":["accessKeyId","secretAccessKey","awsAccessKeyId","awsSecretAccessKey"]},"hostedApiKey":"none"},"appconfig_create_configuration_profile":{"id":"appconfig_create_configuration_profile","name":"AppConfig Create Configuration Profile","description":"Create a configuration profile in an AWS AppConfig application","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID to create the configuration profile in"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the configuration profile"},"locationUri":{"type":"string","required":true,"visibility":"user-or-llm","description":"Where the configuration is stored. Use \\"hosted\\" for AppConfig-hosted configurations, or an SSM/S3 URI"},"description":{"type":"string","required":false,"visibility":"user-or-llm","description":"Description of the configuration profile"},"retrievalRoleArn":{"type":"string","required":false,"visibility":"user-or-llm","description":"ARN of an IAM role to retrieve the configuration (required for non-hosted URIs)"},"type":{"type":"string","required":false,"visibility":"user-or-llm","description":"Profile type: AWS.Freeform (default) or AWS.AppConfig.FeatureFlags"}},"oauth":{"required":false,"provider":"aws","credentialKind":"service-account","credentialSuppliedParams":["accessKeyId","secretAccessKey","awsAccessKeyId","awsSecretAccessKey"]},"hostedApiKey":"none"},"appconfig_create_environment":{"id":"appconfig_create_environment","name":"AppConfig Create Environment","description":"Create an environment for an AWS AppConfig application","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID to create the environment in"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the environment to create"},"description":{"type":"string","required":false,"visibility":"user-or-llm","description":"Description of the environment"}},"oauth":{"required":false,"provider":"aws","credentialKind":"service-account","credentialSuppliedParams":["accessKeyId","secretAccessKey","awsAccessKeyId","awsSecretAccessKey"]},"hostedApiKey":"none"},"appconfig_create_hosted_configuration_version":{"id":"appconfig_create_hosted_configuration_version","name":"AppConfig Create Hosted Configuration Version","description":"Create a new hosted configuration version for an AppConfig configuration profile","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the configuration profile"},"configurationProfileId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration profile ID to add the version to"},"content":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration content (e.g., a JSON or YAML document)"},"contentType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Content type of the configuration (e.g., application/json, text/plain)"},"description":{"type":"string","required":false,"visibility":"user-or-llm","description":"Description of the configuration version"},"latestVersionNumber":{"type":"number","required":false,"visibility":"user-or-llm","description":"The version number of the latest version, used for optimistic concurrency"},"versionLabel":{"type":"string","required":false,"visibility":"user-or-llm","description":"A user-defined label for the configuration version"}},"oauth":{"required":false,"provider":"aws","credentialKind":"service-account","credentialSuppliedParams":["accessKeyId","secretAccessKey","awsAccessKeyId","awsSecretAccessKey"]},"hostedApiKey":"none"},"appconfig_delete_application":{"id":"appconfig_delete_application","name":"AppConfig Delete Application","description":"Delete an AWS AppConfig application","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID to delete"}},"oauth":{"required":false,"provider":"aws","credentialKind":"service-account","credentialSuppliedParams":["accessKeyId","secretAccessKey","awsAccessKeyId","awsSecretAccessKey"]},"hostedApiKey":"none"},"appconfig_delete_configuration_profile":{"id":"appconfig_delete_configuration_profile","name":"AppConfig Delete Configuration Profile","description":"Delete an AWS AppConfig configuration profile","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the configuration profile"},"configurationProfileId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration profile ID to delete"}},"oauth":{"required":false,"provider":"aws","credentialKind":"service-account","credentialSuppliedParams":["accessKeyId","secretAccessKey","awsAccessKeyId","awsSecretAccessKey"]},"hostedApiKey":"none"},"appconfig_delete_environment":{"id":"appconfig_delete_environment","name":"AppConfig Delete Environment","description":"Delete an AWS AppConfig environment","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the environment"},"environmentId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The environment ID to delete"}},"oauth":{"required":false,"provider":"aws","credentialKind":"service-account","credentialSuppliedParams":["accessKeyId","secretAccessKey","awsAccessKeyId","awsSecretAccessKey"]},"hostedApiKey":"none"},"appconfig_delete_hosted_configuration_version":{"id":"appconfig_delete_hosted_configuration_version","name":"AppConfig Delete Hosted Configuration Version","description":"Delete a specific hosted configuration version from an AppConfig profile","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the configuration profile"},"configurationProfileId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration profile ID that owns the version"},"versionNumber":{"type":"number","required":true,"visibility":"user-or-llm","description":"The version number to delete"}},"oauth":{"required":false,"provider":"aws","credentialKind":"service-account","credentialSuppliedParams":["accessKeyId","secretAccessKey","awsAccessKeyId","awsSecretAccessKey"]},"hostedApiKey":"none"},"appconfig_get_application":{"id":"appconfig_get_application","name":"AppConfig Get Application","description":"Get details about a single AWS AppConfig application","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID to retrieve"}},"oauth":{"required":false,"provider":"aws","credentialKind":"service-account","credentialSuppliedParams":["accessKeyId","secretAccessKey","awsAccessKeyId","awsSecretAccessKey"]},"hostedApiKey":"none"},"appconfig_get_configuration":{"id":"appconfig_get_configuration","name":"AppConfig Get Configuration","description":"Retrieve the latest deployed configuration for an AppConfig application, environment, and profile","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID or name to retrieve configuration for"},"environmentId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The environment ID or name to retrieve configuration for"},"configurationProfileId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration profile ID or name to retrieve"}},"oauth":{"required":false,"provider":"aws","credentialKind":"service-account","credentialSuppliedParams":["accessKeyId","secretAccessKey","awsAccessKeyId","awsSecretAccessKey"]},"hostedApiKey":"none"},"appconfig_get_configuration_profile":{"id":"appconfig_get_configuration_profile","name":"AppConfig Get Configuration Profile","description":"Get details about a single AWS AppConfig configuration profile","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the configuration profile"},"configurationProfileId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration profile ID to retrieve"}},"oauth":{"required":false,"provider":"aws","credentialKind":"service-account","credentialSuppliedParams":["accessKeyId","secretAccessKey","awsAccessKeyId","awsSecretAccessKey"]},"hostedApiKey":"none"},"appconfig_get_deployment":{"id":"appconfig_get_deployment","name":"AppConfig Get Deployment","description":"Get details about a specific AWS AppConfig deployment","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID of the deployment"},"environmentId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The environment ID of the deployment"},"deploymentNumber":{"type":"number","required":true,"visibility":"user-or-llm","description":"The sequence number of the deployment"}},"oauth":{"required":false,"provider":"aws","credentialKind":"service-account","credentialSuppliedParams":["accessKeyId","secretAccessKey","awsAccessKeyId","awsSecretAccessKey"]},"hostedApiKey":"none"},"appconfig_get_environment":{"id":"appconfig_get_environment","name":"AppConfig Get Environment","description":"Get details about a single AWS AppConfig environment","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the environment"},"environmentId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The environment ID to retrieve"}},"oauth":{"required":false,"provider":"aws","credentialKind":"service-account","credentialSuppliedParams":["accessKeyId","secretAccessKey","awsAccessKeyId","awsSecretAccessKey"]},"hostedApiKey":"none"},"appconfig_get_hosted_configuration_version":{"id":"appconfig_get_hosted_configuration_version","name":"AppConfig Get Hosted Configuration Version","description":"Retrieve a specific hosted configuration version from an AppConfig profile","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the configuration profile"},"configurationProfileId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration profile ID to read the version from"},"versionNumber":{"type":"number","required":true,"visibility":"user-or-llm","description":"The version number to retrieve"}},"oauth":{"required":false,"provider":"aws","credentialKind":"service-account","credentialSuppliedParams":["accessKeyId","secretAccessKey","awsAccessKeyId","awsSecretAccessKey"]},"hostedApiKey":"none"},"appconfig_list_applications":{"id":"appconfig_list_applications","name":"AppConfig List Applications","description":"List applications in AWS AppConfig","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"maxResults":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of applications to return (1-50)"},"nextToken":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination token from a previous response"}},"oauth":{"required":false,"provider":"aws","credentialKind":"service-account","credentialSuppliedParams":["accessKeyId","secretAccessKey","awsAccessKeyId","awsSecretAccessKey"]},"hostedApiKey":"none"},"appconfig_list_configuration_profiles":{"id":"appconfig_list_configuration_profiles","name":"AppConfig List Configuration Profiles","description":"List configuration profiles for an AWS AppConfig application","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the configuration profiles"},"maxResults":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of configuration profiles to return (1-50)"},"nextToken":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination token from a previous response"}},"oauth":{"required":false,"provider":"aws","credentialKind":"service-account","credentialSuppliedParams":["accessKeyId","secretAccessKey","awsAccessKeyId","awsSecretAccessKey"]},"hostedApiKey":"none"},"appconfig_list_deployment_strategies":{"id":"appconfig_list_deployment_strategies","name":"AppConfig List Deployment Strategies","description":"List deployment strategies available in AWS AppConfig","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"maxResults":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of deployment strategies to return (1-50)"},"nextToken":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination token from a previous response"}},"oauth":{"required":false,"provider":"aws","credentialKind":"service-account","credentialSuppliedParams":["accessKeyId","secretAccessKey","awsAccessKeyId","awsSecretAccessKey"]},"hostedApiKey":"none"},"appconfig_list_deployments":{"id":"appconfig_list_deployments","name":"AppConfig List Deployments","description":"List deployments for an AWS AppConfig environment","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID of the deployments"},"environmentId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The environment ID of the deployments"},"maxResults":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of deployments to return (1-50)"},"nextToken":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination token from a previous response"}},"oauth":{"required":false,"provider":"aws","credentialKind":"service-account","credentialSuppliedParams":["accessKeyId","secretAccessKey","awsAccessKeyId","awsSecretAccessKey"]},"hostedApiKey":"none"},"appconfig_list_environments":{"id":"appconfig_list_environments","name":"AppConfig List Environments","description":"List environments for an AWS AppConfig application","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the environments"},"maxResults":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of environments to return (1-50)"},"nextToken":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination token from a previous response"}},"oauth":{"required":false,"provider":"aws","credentialKind":"service-account","credentialSuppliedParams":["accessKeyId","secretAccessKey","awsAccessKeyId","awsSecretAccessKey"]},"hostedApiKey":"none"},"appconfig_list_hosted_configuration_versions":{"id":"appconfig_list_hosted_configuration_versions","name":"AppConfig List Hosted Configuration Versions","description":"List hosted configuration versions for an AWS AppConfig configuration profile","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the configuration profile"},"configurationProfileId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration profile ID to list versions for"},"maxResults":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of versions to return (1-50)"},"nextToken":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination token from a previous response"}},"oauth":{"required":false,"provider":"aws","credentialKind":"service-account","credentialSuppliedParams":["accessKeyId","secretAccessKey","awsAccessKeyId","awsSecretAccessKey"]},"hostedApiKey":"none"},"appconfig_start_deployment":{"id":"appconfig_start_deployment","name":"AppConfig Start Deployment","description":"Start deploying a configuration version to an AWS AppConfig environment","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID to deploy in"},"environmentId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The environment ID to deploy to"},"deploymentStrategyId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The deployment strategy ID to use"},"configurationProfileId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration profile ID to deploy"},"configurationVersion":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration version to deploy"},"description":{"type":"string","required":false,"visibility":"user-or-llm","description":"Description of the deployment"}},"oauth":{"required":false,"provider":"aws","credentialKind":"service-account","credentialSuppliedParams":["accessKeyId","secretAccessKey","awsAccessKeyId","awsSecretAccessKey"]},"hostedApiKey":"none"},"appconfig_stop_deployment":{"id":"appconfig_stop_deployment","name":"AppConfig Stop Deployment","description":"Stop an in-progress AWS AppConfig deployment","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID of the deployment"},"environmentId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The environment ID of the deployment"},"deploymentNumber":{"type":"number","required":true,"visibility":"user-or-llm","description":"The sequence number of the deployment to stop"}},"oauth":{"required":false,"provider":"aws","credentialKind":"service-account","credentialSuppliedParams":["accessKeyId","secretAccessKey","awsAccessKeyId","awsSecretAccessKey"]},"hostedApiKey":"none"},"appconfig_update_application":{"id":"appconfig_update_application","name":"AppConfig Update Application","description":"Update the name or description of an AWS AppConfig application","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID to update"},"name":{"type":"string","required":false,"visibility":"user-or-llm","description":"New name for the application"},"description":{"type":"string","required":false,"visibility":"user-or-llm","description":"New description for the application"}},"oauth":{"required":false,"provider":"aws","credentialKind":"service-account","credentialSuppliedParams":["accessKeyId","secretAccessKey","awsAccessKeyId","awsSecretAccessKey"]},"hostedApiKey":"none"},"appconfig_update_configuration_profile":{"id":"appconfig_update_configuration_profile","name":"AppConfig Update Configuration Profile","description":"Update the name, description, or retrieval role of an AppConfig configuration profile","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the configuration profile"},"configurationProfileId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration profile ID to update"},"name":{"type":"string","required":false,"visibility":"user-or-llm","description":"New name for the configuration profile"},"description":{"type":"string","required":false,"visibility":"user-or-llm","description":"New description for the configuration profile"},"retrievalRoleArn":{"type":"string","required":false,"visibility":"user-or-llm","description":"New ARN of the IAM role used to retrieve the configuration"}},"oauth":{"required":false,"provider":"aws","credentialKind":"service-account","credentialSuppliedParams":["accessKeyId","secretAccessKey","awsAccessKeyId","awsSecretAccessKey"]},"hostedApiKey":"none"},"appconfig_update_environment":{"id":"appconfig_update_environment","name":"AppConfig Update Environment","description":"Update the name or description of an AWS AppConfig environment","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the environment"},"environmentId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The environment ID to update"},"name":{"type":"string","required":false,"visibility":"user-or-llm","description":"New name for the environment"},"description":{"type":"string","required":false,"visibility":"user-or-llm","description":"New description for the environment"}},"oauth":{"required":false,"provider":"aws","credentialKind":"service-account","credentialSuppliedParams":["accessKeyId","secretAccessKey","awsAccessKeyId","awsSecretAccessKey"]},"hostedApiKey":"none"},"arxiv_get_author_papers":{"id":"arxiv_get_author_papers","name":"ArXiv Get Author Papers","description":"Search for papers by a specific author on ArXiv.","version":"1.0.0","params":{"authorName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Author name to search for"},"maxResults":{"type":"number","required":false,"visibility":"user-only","description":"Maximum number of results to return (default: 10, max: 2000)"}},"hostedApiKey":"none"},"arxiv_get_paper":{"id":"arxiv_get_paper","name":"ArXiv Get Paper","description":"Get detailed information about a specific ArXiv paper by its ID.","version":"1.0.0","params":{"paperId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ArXiv paper ID (e.g., \\"1706.03762\\")"}},"hostedApiKey":"none"},"arxiv_search":{"id":"arxiv_search","name":"ArXiv Search","description":"Search for academic papers on ArXiv by keywords, authors, titles, or other fields.","version":"1.0.0","params":{"searchQuery":{"type":"string","required":true,"visibility":"user-or-llm","description":"The search query to execute"},"searchField":{"type":"string","required":false,"visibility":"user-only","description":"Field to search in: all, ti (title), au (author), abs (abstract), co (comment), jr (journal), cat (category), rn (report number)"},"maxResults":{"type":"number","required":false,"visibility":"user-only","description":"Maximum number of results to return (default: 10, max: 2000)"},"sortBy":{"type":"string","required":false,"visibility":"user-only","description":"Sort by: relevance, lastUpdatedDate, submittedDate (default: relevance)"},"sortOrder":{"type":"string","required":false,"visibility":"user-only","description":"Sort order: ascending, descending (default: descending)"}},"hostedApiKey":"none"},"asana_add_comment":{"id":"asana_add_comment","name":"Asana Add Comment","description":"Add a comment (story) to an Asana task","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"taskGid":{"type":"string","required":true,"visibility":"user-or-llm","description":"Asana task GID (numeric string)"},"text":{"type":"string","required":true,"visibility":"user-or-llm","description":"The text content of the comment"}},"oauth":{"required":true,"provider":"asana"},"hostedApiKey":"none"},"asana_add_followers":{"id":"asana_add_followers","name":"Asana Add Followers","description":"Add one or more followers to an Asana task","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"taskGid":{"type":"string","required":true,"visibility":"user-or-llm","description":"GID of the Asana task (numeric string)"},"followers":{"type":"array","required":true,"visibility":"user-or-llm","description":"Array of user GIDs to add as followers to the task"}},"oauth":{"required":true,"provider":"asana"},"hostedApiKey":"none"},"asana_create_project":{"id":"asana_create_project","name":"Asana Create Project","description":"Create a new project in an Asana workspace","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"workspace":{"type":"string","required":true,"visibility":"user-or-llm","description":"Asana workspace GID (numeric string) where the project will be created"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the project"},"notes":{"type":"string","required":false,"visibility":"user-or-llm","description":"Notes or description for the project"}},"oauth":{"required":true,"provider":"asana"},"hostedApiKey":"none"},"asana_create_section":{"id":"asana_create_section","name":"Asana Create Section","description":"Create a new section in an Asana project","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"projectGid":{"type":"string","required":true,"visibility":"user-or-llm","description":"GID of the Asana project (numeric string) to add the section to"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the section"}},"oauth":{"required":true,"provider":"asana"},"hostedApiKey":"none"},"asana_create_subtask":{"id":"asana_create_subtask","name":"Asana Create Subtask","description":"Create a subtask under an existing Asana task","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"taskGid":{"type":"string","required":true,"visibility":"user-or-llm","description":"GID of the parent Asana task (numeric string)"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the subtask"},"notes":{"type":"string","required":false,"visibility":"user-or-llm","description":"Notes or description for the subtask"},"assignee":{"type":"string","required":false,"visibility":"user-or-llm","description":"User GID to assign the subtask to"},"due_on":{"type":"string","required":false,"visibility":"user-or-llm","description":"Due date in YYYY-MM-DD format"}},"oauth":{"required":true,"provider":"asana"},"hostedApiKey":"none"},"asana_create_task":{"id":"asana_create_task","name":"Asana Create Task","description":"Create a new task in Asana","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"workspace":{"type":"string","required":true,"visibility":"user-or-llm","description":"Asana workspace GID (numeric string) where the task will be created"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the task"},"notes":{"type":"string","required":false,"visibility":"user-or-llm","description":"Notes or description for the task"},"assignee":{"type":"string","required":false,"visibility":"user-or-llm","description":"User GID to assign the task to"},"due_on":{"type":"string","required":false,"visibility":"user-or-llm","description":"Due date in YYYY-MM-DD format"}},"oauth":{"required":true,"provider":"asana"},"hostedApiKey":"none"},"asana_delete_task":{"id":"asana_delete_task","name":"Asana Delete Task","description":"Delete an Asana task by its GID (moves it to the trash)","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"taskGid":{"type":"string","required":true,"visibility":"user-or-llm","description":"GID of the Asana task to delete (numeric string)"}},"oauth":{"required":true,"provider":"asana"},"hostedApiKey":"none"},"asana_get_project":{"id":"asana_get_project","name":"Asana Get Project","description":"Retrieve a single Asana project by its GID","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"projectGid":{"type":"string","required":true,"visibility":"user-or-llm","description":"Asana project GID (numeric string) to retrieve"}},"oauth":{"required":true,"provider":"asana"},"hostedApiKey":"none"},"asana_get_projects":{"id":"asana_get_projects","name":"Asana Get Projects","description":"Retrieve all projects from an Asana workspace","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"workspace":{"type":"string","required":true,"visibility":"user-or-llm","description":"Asana workspace GID (numeric string) to retrieve projects from"}},"oauth":{"required":true,"provider":"asana"},"hostedApiKey":"none"},"asana_get_task":{"id":"asana_get_task","name":"Asana Get Task","description":"Retrieve a single task by GID or get multiple tasks with filters","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"taskGid":{"type":"string","required":false,"visibility":"user-or-llm","description":"The globally unique identifier (GID) of the task. If not provided, will get multiple tasks."},"workspace":{"type":"string","required":false,"visibility":"user-or-llm","description":"Asana workspace GID (numeric string) to filter tasks (required when not using taskGid)"},"project":{"type":"string","required":false,"visibility":"user-or-llm","description":"Asana project GID (numeric string) to filter tasks"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of tasks to return (default: 50)"}},"oauth":{"required":true,"provider":"asana"},"hostedApiKey":"none"},"asana_list_sections":{"id":"asana_list_sections","name":"Asana List Sections","description":"List all sections in an Asana project","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"projectGid":{"type":"string","required":true,"visibility":"user-or-llm","description":"GID of the Asana project (numeric string) to list sections from"}},"oauth":{"required":true,"provider":"asana"},"hostedApiKey":"none"},"asana_list_workspaces":{"id":"asana_list_workspaces","name":"Asana List Workspaces","description":"List all Asana workspaces and organizations the authenticated user belongs to","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"}},"oauth":{"required":true,"provider":"asana"},"hostedApiKey":"none"},"asana_search_tasks":{"id":"asana_search_tasks","name":"Asana Search Tasks","description":"Search for tasks in an Asana workspace","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"workspace":{"type":"string","required":true,"visibility":"user-or-llm","description":"Asana workspace GID (numeric string) to search tasks in"},"text":{"type":"string","required":false,"visibility":"user-or-llm","description":"Text to search for in task names"},"assignee":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter tasks by assignee user GID"},"projects":{"type":"array","required":false,"visibility":"user-or-llm","description":"Array of Asana project GIDs (numeric strings) to filter tasks by"},"completed":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Filter by completion status"}},"oauth":{"required":true,"provider":"asana"},"hostedApiKey":"none"},"asana_update_task":{"id":"asana_update_task","name":"Asana Update Task","description":"Update an existing task in Asana","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"taskGid":{"type":"string","required":true,"visibility":"user-or-llm","description":"Asana task GID (numeric string) of the task to update"},"name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Updated name for the task"},"notes":{"type":"string","required":false,"visibility":"user-or-llm","description":"Updated notes or description for the task"},"assignee":{"type":"string","required":false,"visibility":"user-or-llm","description":"Updated assignee user GID"},"completed":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Mark task as completed or not completed"},"due_on":{"type":"string","required":false,"visibility":"user-or-llm","description":"Updated due date in YYYY-MM-DD format"}},"oauth":{"required":true,"provider":"asana"},"hostedApiKey":"none"},"ashby_add_candidate_tag":{"id":"ashby_add_candidate_tag","name":"Ashby Add Candidate Tag","description":"Adds a tag to a candidate in Ashby and returns the updated candidate.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ashby API Key"},"onBehalfOfUserId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Active Ashby user UUID to attribute this mutation to; the API key must permit on-behalf-of calls"},"candidateId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The UUID of the candidate to add the tag to"},"tagId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The UUID of the tag to add"}},"oauth":{"required":false,"provider":"ashby","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"ashby_anonymize_candidate":{"id":"ashby_anonymize_candidate","name":"Ashby Anonymize Candidate","description":"Remove personally identifiable information from an Ashby candidate while retaining the candidate record and its applications. This operation does not delete the candidate. Requires the candidatesWrite permission.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ashby API Key"},"onBehalfOfUserId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Active Ashby user UUID to attribute this mutation to; the API key must permit on-behalf-of calls"},"candidateId":{"type":"string","required":true,"visibility":"user-or-llm","description":"UUID of the candidate to anonymize"}},"oauth":{"required":false,"provider":"ashby","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"ashby_change_application_source":{"id":"ashby_change_application_source","name":"Ashby Change Application Source","description":"Changes the source attributed to an existing application, so programmatically created applications report correctly on the recruiting side. Requires the candidatesWrite permission.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ashby API Key"},"onBehalfOfUserId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Active Ashby user UUID to attribute this mutation to; the API key must permit on-behalf-of calls"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"UUID of the application whose source should change"},"sourceId":{"type":"string","required":false,"visibility":"user-or-llm","description":"UUID of the source to attribute the application to, as returned by List Sources. Omit only when unsetSource is true."},"unsetSource":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Set true to deliberately clear the application source. Required to unset, so that a missing or empty sourceId cannot wipe attribution by accident."}},"oauth":{"required":false,"provider":"ashby","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"ashby_change_application_stage":{"id":"ashby_change_application_stage","name":"Ashby Change Application Stage","description":"Moves an application to a different interview stage. Requires an archive reason when moving to an Archived stage.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ashby API Key"},"onBehalfOfUserId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Active Ashby user UUID to attribute this mutation to; the API key must permit on-behalf-of calls"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The UUID of the application to update the stage of"},"interviewStageId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The UUID of the interview stage to move the application to"},"archiveReasonId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Archive reason UUID. Required when moving to an Archived stage, ignored otherwise"},"archiveEmail":{"type":"json","required":false,"visibility":"user-or-llm","description":"Archive email configuration with communicationTemplateId and optional sendAt ISO 8601 timestamp. Pass null or omit to send no archive email."}},"oauth":{"required":false,"provider":"ashby","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"ashby_create_application":{"id":"ashby_create_application","name":"Ashby Create Application","description":"Creates a new application for a candidate on a job. Optionally specify interview plan, stage, source, and credited user.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ashby API Key"},"onBehalfOfUserId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Active Ashby user UUID to attribute this mutation to; the API key must permit on-behalf-of calls"},"candidateId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The UUID of the candidate to consider for the job"},"jobId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The UUID of the job to consider the candidate for"},"interviewPlanId":{"type":"string","required":false,"visibility":"user-or-llm","description":"UUID of the interview plan to use (defaults to the job default plan)"},"interviewStageId":{"type":"string","required":false,"visibility":"user-or-llm","description":"UUID of the interview stage to place the application in, or FirstPreInterviewScreen (defaults to the first Lead stage)"},"sourceId":{"type":"string","required":false,"visibility":"user-or-llm","description":"UUID of the source to set on the application"},"creditedToUserId":{"type":"string","required":false,"visibility":"user-or-llm","description":"UUID of the user the application is credited to"},"createdAt":{"type":"string","required":false,"visibility":"user-or-llm","description":"ISO 8601 timestamp to set as the application creation date (defaults to now)"},"applicationHistory":{"type":"json","required":false,"visibility":"user-or-llm","description":"Optional documented application history entries to create with the application"}},"oauth":{"required":false,"provider":"ashby","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"ashby_create_candidate":{"id":"ashby_create_candidate","name":"Ashby Create Candidate","description":"Creates a new candidate record in Ashby.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ashby API Key"},"onBehalfOfUserId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Active Ashby user UUID to attribute this mutation to; the API key must permit on-behalf-of calls"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"The candidate full name"},"email":{"type":"string","required":false,"visibility":"user-or-llm","description":"Primary email address for the candidate"},"phoneNumber":{"type":"string","required":false,"visibility":"user-or-llm","description":"Primary phone number for the candidate"},"linkedInUrl":{"type":"string","required":false,"visibility":"user-or-llm","description":"LinkedIn profile URL"},"githubUrl":{"type":"string","required":false,"visibility":"user-or-llm","description":"GitHub profile URL"},"website":{"type":"string","required":false,"visibility":"user-or-llm","description":"Personal website URL"},"sourceId":{"type":"string","required":false,"visibility":"user-or-llm","description":"UUID of the source to attribute the candidate to"},"creditedToUserId":{"type":"string","required":false,"visibility":"user-or-llm","description":"UUID of the Ashby user to credit with sourcing this candidate"},"createdAt":{"type":"string","required":false,"visibility":"user-or-llm","description":"Backdated creation timestamp in ISO 8601 (e.g. 2024-01-01T00:00:00Z). Defaults to now."},"alternateEmailAddresses":{"type":"json","required":false,"visibility":"user-or-llm","description":"Array of additional email address strings to add to the candidate, e.g. [\\"a@x.com\\",\\"b@y.com\\"]"},"location":{"type":"json","required":false,"visibility":"user-or-llm","description":"Candidate location object with optional city, region, and country"}},"oauth":{"required":false,"provider":"ashby","credentialKind":"service-account","credentialParams":{"apiKey":"accessToken"},"credentialSuppliedParams":["apiKey"]},"hostedApiKey":"none"},"ashby_create_note":{"id":"ashby_create_note","name":"Ashby Create Note","description":"Creates a note on a candidate in Ashby. Supports plain text and HTML content (bold, italic, underline, links, lists, code).","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ashby API Key"},"onBehalfOfUserId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Active Ashby user UUID to attribute this mutation to; the API key must permit on-behalf-of calls"},"candidateId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The UUID of the candidate to add the note to"},"note":{"type":"string","required":true,"visibility":"user-or-llm","description":"The note content. If noteType is text/html, supports: , , , ,