From 77bc296154db42fd27bddf3e2100c0f4e07c06c9 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Sun, 12 Apr 2026 15:34:28 -0700 Subject: [PATCH 1/5] feat(crowdstrike): add tools + validate whatsapp, shopify, trello --- apps/docs/components/icons.tsx | 11 + apps/docs/components/ui/icon-mapping.ts | 2 + .../content/docs/en/tools/crowdstrike.mdx | 150 +++++ apps/docs/content/docs/en/tools/meta.json | 1 + apps/docs/content/docs/en/tools/shopify.mdx | 4 +- apps/docs/content/docs/en/tools/trello.mdx | 131 ++++- apps/docs/content/docs/en/tools/whatsapp.mdx | 15 +- .../integrations/data/icon-mapping.ts | 2 + .../integrations/data/integrations.json | 45 +- .../app/api/auth/shopify/authorize/route.ts | 10 +- .../app/api/auth/trello/authorize/route.ts | 9 +- .../sim/app/api/auth/trello/callback/route.ts | 9 +- apps/sim/app/api/auth/trello/store/route.ts | 25 +- .../app/api/tools/crowdstrike/query/route.ts | 311 ++++++++++ apps/sim/app/api/tools/trello/boards/route.ts | 47 +- apps/sim/blocks/blocks/crowdstrike.ts | 186 ++++++ apps/sim/blocks/blocks/shopify.ts | 260 ++++++++- apps/sim/blocks/blocks/trello.ts | 536 +++++++++++------- apps/sim/blocks/blocks/whatsapp.ts | 112 +++- apps/sim/blocks/registry.ts | 2 + apps/sim/components/icons.tsx | 11 + apps/sim/lib/oauth/utils.ts | 4 +- .../lib/webhooks/providers/whatsapp.test.ts | 201 +++++++ apps/sim/lib/webhooks/providers/whatsapp.ts | 301 +++++++++- apps/sim/tools/crowdstrike/index.ts | 5 + apps/sim/tools/crowdstrike/query_behaviors.ts | 130 +++++ .../sim/tools/crowdstrike/query_crowdscore.ts | 126 ++++ apps/sim/tools/crowdstrike/query_incidents.ts | 131 +++++ apps/sim/tools/crowdstrike/query_sensors.ts | 126 ++++ apps/sim/tools/crowdstrike/types.ts | 111 ++++ apps/sim/tools/registry.ts | 10 + apps/sim/tools/shopify/adjust_inventory.ts | 11 +- apps/sim/tools/shopify/cancel_order.ts | 35 +- apps/sim/tools/shopify/create_fulfillment.ts | 48 +- apps/sim/tools/shopify/create_product.ts | 6 +- apps/sim/tools/shopify/get_collection.ts | 46 +- apps/sim/tools/shopify/get_inventory_level.ts | 64 ++- apps/sim/tools/shopify/index.ts | 1 + apps/sim/tools/shopify/list_collections.ts | 38 +- .../sim/tools/shopify/list_inventory_items.ts | 42 +- apps/sim/tools/shopify/list_locations.ts | 35 +- apps/sim/tools/shopify/types.ts | 203 ++++++- apps/sim/tools/shopify/update_product.ts | 6 +- apps/sim/tools/trello/add_comment.ts | 153 ++++- apps/sim/tools/trello/create_card.ts | 133 ++++- apps/sim/tools/trello/get_actions.ts | 177 +++++- apps/sim/tools/trello/index.ts | 2 + apps/sim/tools/trello/list_cards.ts | 140 ++++- apps/sim/tools/trello/list_lists.ts | 80 ++- apps/sim/tools/trello/shared.ts | 236 ++++++++ apps/sim/tools/trello/types.ts | 72 ++- apps/sim/tools/trello/update_card.ts | 106 +++- apps/sim/tools/whatsapp/index.ts | 5 +- apps/sim/tools/whatsapp/send_message.ts | 121 +++- apps/sim/tools/whatsapp/types.ts | 11 + apps/sim/triggers/whatsapp/webhook.ts | 112 ++-- 56 files changed, 4121 insertions(+), 786 deletions(-) create mode 100644 apps/docs/content/docs/en/tools/crowdstrike.mdx create mode 100644 apps/sim/app/api/tools/crowdstrike/query/route.ts create mode 100644 apps/sim/blocks/blocks/crowdstrike.ts create mode 100644 apps/sim/lib/webhooks/providers/whatsapp.test.ts create mode 100644 apps/sim/tools/crowdstrike/index.ts create mode 100644 apps/sim/tools/crowdstrike/query_behaviors.ts create mode 100644 apps/sim/tools/crowdstrike/query_crowdscore.ts create mode 100644 apps/sim/tools/crowdstrike/query_incidents.ts create mode 100644 apps/sim/tools/crowdstrike/query_sensors.ts create mode 100644 apps/sim/tools/crowdstrike/types.ts create mode 100644 apps/sim/tools/trello/shared.ts diff --git a/apps/docs/components/icons.tsx b/apps/docs/components/icons.tsx index 0e4de0bf5e5..2f91eebc395 100644 --- a/apps/docs/components/icons.tsx +++ b/apps/docs/components/icons.tsx @@ -28,6 +28,17 @@ export function AgentMailIcon(props: SVGProps) { ) } +export function CrowdStrikeIcon(props: SVGProps) { + return ( + + + + ) +} + export function SearchIcon(props: SVGProps) { return ( = { cloudformation: CloudFormationIcon, cloudwatch: CloudWatchIcon, confluence_v2: ConfluenceIcon, + crowdstrike: CrowdStrikeIcon, cursor_v2: CursorIcon, dagster: DagsterIcon, databricks: DatabricksIcon, diff --git a/apps/docs/content/docs/en/tools/crowdstrike.mdx b/apps/docs/content/docs/en/tools/crowdstrike.mdx new file mode 100644 index 00000000000..6bbbded2308 --- /dev/null +++ b/apps/docs/content/docs/en/tools/crowdstrike.mdx @@ -0,0 +1,150 @@ +--- +title: CrowdStrike +description: Search Falcon sensors, CrowdScore, incidents, and behaviors +--- + +import { BlockInfoCard } from "@/components/ui/block-info-card" + + + +## Usage Instructions + +Integrate CrowdStrike Falcon into workflows to search identity sensors, environment CrowdScore, incidents, and behaviors using documented Falcon query endpoints. + + + +## Tools + +### `crowdstrike_query_behaviors` + +Search CrowdStrike behaviors by filter, sort order, and pagination + +#### Input + +| 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 behavior search | +| `limit` | number | No | Maximum number of behavior records to return | +| `offset` | number | No | Pagination offset for the behavior query | +| `sort` | string | No | Sort expression for behavior results | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `behaviors` | array | Matching CrowdStrike behavior records | +| ↳ `behaviorId` | string | Behavior identifier | +| ↳ `incidentId` | string | Parent incident identifier | +| ↳ `name` | string | Behavior name | +| ↳ `createdTimestamp` | string | Behavior creation timestamp | +| `count` | number | Number of behaviors returned | +| `pagination` | json | Pagination metadata \(offset, limit, total, expiresAt\) | +| ↳ `expiresAt` | number | Pagination cursor expiry timestamp | +| ↳ `limit` | number | Page size used for the query | +| ↳ `offset` | number | Offset returned by CrowdStrike | +| ↳ `total` | number | Total records available | + +### `crowdstrike_query_crowdscore` + +Retrieve environment-wide CrowdScore entities from CrowdStrike Falcon + +#### Input + +| 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 CrowdScore search | +| `limit` | number | No | Maximum number of CrowdScore records to return | +| `offset` | number | No | Pagination offset for CrowdScore results | +| `sort` | string | No | Sort expression for CrowdScore results | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `crowdScores` | array | CrowdStrike CrowdScore entities | +| ↳ `entityId` | string | Entity identifier | +| ↳ `entityType` | string | Entity type | +| ↳ `lastUpdated` | string | Last update timestamp | +| ↳ `score` | number | CrowdScore value | +| `count` | number | Number of CrowdScore records returned | +| `pagination` | json | Pagination metadata \(offset, limit, total, expiresAt\) | +| ↳ `expiresAt` | number | Pagination cursor expiry timestamp | +| ↳ `limit` | number | Page size used for the query | +| ↳ `offset` | number | Offset returned by CrowdStrike | +| ↳ `total` | number | Total records available | + +### `crowdstrike_query_incidents` + +Search CrowdStrike incidents by filter, sort order, and pagination + +#### Input + +| 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 incident search | +| `limit` | number | No | Maximum number of incident records to return | +| `offset` | number | No | Pagination offset for the incident query | +| `sort` | string | No | Sort expression for incident results | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `incidents` | array | Matching CrowdStrike incident records | +| ↳ `incidentId` | string | Incident identifier | +| ↳ `name` | string | Incident name | +| ↳ `createdTimestamp` | string | Incident creation timestamp | +| ↳ `status` | string | Incident status | +| ↳ `severity` | string | Incident severity | +| `count` | number | Number of incidents returned | +| `pagination` | json | Pagination metadata \(offset, limit, total, expiresAt\) | +| ↳ `expiresAt` | number | Pagination cursor expiry timestamp | +| ↳ `limit` | number | Page size used for the query | +| ↳ `offset` | number | Offset returned by CrowdStrike | +| ↳ `total` | number | Total records available | + +### `crowdstrike_query_sensors` + +Search CrowdStrike identity protection sensors by hostname, IP, or related fields + +#### Input + +| 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 | +| `sort` | string | No | Sort expression for identity sensor results | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `sensors` | array | Matching CrowdStrike identity sensor records | +| ↳ `agentId` | string | Sensor agent identifier | +| ↳ `hostname` | string | Sensor hostname | +| ↳ `ipAddress` | string | Sensor IP address | +| ↳ `macAddress` | string | Sensor MAC address | +| `count` | number | Number of sensors returned | +| `pagination` | json | Pagination metadata \(offset, limit, total, expiresAt\) | +| ↳ `expiresAt` | number | Pagination cursor expiry timestamp | +| ↳ `limit` | number | Page size used for the query | +| ↳ `offset` | number | Offset returned by CrowdStrike | +| ↳ `total` | number | Total records available | + + diff --git a/apps/docs/content/docs/en/tools/meta.json b/apps/docs/content/docs/en/tools/meta.json index 2a8e6ba0c89..dfc8894b490 100644 --- a/apps/docs/content/docs/en/tools/meta.json +++ b/apps/docs/content/docs/en/tools/meta.json @@ -27,6 +27,7 @@ "cloudformation", "cloudwatch", "confluence", + "crowdstrike", "cursor", "dagster", "databricks", diff --git a/apps/docs/content/docs/en/tools/shopify.mdx b/apps/docs/content/docs/en/tools/shopify.mdx index bbc0ea8cbc6..0502cf7ba5c 100644 --- a/apps/docs/content/docs/en/tools/shopify.mdx +++ b/apps/docs/content/docs/en/tools/shopify.mdx @@ -314,8 +314,8 @@ Cancel an order in your Shopify store | `orderId` | string | Yes | Order ID to cancel \(gid://shopify/Order/123456789\) | | `reason` | string | Yes | Cancellation reason \(CUSTOMER, DECLINED, FRAUD, INVENTORY, STAFF, OTHER\) | | `notifyCustomer` | boolean | No | Whether to notify the customer about the cancellation | -| `refund` | boolean | No | Whether to refund the order | -| `restock` | boolean | No | Whether to restock the inventory | +| `restock` | boolean | Yes | Whether to restock the inventory committed to the order | +| `refundMethod` | json | No | Optional refund method object, for example \{"originalPaymentMethodsRefund": true\} | | `staffNote` | string | No | A note about the cancellation for staff reference | #### Output diff --git a/apps/docs/content/docs/en/tools/trello.mdx b/apps/docs/content/docs/en/tools/trello.mdx index e4d377f6541..c7a6ad57098 100644 --- a/apps/docs/content/docs/en/tools/trello.mdx +++ b/apps/docs/content/docs/en/tools/trello.mdx @@ -1,6 +1,6 @@ --- title: Trello -description: Manage Trello boards and cards +description: Manage Trello lists, cards, and activity --- import { BlockInfoCard } from "@/components/ui/block-info-card" @@ -28,7 +28,15 @@ Integrating Trello with Sim empowers your agents to manage your team’s tasks, ## Usage Instructions -Integrate with Trello to manage boards and cards. List boards, list cards, create cards, update cards, get actions, and add comments. +{/* MANUAL-CONTENT-START:usage */} +### Trello OAuth Setup + +Before connecting Trello in Sim, add your Sim app origin to the **Allowed Origins** list for your Trello API key in the Trello Power-Up admin settings. + +Trello's authorization flow redirects back to Sim using a `return_url`. If your Sim origin is not whitelisted in Trello, Trello will block the redirect and the connection flow will fail before Sim can save the token. +{/* MANUAL-CONTENT-END */} + +Integrate with Trello to list board lists, list cards, create cards, update cards, review activity, and add comments. @@ -48,48 +56,82 @@ List all lists on a Trello board | Parameter | Type | Description | | --------- | ---- | ----------- | -| `lists` | array | Array of list objects with id, name, closed, pos, and idBoard | +| `lists` | array | Lists on the selected board | +| ↳ `id` | string | List ID | +| ↳ `name` | string | List name | +| ↳ `closed` | boolean | Whether the list is archived | +| ↳ `pos` | number | List position on the board | +| ↳ `idBoard` | string | Board ID containing the list | | `count` | number | Number of lists returned | ### `trello_list_cards` -List all cards on a Trello board +List cards from a Trello board or list #### Input | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `boardId` | string | Yes | Trello board ID \(24-character hex string\) | -| `listId` | string | No | Trello list ID to filter cards \(24-character hex string\) | +| `boardId` | string | No | Trello board ID to list open cards from. Provide either boardId or listId | +| `listId` | string | No | Trello list ID to list cards from. Provide either boardId or listId | #### Output | Parameter | Type | Description | | --------- | ---- | ----------- | -| `cards` | array | Array of card objects with id, name, desc, url, board/list IDs, labels, and due date | +| `cards` | array | Cards returned from the selected Trello board or list | +| ↳ `id` | string | Card ID | +| ↳ `name` | string | Card name | +| ↳ `desc` | string | Card description | +| ↳ `url` | string | Full card URL | +| ↳ `idBoard` | string | Board ID containing the card | +| ↳ `idList` | string | List ID containing the card | +| ↳ `closed` | boolean | Whether the card is archived | +| ↳ `labelIds` | array | Label IDs applied to the card | +| ↳ `labels` | array | Labels applied to the card | +| ↳ `id` | string | Label ID | +| ↳ `name` | string | Label name | +| ↳ `color` | string | Label color | +| ↳ `due` | string | Card due date in ISO 8601 format | +| ↳ `dueComplete` | boolean | Whether the due date is complete | | `count` | number | Number of cards returned | ### `trello_create_card` -Create a new card on a Trello board +Create a new card in a Trello list #### Input | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `boardId` | string | Yes | Trello board ID \(24-character hex string\) | | `listId` | string | Yes | Trello list ID \(24-character hex string\) | | `name` | string | Yes | Name/title of the card | | `desc` | string | No | Description of the card | | `pos` | string | No | Position of the card \(top, bottom, or positive float\) | | `due` | string | No | Due date \(ISO 8601 format\) | -| `labels` | string | No | Comma-separated list of label IDs \(24-character hex strings\) | +| `dueComplete` | boolean | No | Whether the due date should be marked complete | +| `labelIds` | array | No | Label IDs to attach to the card | +| `items` | string | No | A Trello label ID | #### Output | Parameter | Type | Description | | --------- | ---- | ----------- | -| `card` | object | The created card object with id, name, desc, url, and other properties | +| `card` | json | Created card \(id, name, desc, url, idBoard, idList, closed, labelIds, labels, due, dueComplete\) | +| ↳ `id` | string | Card ID | +| ↳ `name` | string | Card name | +| ↳ `desc` | string | Card description | +| ↳ `url` | string | Full card URL | +| ↳ `idBoard` | string | Board ID containing the card | +| ↳ `idList` | string | List ID containing the card | +| ↳ `closed` | boolean | Whether the card is archived | +| ↳ `labelIds` | array | Label IDs applied to the card | +| ↳ `labels` | array | Labels applied to the card | +| ↳ `id` | string | Label ID | +| ↳ `name` | string | Label name | +| ↳ `color` | string | Label color | +| ↳ `due` | string | Card due date in ISO 8601 format | +| ↳ `dueComplete` | boolean | Whether the due date is complete | ### `trello_update_card` @@ -111,7 +153,21 @@ Update an existing card on Trello | Parameter | Type | Description | | --------- | ---- | ----------- | -| `card` | object | The updated card object with id, name, desc, url, and other properties | +| `card` | json | Updated card \(id, name, desc, url, idBoard, idList, closed, labelIds, labels, due, dueComplete\) | +| ↳ `id` | string | Card ID | +| ↳ `name` | string | Card name | +| ↳ `desc` | string | Card description | +| ↳ `url` | string | Full card URL | +| ↳ `idBoard` | string | Board ID containing the card | +| ↳ `idList` | string | List ID containing the card | +| ↳ `closed` | boolean | Whether the card is archived | +| ↳ `labelIds` | array | Label IDs applied to the card | +| ↳ `labels` | array | Labels applied to the card | +| ↳ `id` | string | Label ID | +| ↳ `name` | string | Label name | +| ↳ `color` | string | Label color | +| ↳ `due` | string | Card due date in ISO 8601 format | +| ↳ `dueComplete` | boolean | Whether the due date is complete | ### `trello_get_actions` @@ -124,13 +180,36 @@ Get activity/actions from a board or card | `boardId` | string | No | Trello board ID \(24-character hex string\). Either boardId or cardId required | | `cardId` | string | No | Trello card ID \(24-character hex string\). Either boardId or cardId required | | `filter` | string | No | Filter actions by type \(e.g., "commentCard,updateCard,createCard" or "all"\) | -| `limit` | number | No | Maximum number of actions to return \(default: 50, max: 1000\) | +| `limit` | number | No | Maximum number of board actions to return | +| `page` | number | No | Page number for action results | #### Output | Parameter | Type | Description | | --------- | ---- | ----------- | -| `actions` | array | Array of action objects with type, date, member, and data | +| `actions` | array | Action items \(id, type, date, idMemberCreator, text, memberCreator, card, board, list\) | +| ↳ `id` | string | Action ID | +| ↳ `type` | string | Action type | +| ↳ `date` | string | Action timestamp | +| ↳ `idMemberCreator` | string | ID of the member who created the action | +| ↳ `text` | string | Comment text when present | +| ↳ `memberCreator` | object | Member who created the action | +| ↳ `id` | string | Member ID | +| ↳ `fullName` | string | Member full name | +| ↳ `username` | string | Member username | +| ↳ `card` | object | Card referenced by the action | +| ↳ `id` | string | Card ID | +| ↳ `name` | string | Card name | +| ↳ `shortLink` | string | Short card link | +| ↳ `idShort` | number | Board-local card number | +| ↳ `due` | string | Card due date | +| ↳ `board` | object | Board referenced by the action | +| ↳ `id` | string | Board ID | +| ↳ `name` | string | Board name | +| ↳ `shortLink` | string | Short board link | +| ↳ `list` | object | List referenced by the action | +| ↳ `id` | string | List ID | +| ↳ `name` | string | List name | | `count` | number | Number of actions returned | ### `trello_add_comment` @@ -148,6 +227,28 @@ Add a comment to a Trello card | Parameter | Type | Description | | --------- | ---- | ----------- | -| `comment` | object | The created comment object with id, text, date, and member creator | +| `comment` | json | Created comment action \(id, type, date, idMemberCreator, text, memberCreator, card, board, list\) | +| ↳ `id` | string | Action ID | +| ↳ `type` | string | Action type | +| ↳ `date` | string | Action timestamp | +| ↳ `idMemberCreator` | string | ID of the member who created the comment | +| ↳ `text` | string | Comment text | +| ↳ `memberCreator` | object | Member who created the comment | +| ↳ `id` | string | Member ID | +| ↳ `fullName` | string | Member full name | +| ↳ `username` | string | Member username | +| ↳ `card` | object | Card referenced by the comment | +| ↳ `id` | string | Card ID | +| ↳ `name` | string | Card name | +| ↳ `shortLink` | string | Short card link | +| ↳ `idShort` | number | Board-local card number | +| ↳ `due` | string | Card due date | +| ↳ `board` | object | Board referenced by the comment | +| ↳ `id` | string | Board ID | +| ↳ `name` | string | Board name | +| ↳ `shortLink` | string | Short board link | +| ↳ `list` | object | List referenced by the comment | +| ↳ `id` | string | List ID | +| ↳ `name` | string | List name | diff --git a/apps/docs/content/docs/en/tools/whatsapp.mdx b/apps/docs/content/docs/en/tools/whatsapp.mdx index 959cd540b05..be8015ba50e 100644 --- a/apps/docs/content/docs/en/tools/whatsapp.mdx +++ b/apps/docs/content/docs/en/tools/whatsapp.mdx @@ -34,15 +34,16 @@ Integrate WhatsApp into the workflow. Can send messages. ### `whatsapp_send_message` -Send WhatsApp messages +Send a text message through the WhatsApp Cloud API. #### Input | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `phoneNumber` | string | Yes | Recipient phone number with country code \(e.g., +14155552671\) | -| `message` | string | Yes | Message content to send \(plain text or template content\) | +| `message` | string | Yes | Plain text message content to send | | `phoneNumberId` | string | Yes | WhatsApp Business Phone Number ID \(from Meta Business Suite\) | +| `previewUrl` | boolean | No | Whether WhatsApp should try to render a link preview for the first URL in the message | #### Output @@ -50,8 +51,12 @@ Send WhatsApp messages | --------- | ---- | ----------- | | `success` | boolean | WhatsApp message send success status | | `messageId` | string | Unique WhatsApp message identifier | -| `phoneNumber` | string | Recipient phone number | -| `status` | string | Message delivery status | -| `timestamp` | string | Message send timestamp | +| `messageStatus` | string | Initial delivery state returned by the API | +| `messagingProduct` | string | Messaging product returned by the API | +| `inputPhoneNumber` | string | Recipient phone number echoed back by WhatsApp | +| `whatsappUserId` | string | WhatsApp user ID resolved for the recipient | +| `contacts` | array | Recipient contact records returned by WhatsApp | +| ↳ `input` | string | Input phone number sent to the API | +| ↳ `wa_id` | string | WhatsApp user ID associated with the recipient | diff --git a/apps/sim/app/(landing)/integrations/data/icon-mapping.ts b/apps/sim/app/(landing)/integrations/data/icon-mapping.ts index ddf28d4d7a6..a29451dcdd0 100644 --- a/apps/sim/app/(landing)/integrations/data/icon-mapping.ts +++ b/apps/sim/app/(landing)/integrations/data/icon-mapping.ts @@ -32,6 +32,7 @@ import { CloudflareIcon, CloudWatchIcon, ConfluenceIcon, + CrowdStrikeIcon, CursorIcon, DagsterIcon, DatabricksIcon, @@ -220,6 +221,7 @@ export const blockTypeToIconMap: Record = { cloudformation: CloudFormationIcon, cloudwatch: CloudWatchIcon, confluence_v2: ConfluenceIcon, + crowdstrike: CrowdStrikeIcon, cursor_v2: CursorIcon, dagster: DagsterIcon, databricks: DatabricksIcon, diff --git a/apps/sim/app/(landing)/integrations/data/integrations.json b/apps/sim/app/(landing)/integrations/data/integrations.json index 1c4a6845c18..306f55aaa8d 100644 --- a/apps/sim/app/(landing)/integrations/data/integrations.json +++ b/apps/sim/app/(landing)/integrations/data/integrations.json @@ -2345,6 +2345,41 @@ "integrationTypes": ["documents", "productivity", "search"], "tags": ["knowledge-base", "content-management", "note-taking"] }, + { + "type": "crowdstrike", + "slug": "crowdstrike", + "name": "CrowdStrike", + "description": "Search Falcon sensors, CrowdScore, incidents, and behaviors", + "longDescription": "Integrate CrowdStrike Falcon into workflows to search identity sensors, environment CrowdScore, incidents, and behaviors using documented Falcon query endpoints.", + "bgColor": "#E01F3D", + "iconName": "CrowdStrikeIcon", + "docsUrl": "https://docs.sim.ai/tools/crowdstrike", + "operations": [ + { + "name": "Search Identity Sensors", + "description": "Search CrowdStrike identity protection sensors by hostname, IP, or related fields" + }, + { + "name": "Get CrowdScore", + "description": "Retrieve environment-wide CrowdScore entities from CrowdStrike Falcon" + }, + { + "name": "Search Incidents", + "description": "Search CrowdStrike incidents by filter, sort order, and pagination" + }, + { + "name": "Search Behaviors", + "description": "Search CrowdStrike behaviors by filter, sort order, and pagination" + } + ], + "operationCount": 4, + "triggers": [], + "triggerCount": 0, + "authType": "none", + "category": "tools", + "integrationTypes": ["security", "analytics", "developer-tools"], + "tags": ["monitoring", "incident-management"] + }, { "type": "cursor_v2", "slug": "cursor", @@ -11963,8 +11998,8 @@ "type": "trello", "slug": "trello", "name": "Trello", - "description": "Manage Trello boards and cards", - "longDescription": "Integrate with Trello to manage boards and cards. List boards, list cards, create cards, update cards, get actions, and add comments.", + "description": "Manage Trello lists, cards, and activity", + "longDescription": "Integrate with Trello to list board lists, list cards, create cards, update cards, review activity, and add comments.", "bgColor": "#0052CC", "iconName": "TrelloIcon", "docsUrl": "https://docs.sim.ai/tools/trello", @@ -11975,11 +12010,11 @@ }, { "name": "List Cards", - "description": "List all cards on a Trello board" + "description": "List cards from a Trello board or list" }, { "name": "Create Card", - "description": "Create a new card on a Trello board" + "description": "Create a new card in a Trello list" }, { "name": "Update Card", @@ -12611,7 +12646,7 @@ { "id": "whatsapp_webhook", "name": "WhatsApp Webhook", - "description": "Trigger workflow from WhatsApp messages and events via Business Platform webhooks" + "description": "Trigger workflow from WhatsApp incoming messages and message status webhooks" } ], "triggerCount": 1, diff --git a/apps/sim/app/api/auth/shopify/authorize/route.ts b/apps/sim/app/api/auth/shopify/authorize/route.ts index 0607f7c8cfb..ed5a58cb3ce 100644 --- a/apps/sim/app/api/auth/shopify/authorize/route.ts +++ b/apps/sim/app/api/auth/shopify/authorize/route.ts @@ -4,19 +4,13 @@ import { getSession } from '@/lib/auth' import { env } from '@/lib/core/config/env' import { getBaseUrl } from '@/lib/core/utils/urls' import { generateId } from '@/lib/core/utils/uuid' +import { getScopesForService } from '@/lib/oauth/utils' const logger = createLogger('ShopifyAuthorize') export const dynamic = 'force-dynamic' -const SHOPIFY_SCOPES = [ - 'write_products', - 'write_orders', - 'write_customers', - 'write_inventory', - 'read_locations', - 'write_merchant_managed_fulfillment_orders', -].join(',') +const SHOPIFY_SCOPES = getScopesForService('shopify').join(',') export async function GET(request: NextRequest) { try { diff --git a/apps/sim/app/api/auth/trello/authorize/route.ts b/apps/sim/app/api/auth/trello/authorize/route.ts index d5e23abf03a..e1945b1febf 100644 --- a/apps/sim/app/api/auth/trello/authorize/route.ts +++ b/apps/sim/app/api/auth/trello/authorize/route.ts @@ -1,14 +1,15 @@ import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' +import { NextResponse } from 'next/server' import { getSession } from '@/lib/auth' import { env } from '@/lib/core/config/env' import { getBaseUrl } from '@/lib/core/utils/urls' +import { getCanonicalScopesForProvider } from '@/lib/oauth/utils' const logger = createLogger('TrelloAuthorize') export const dynamic = 'force-dynamic' -export async function GET(request: NextRequest) { +export async function GET() { try { const session = await getSession() if (!session?.user?.id) { @@ -24,13 +25,15 @@ export async function GET(request: NextRequest) { const baseUrl = getBaseUrl() const returnUrl = `${baseUrl}/api/auth/trello/callback` + const scope = getCanonicalScopesForProvider('trello').join(',') const authUrl = new URL('https://trello.com/1/authorize') authUrl.searchParams.set('key', apiKey) authUrl.searchParams.set('name', 'Sim Studio') authUrl.searchParams.set('expiration', 'never') + authUrl.searchParams.set('callback_method', 'fragment') authUrl.searchParams.set('response_type', 'token') - authUrl.searchParams.set('scope', 'read,write') + authUrl.searchParams.set('scope', scope) authUrl.searchParams.set('return_url', returnUrl) return NextResponse.redirect(authUrl.toString()) diff --git a/apps/sim/app/api/auth/trello/callback/route.ts b/apps/sim/app/api/auth/trello/callback/route.ts index 2aa76dc8ad6..a70da8fadc7 100644 --- a/apps/sim/app/api/auth/trello/callback/route.ts +++ b/apps/sim/app/api/auth/trello/callback/route.ts @@ -1,9 +1,9 @@ -import { type NextRequest, NextResponse } from 'next/server' +import { NextResponse } from 'next/server' import { getBaseUrl } from '@/lib/core/utils/urls' export const dynamic = 'force-dynamic' -export async function GET(request: NextRequest) { +export async function GET() { const baseUrl = getBaseUrl() return new NextResponse( @@ -75,6 +75,11 @@ export async function GET(request: NextRequest) { const fragment = window.location.hash.substring(1); const params = new URLSearchParams(fragment); const token = params.get('token'); + const authError = params.get('error'); + + if (authError) { + throw new Error(authError); + } if (!token) { throw new Error('No token received from Trello'); diff --git a/apps/sim/app/api/auth/trello/store/route.ts b/apps/sim/app/api/auth/trello/store/route.ts index 47e59766a4b..8e2f96aa8ba 100644 --- a/apps/sim/app/api/auth/trello/store/route.ts +++ b/apps/sim/app/api/auth/trello/store/route.ts @@ -6,6 +6,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' import { env } from '@/lib/core/config/env' import { processCredentialDraft } from '@/lib/credentials/draft-processor' +import { getCanonicalScopesForProvider } from '@/lib/oauth/utils' import { safeAccountInsert } from '@/app/api/auth/oauth/utils' const logger = createLogger('TrelloStore') @@ -20,8 +21,8 @@ export async function POST(request: NextRequest) { return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 }) } - const body = await request.json() - const { token } = body + const body = (await request.json().catch(() => null)) as { token?: string } | null + const token = typeof body?.token === 'string' ? body.token : '' if (!token) { return NextResponse.json({ success: false, error: 'Token required' }, { status: 400 }) @@ -33,7 +34,9 @@ export async function POST(request: NextRequest) { return NextResponse.json({ success: false, error: 'Trello not configured' }, { status: 500 }) } - const validationUrl = `https://api.trello.com/1/members/me?key=${apiKey}&token=${token}&fields=id,username,fullName,email` + const scope = getCanonicalScopesForProvider('trello').join(',') + + const validationUrl = `https://api.trello.com/1/members/me?key=${apiKey}&token=${token}&fields=id,username,fullName` const userResponse = await fetch(validationUrl, { headers: { Accept: 'application/json' }, }) @@ -50,7 +53,17 @@ export async function POST(request: NextRequest) { ) } - const trelloUser = await userResponse.json() + const trelloUser = (await userResponse.json().catch(() => null)) as { id?: string } | null + + if (typeof trelloUser?.id !== 'string' || trelloUser.id.trim().length === 0) { + logger.error('Trello validation response did not include a valid member id', { + response: trelloUser, + }) + return NextResponse.json( + { success: false, error: 'Invalid Trello member response' }, + { status: 502 } + ) + } const existing = await db.query.account.findFirst({ where: and( @@ -68,7 +81,7 @@ export async function POST(request: NextRequest) { .set({ accessToken: token, accountId: trelloUser.id, - scope: 'read,write', + scope, updatedAt: now, }) .where(eq(account.id, existing.id)) @@ -80,7 +93,7 @@ export async function POST(request: NextRequest) { providerId: 'trello', accountId: trelloUser.id, accessToken: token, - scope: 'read,write', + scope, createdAt: now, updatedAt: now, }, diff --git a/apps/sim/app/api/tools/crowdstrike/query/route.ts b/apps/sim/app/api/tools/crowdstrike/query/route.ts new file mode 100644 index 00000000000..2ac022c77a9 --- /dev/null +++ b/apps/sim/app/api/tools/crowdstrike/query/route.ts @@ -0,0 +1,311 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { z } from 'zod' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { generateId } from '@/lib/core/utils/uuid' + +const logger = createLogger('CrowdStrikeQueryAPI') + +const CROWDSTRIKE_CLOUDS = ['us-1', 'us-2', 'eu-1', 'us-gov-1', 'us-gov-2'] as const +const CROWDSTRIKE_OPERATIONS = [ + 'crowdstrike_query_behaviors', + 'crowdstrike_query_crowdscore', + 'crowdstrike_query_incidents', + 'crowdstrike_query_sensors', +] as const + +const QuerySchema = z.object({ + operation: z.enum(CROWDSTRIKE_OPERATIONS), + clientId: z.string().min(1, 'Client ID is required'), + clientSecret: z.string().min(1, 'Client Secret is required'), + cloud: z.enum(CROWDSTRIKE_CLOUDS), + filter: z.string().nullish(), + limit: z.number().int().positive().nullish(), + offset: z.number().int().nonnegative().nullish(), + sort: z.string().nullish(), +}) + +type QueryRequest = z.infer +type JsonRecord = Record + +function getCloudBaseUrl(cloud: QueryRequest['cloud']): string { + const cloudMap: Record = { + 'eu-1': 'https://api.eu-1.crowdstrike.com', + 'us-1': 'https://api.crowdstrike.com', + 'us-2': 'https://api.us-2.crowdstrike.com', + 'us-gov-1': 'https://api.laggar.gcw.crowdstrike.com', + 'us-gov-2': 'https://api.us-gov-2.crowdstrike.mil', + } + + return cloudMap[cloud] +} + +function isJsonRecord(value: unknown): value is JsonRecord { + return typeof value === 'object' && value !== null +} + +function getString(value: unknown): string | null { + return typeof value === 'string' ? value : null +} + +function getNumber(value: unknown): number | null { + return typeof value === 'number' ? value : null +} + +function getArray(value: unknown): JsonRecord[] { + if (!Array.isArray(value)) { + return [] + } + + return value.filter(isJsonRecord) +} + +function getResources(data: unknown): JsonRecord[] { + const root = getResponseRoot(data) + if (!isJsonRecord(root)) { + return [] + } + + return getArray(root.resources) +} + +function getResponseRoot(data: unknown): unknown { + if (!isJsonRecord(data)) { + return null + } + + if (isJsonRecord(data.body)) { + return data.body + } + + return data +} + +function getPagination(data: unknown) { + const root = getResponseRoot(data) + if (!isJsonRecord(root) || !isJsonRecord(root.meta) || !isJsonRecord(root.meta.pagination)) { + return null + } + + return { + expiresAt: getNumber(root.meta.pagination.expires_at), + limit: getNumber(root.meta.pagination.limit), + offset: + getNumber(root.meta.pagination.offset) ?? getString(root.meta.pagination.offset) ?? null, + total: getNumber(root.meta.pagination.total), + } +} + +function getErrorMessage(data: unknown, fallback: string): string { + if (!isJsonRecord(data)) { + return fallback + } + + const errors = Array.isArray(data.errors) ? data.errors : [] + const firstError = errors[0] + if (isJsonRecord(firstError)) { + const firstMessage = getString(firstError.message) ?? getString(firstError.code) + if (firstMessage) { + return firstMessage + } + } + + return ( + getString(data.message) ?? + getString(data.error_description) ?? + getString(data.error) ?? + fallback + ) +} + +function buildOperationUrl(baseUrl: string, params: QueryRequest): string { + const url = new URL(baseUrl) + + switch (params.operation) { + case 'crowdstrike_query_sensors': + url.pathname = '/identity-protection/queries/devices/v1' + if (params.filter) url.searchParams.set('filter', params.filter) + if (params.limit != null) url.searchParams.set('limit', params.limit.toString()) + if (params.offset != null) url.searchParams.set('offset', params.offset.toString()) + if (params.sort) url.searchParams.set('sort', params.sort) + return url.toString() + case 'crowdstrike_query_crowdscore': + url.pathname = '/incidents/combined/crowdscores/v1' + if (params.filter) url.searchParams.set('filter', params.filter) + if (params.limit != null) url.searchParams.set('limit', params.limit.toString()) + if (params.offset != null) url.searchParams.set('offset', params.offset.toString()) + if (params.sort) url.searchParams.set('sort', params.sort) + return url.toString() + case 'crowdstrike_query_incidents': + url.pathname = '/incidents/queries/incidents/v1' + if (params.filter) url.searchParams.set('filter', params.filter) + if (params.limit != null) url.searchParams.set('limit', params.limit.toString()) + if (params.offset != null) url.searchParams.set('offset', params.offset.toString()) + if (params.sort) url.searchParams.set('sort', params.sort) + return url.toString() + case 'crowdstrike_query_behaviors': + url.pathname = '/incidents/queries/behaviors/v1' + if (params.filter) url.searchParams.set('filter', params.filter) + if (params.limit != null) url.searchParams.set('limit', params.limit.toString()) + if (params.offset != null) url.searchParams.set('offset', params.offset.toString()) + if (params.sort) url.searchParams.set('sort', params.sort) + return url.toString() + } + + throw new Error(`Unsupported CrowdStrike operation: ${params.operation}`) +} + +async function getAccessToken(params: QueryRequest): Promise { + const baseUrl = getCloudBaseUrl(params.cloud) + const response = await fetch(`${baseUrl}/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(), + cache: 'no-store', + }) + + const data: unknown = await response.json().catch(() => null) + if (!response.ok) { + throw new Error(getErrorMessage(data, 'Failed to authenticate with CrowdStrike')) + } + + if (!isJsonRecord(data) || typeof data.access_token !== 'string') { + throw new Error('CrowdStrike authentication did not return an access token') + } + + return data.access_token +} + +function normalizeOutput(operation: QueryRequest['operation'], data: unknown) { + const pagination = getPagination(data) + + switch (operation) { + case 'crowdstrike_query_sensors': { + const sensors = getResources(data).map((resource) => ({ + agentId: getString(resource.agent_id), + hostname: getString(resource.hostname), + ipAddress: getString(resource.ip_address), + macAddress: getString(resource.mac_address), + })) + + return { + count: sensors.length, + pagination, + sensors, + } + } + case 'crowdstrike_query_crowdscore': { + const crowdScores = getResources(data).map((resource) => ({ + entityId: getString(resource.entity_uuid) ?? getString(resource.entity_id), + entityType: getString(resource.entity_type), + lastUpdated: getString(resource.last_updated), + score: getNumber(resource.score) ?? getNumber(resource.crowdscore), + })) + + return { + count: crowdScores.length, + crowdScores, + pagination, + } + } + case 'crowdstrike_query_incidents': { + const incidents = getResources(data).map((resource) => ({ + createdTimestamp: getString(resource.created_timestamp), + incidentId: getString(resource.incident_id), + name: getString(resource.name), + severity: getString(resource.severity), + status: getString(resource.status), + })) + + return { + count: incidents.length, + incidents, + pagination, + } + } + case 'crowdstrike_query_behaviors': { + const behaviors = getResources(data).map((resource) => ({ + behaviorId: getString(resource.behavior_id), + createdTimestamp: getString(resource.created_timestamp), + incidentId: getString(resource.incident_id), + name: getString(resource.name), + })) + + return { + behaviors, + count: behaviors.length, + pagination, + } + } + } + + throw new Error(`Unsupported CrowdStrike operation: ${operation}`) +} + +export async function POST(request: NextRequest) { + const requestId = generateId().slice(0, 8) + + const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success) { + return NextResponse.json( + { success: false, error: authResult.error || 'Unauthorized' }, + { status: 401 } + ) + } + + try { + const rawBody: unknown = await request.json() + const params = QuerySchema.parse(rawBody) + const baseUrl = getCloudBaseUrl(params.cloud) + const accessToken = await getAccessToken(params) + const apiUrl = buildOperationUrl(baseUrl, params) + + logger.info(`[${requestId}] CrowdStrike query`, { + cloud: params.cloud, + operation: params.operation, + }) + + const response = await fetch(apiUrl, { + method: 'GET', + headers: { + Accept: 'application/json', + Authorization: `Bearer ${accessToken}`, + }, + cache: 'no-store', + }) + + const data: unknown = await response.json().catch(() => null) + if (!response.ok) { + return NextResponse.json( + { + success: false, + error: getErrorMessage(data, 'CrowdStrike request failed'), + }, + { status: response.status } + ) + } + + return NextResponse.json({ + success: true, + output: normalizeOutput(params.operation, data), + }) + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json( + { success: false, error: 'Invalid request data', details: error.errors }, + { status: 400 } + ) + } + + const message = error instanceof Error ? error.message : 'Unknown error' + logger.error(`[${requestId}] CrowdStrike query failed`, { error: message }) + return NextResponse.json({ success: false, error: message }, { status: 500 }) + } +} diff --git a/apps/sim/app/api/tools/trello/boards/route.ts b/apps/sim/app/api/tools/trello/boards/route.ts index fb4ca52738a..3b3851285a6 100644 --- a/apps/sim/app/api/tools/trello/boards/route.ts +++ b/apps/sim/app/api/tools/trello/boards/route.ts @@ -1,5 +1,5 @@ import { createLogger } from '@sim/logger' -import { NextResponse } from 'next/server' +import { type NextRequest, NextResponse } from 'next/server' import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { generateRequestId } from '@/lib/core/utils/request' import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' @@ -8,7 +8,7 @@ const logger = createLogger('TrelloBoardsAPI') export const dynamic = 'force-dynamic' -export async function POST(request: Request) { +export async function POST(request: NextRequest) { const requestId = generateRequestId() try { const apiKey = process.env.TRELLO_API_KEY @@ -16,15 +16,19 @@ export async function POST(request: Request) { logger.error('Trello API key not configured') return NextResponse.json({ error: 'Trello API key not configured' }, { status: 500 }) } - const body = await request.json() - const { credential, workflowId } = body + const body = (await request.json().catch(() => null)) as { + credential?: string + workflowId?: string + } | null + const credential = typeof body?.credential === 'string' ? body.credential : '' + const workflowId = typeof body?.workflowId === 'string' ? body.workflowId : undefined if (!credential) { logger.error('Missing credential in request') return NextResponse.json({ error: 'Credential is required' }, { status: 400 }) } - const authz = await authorizeCredentialUse(request as any, { + const authz = await authorizeCredentialUse(request, { credentialId: credential, workflowId, }) @@ -58,7 +62,7 @@ export async function POST(request: Request) { ) if (!response.ok) { - const errorData = await response.json().catch(() => ({})) + const errorData = await response.json().catch(() => null) logger.error('Failed to fetch Trello boards', { status: response.status, error: errorData, @@ -69,12 +73,31 @@ export async function POST(request: Request) { ) } - const data = await response.json() - const boards = (data || []).map((board: { id: string; name: string; closed: boolean }) => ({ - id: board.id, - name: board.name, - closed: board.closed, - })) + const data = (await response.json().catch(() => null)) as unknown + + if (!Array.isArray(data)) { + logger.error('Trello returned an invalid board collection', { data }) + return NextResponse.json({ error: 'Invalid Trello board response' }, { status: 502 }) + } + + const boards = data.flatMap((board) => { + if (typeof board !== 'object' || board === null) { + return [] + } + + const record = board as Record + if (typeof record.id !== 'string' || typeof record.name !== 'string') { + return [] + } + + return [ + { + id: record.id, + name: record.name, + closed: typeof record.closed === 'boolean' ? record.closed : false, + }, + ] + }) return NextResponse.json({ boards }) } catch (error) { diff --git a/apps/sim/blocks/blocks/crowdstrike.ts b/apps/sim/blocks/blocks/crowdstrike.ts new file mode 100644 index 00000000000..2791eab051e --- /dev/null +++ b/apps/sim/blocks/blocks/crowdstrike.ts @@ -0,0 +1,186 @@ +import { CrowdStrikeIcon } from '@/components/icons' +import type { BlockConfig } from '@/blocks/types' +import { AuthMode, IntegrationType } from '@/blocks/types' +import type { CrowdStrikeResponse } from '@/tools/crowdstrike/types' + +const FILTER_OPERATIONS = [ + 'crowdstrike_query_sensors', + 'crowdstrike_query_crowdscore', + 'crowdstrike_query_incidents', + 'crowdstrike_query_behaviors', +] +const LIMIT_OFFSET_OPERATIONS = [ + 'crowdstrike_query_behaviors', + 'crowdstrike_query_crowdscore', + 'crowdstrike_query_incidents', + 'crowdstrike_query_sensors', +] +const SORT_OPERATIONS = [ + 'crowdstrike_query_behaviors', + 'crowdstrike_query_crowdscore', + 'crowdstrike_query_incidents', + 'crowdstrike_query_sensors', +] + +export const CrowdStrikeBlock: BlockConfig = { + type: 'crowdstrike', + name: 'CrowdStrike', + description: 'Search Falcon sensors, CrowdScore, incidents, and behaviors', + longDescription: + 'Integrate CrowdStrike Falcon into workflows to search identity sensors, environment CrowdScore, incidents, and behaviors using documented Falcon query endpoints.', + docsLink: 'https://docs.sim.ai/tools/crowdstrike', + category: 'tools', + integrationType: IntegrationType.Security, + tags: ['monitoring', 'incident-management'], + bgColor: '#E01F3D', + icon: CrowdStrikeIcon, + authMode: AuthMode.ApiKey, + + subBlocks: [ + { + id: 'operation', + title: 'Operation', + type: 'dropdown', + options: [ + { label: 'Search Identity Sensors', id: 'crowdstrike_query_sensors' }, + { label: 'Get CrowdScore', id: 'crowdstrike_query_crowdscore' }, + { label: 'Search Incidents', id: 'crowdstrike_query_incidents' }, + { label: 'Search Behaviors', id: 'crowdstrike_query_behaviors' }, + ], + value: () => 'crowdstrike_query_sensors', + }, + { + id: 'clientId', + title: 'Client ID', + type: 'short-input', + placeholder: 'CrowdStrike Falcon API client ID', + required: true, + }, + { + id: 'clientSecret', + title: 'Client Secret', + type: 'short-input', + password: true, + placeholder: 'CrowdStrike Falcon API client secret', + required: true, + }, + { + id: 'cloud', + title: 'Cloud Region', + type: 'dropdown', + options: [ + { label: 'US-1', id: 'us-1' }, + { label: 'US-2', id: 'us-2' }, + { label: 'EU-1', id: 'eu-1' }, + { label: 'US-GOV-1', id: 'us-gov-1' }, + { label: 'US-GOV-2', id: 'us-gov-2' }, + ], + value: () => 'us-1', + required: true, + }, + { + id: 'filter', + title: 'Filter', + type: 'short-input', + placeholder: 'hostname:"server-01" or status:30', + condition: { + field: 'operation', + value: FILTER_OPERATIONS, + }, + wandConfig: { + enabled: true, + prompt: + 'Generate a CrowdStrike Falcon Query Language filter string for the selected operation. Use exact field names, operators, and values only. Return ONLY the filter string - no explanations, no extra text.', + placeholder: + 'Describe the CrowdStrike records you want to filter, for example "sensors with hostnames starting with web" or "incidents in progress"...', + }, + }, + { + id: 'limit', + title: 'Limit', + type: 'short-input', + placeholder: '100', + condition: { field: 'operation', value: LIMIT_OFFSET_OPERATIONS }, + mode: 'advanced', + }, + { + id: 'offset', + title: 'Offset', + type: 'short-input', + placeholder: '0', + condition: { field: 'operation', value: LIMIT_OFFSET_OPERATIONS }, + mode: 'advanced', + }, + { + id: 'sort', + title: 'Sort', + type: 'short-input', + placeholder: 'created_timestamp.asc', + condition: { field: 'operation', value: SORT_OPERATIONS }, + mode: 'advanced', + }, + ], + + tools: { + access: [ + 'crowdstrike_query_behaviors', + 'crowdstrike_query_crowdscore', + 'crowdstrike_query_incidents', + 'crowdstrike_query_sensors', + ], + config: { + tool: (params) => params.operation, + params: (params) => { + const mapped: Record = { + clientId: params.clientId, + clientSecret: params.clientSecret, + cloud: params.cloud, + } + + if (params.filter) mapped.filter = params.filter + if (params.limit != null && params.limit !== '') mapped.limit = Number(params.limit) + if (params.offset != null && params.offset !== '') mapped.offset = Number(params.offset) + if (params.sort) mapped.sort = params.sort + + return mapped + }, + }, + }, + + inputs: { + clientId: { type: 'string', description: 'CrowdStrike Falcon API client ID' }, + clientSecret: { type: 'string', description: 'CrowdStrike Falcon API client secret' }, + cloud: { type: 'string', description: 'CrowdStrike Falcon cloud region' }, + filter: { type: 'string', description: 'Falcon Query Language filter' }, + limit: { type: 'string', description: 'Maximum number of records to return' }, + offset: { type: 'string', description: 'Pagination offset' }, + sort: { type: 'string', description: 'Sort expression' }, + }, + + outputs: { + sensors: { + type: 'json', + description: + 'CrowdStrike identity sensor summaries (agentId, hostname, ipAddress, macAddress)', + }, + crowdScores: { + type: 'json', + description: 'CrowdStrike CrowdScore entities (entityId, entityType, score, lastUpdated)', + }, + incidents: { + type: 'json', + description: + 'CrowdStrike incident summaries (incidentId, name, createdTimestamp, status, severity)', + }, + behaviors: { + type: 'json', + description: + 'CrowdStrike behavior summaries (behaviorId, incidentId, name, createdTimestamp)', + }, + pagination: { + type: 'json', + description: 'Pagination metadata (offset, limit, total, expiresAt) for paginated responses', + }, + count: { type: 'number', description: 'Number of records returned by the selected operation' }, + }, +} diff --git a/apps/sim/blocks/blocks/shopify.ts b/apps/sim/blocks/blocks/shopify.ts index d21d414c8c5..857b7fa72ed 100644 --- a/apps/sim/blocks/blocks/shopify.ts +++ b/apps/sim/blocks/blocks/shopify.ts @@ -9,6 +9,41 @@ interface ShopifyResponse { output: Record } +const LIST_OPERATIONS = [ + 'shopify_list_products', + 'shopify_list_orders', + 'shopify_list_customers', + 'shopify_list_inventory_items', + 'shopify_list_locations', + 'shopify_list_collections', +] as const + +function parseOptionalNumber(value: unknown): number | undefined { + if (typeof value === 'number' && Number.isFinite(value)) { + return value + } + + if (typeof value === 'string' && value.trim()) { + const parsed = Number(value) + return Number.isFinite(parsed) ? parsed : undefined + } + + return undefined +} + +function parseBooleanInput(value: unknown): boolean | undefined { + if (typeof value === 'boolean') { + return value + } + + if (typeof value === 'string') { + if (value === 'true') return true + if (value === 'false') return false + } + + return undefined +} + export const ShopifyBlock: BlockConfig = { type: 'shopify', name: 'Shopify', @@ -84,7 +119,7 @@ export const ShopifyBlock: BlockConfig = { title: 'Shop Domain', type: 'short-input', placeholder: 'Auto-detected from OAuth or enter manually', - hidden: true, // Auto-detected from OAuth credential's idToken field + hidden: true, }, // Product ID (for get/update/delete operations) { @@ -179,6 +214,7 @@ export const ShopifyBlock: BlockConfig = { title: 'Search Query', type: 'short-input', placeholder: 'Filter products (optional)', + mode: 'advanced', condition: { field: 'operation', value: ['shopify_list_products'], @@ -190,6 +226,7 @@ export const ShopifyBlock: BlockConfig = { title: 'Search Query', type: 'short-input', placeholder: 'e.g., first_name:John OR email:*@gmail.com', + mode: 'advanced', condition: { field: 'operation', value: ['shopify_list_customers'], @@ -201,11 +238,23 @@ export const ShopifyBlock: BlockConfig = { title: 'Search Query', type: 'short-input', placeholder: 'e.g., sku:ABC123', + mode: 'advanced', condition: { field: 'operation', value: ['shopify_list_inventory_items'], }, }, + { + id: 'first', + title: 'Max Results', + type: 'short-input', + placeholder: 'Defaults to 50, max 250', + mode: 'advanced', + condition: { + field: 'operation', + value: LIST_OPERATIONS as unknown as string[], + }, + }, // Order ID { id: 'orderId', @@ -235,6 +284,17 @@ export const ShopifyBlock: BlockConfig = { value: ['shopify_list_orders'], }, }, + { + id: 'orderQuery', + title: 'Search Query', + type: 'short-input', + placeholder: 'e.g., financial_status:paid OR email:customer@example.com', + mode: 'advanced', + condition: { + field: 'operation', + value: ['shopify_list_orders'], + }, + }, // Order Note (for update) { id: 'orderNote', @@ -278,6 +338,7 @@ export const ShopifyBlock: BlockConfig = { { label: 'Declined Payment', id: 'DECLINED' }, { label: 'Fraud', id: 'FRAUD' }, { label: 'Inventory Issue', id: 'INVENTORY' }, + { label: 'Staff Error', id: 'STAFF' }, { label: 'Other', id: 'OTHER' }, ], value: () => 'OTHER', @@ -298,6 +359,52 @@ export const ShopifyBlock: BlockConfig = { value: ['shopify_cancel_order'], }, }, + { + id: 'restock', + title: 'Restock Inventory', + type: 'dropdown', + options: [ + { label: 'Yes', id: 'true' }, + { label: 'No', id: 'false' }, + ], + value: () => 'false', + required: true, + mode: 'advanced', + condition: { + field: 'operation', + value: ['shopify_cancel_order'], + }, + }, + { + id: 'cancelNotifyCustomer', + title: 'Notify Customer', + type: 'dropdown', + options: [ + { label: 'Yes', id: 'true' }, + { label: 'No', id: 'false' }, + ], + value: () => 'false', + mode: 'advanced', + condition: { + field: 'operation', + value: ['shopify_cancel_order'], + }, + }, + { + id: 'refundOriginalPayment', + title: 'Refund to Original Payment Method', + type: 'dropdown', + options: [ + { label: 'Yes', id: 'true' }, + { label: 'No', id: 'false' }, + ], + value: () => 'false', + mode: 'advanced', + condition: { + field: 'operation', + value: ['shopify_cancel_order'], + }, + }, // Customer ID { id: 'customerId', @@ -376,16 +483,6 @@ export const ShopifyBlock: BlockConfig = { value: ['shopify_create_customer', 'shopify_update_customer'], }, }, - // Accepts Marketing - { - id: 'acceptsMarketing', - title: 'Accepts Marketing', - type: 'switch', - condition: { - field: 'operation', - value: ['shopify_create_customer', 'shopify_update_customer'], - }, - }, // Inventory Item ID { id: 'inventoryItemId', @@ -474,12 +571,33 @@ export const ShopifyBlock: BlockConfig = { { id: 'notifyCustomer', title: 'Notify Customer', - type: 'switch', + type: 'dropdown', + options: [ + { label: 'Yes', id: 'true' }, + { label: 'No', id: 'false' }, + ], + value: () => 'true', + mode: 'advanced', condition: { field: 'operation', value: ['shopify_create_fulfillment'], }, }, + { + id: 'includeInactive', + title: 'Include Inactive Locations', + type: 'dropdown', + options: [ + { label: 'Yes', id: 'true' }, + { label: 'No', id: 'false' }, + ], + value: () => 'false', + mode: 'advanced', + condition: { + field: 'operation', + value: ['shopify_list_locations'], + }, + }, // Collection ID { id: 'collectionId', @@ -498,11 +616,23 @@ export const ShopifyBlock: BlockConfig = { title: 'Search Query', type: 'short-input', placeholder: 'e.g., title:Summer OR collection_type:smart', + mode: 'advanced', condition: { field: 'operation', value: ['shopify_list_collections'], }, }, + { + id: 'productsFirst', + title: 'Max Products In Collection', + type: 'short-input', + placeholder: 'Defaults to 50, max 250', + mode: 'advanced', + condition: { + field: 'operation', + value: ['shopify_get_collection'], + }, + }, ], tools: { access: [ @@ -533,6 +663,7 @@ export const ShopifyBlock: BlockConfig = { return params.operation || 'shopify_list_products' }, params: (params) => { + const first = parseOptionalNumber(params.first) const baseParams: Record = { oauthCredential: params.oauthCredential, shopDomain: params.shopDomain?.trim(), @@ -569,6 +700,7 @@ export const ShopifyBlock: BlockConfig = { case 'shopify_list_products': return { ...baseParams, + first, query: params.productQuery?.trim(), } @@ -612,7 +744,9 @@ export const ShopifyBlock: BlockConfig = { case 'shopify_list_orders': return { ...baseParams, + first, status: params.orderStatus !== 'any' ? params.orderStatus : undefined, + query: params.orderQuery?.trim(), } case 'shopify_update_order': @@ -641,6 +775,12 @@ export const ShopifyBlock: BlockConfig = { ...baseParams, orderId: params.orderId.trim(), reason: params.cancelReason, + restock: parseBooleanInput(params.restock) ?? false, + notifyCustomer: parseBooleanInput(params.cancelNotifyCustomer), + refundMethod: + parseBooleanInput(params.refundOriginalPayment) === true + ? { originalPaymentMethodsRefund: true } + : undefined, staffNote: params.staffNote?.trim(), } @@ -658,7 +798,6 @@ export const ShopifyBlock: BlockConfig = { ?.split(',') .map((t: string) => t.trim()) .filter(Boolean), - acceptsMarketing: params.acceptsMarketing, } case 'shopify_get_customer': @@ -673,6 +812,7 @@ export const ShopifyBlock: BlockConfig = { case 'shopify_list_customers': return { ...baseParams, + first, query: params.customerQuery?.trim(), } @@ -707,6 +847,7 @@ export const ShopifyBlock: BlockConfig = { case 'shopify_list_inventory_items': return { ...baseParams, + first, query: params.inventoryQuery?.trim(), } @@ -741,6 +882,8 @@ export const ShopifyBlock: BlockConfig = { case 'shopify_list_locations': return { ...baseParams, + first, + includeInactive: parseBooleanInput(params.includeInactive), } // Fulfillment Operations @@ -754,13 +897,14 @@ export const ShopifyBlock: BlockConfig = { trackingNumber: params.trackingNumber?.trim(), trackingCompany: params.trackingCompany?.trim(), trackingUrl: params.trackingUrl?.trim(), - notifyCustomer: params.notifyCustomer, + notifyCustomer: parseBooleanInput(params.notifyCustomer), } // Collection Operations case 'shopify_list_collections': return { ...baseParams, + first, query: params.collectionQuery?.trim(), } @@ -771,6 +915,7 @@ export const ShopifyBlock: BlockConfig = { return { ...baseParams, collectionId: params.collectionId.trim(), + productsFirst: parseOptionalNumber(params.productsFirst), } default: @@ -791,14 +936,22 @@ export const ShopifyBlock: BlockConfig = { vendor: { type: 'string', description: 'Product vendor' }, tags: { type: 'string', description: 'Tags (comma-separated)' }, status: { type: 'string', description: 'Product status' }, - query: { type: 'string', description: 'Search query' }, + productQuery: { type: 'string', description: 'Product search query' }, + first: { type: 'number', description: 'Maximum number of results to return' }, // Order inputs orderId: { type: 'string', description: 'Order ID' }, orderStatus: { type: 'string', description: 'Order status filter' }, + orderQuery: { type: 'string', description: 'Order search query' }, orderNote: { type: 'string', description: 'Order note' }, orderEmail: { type: 'string', description: 'Order customer email' }, orderTags: { type: 'string', description: 'Order tags' }, cancelReason: { type: 'string', description: 'Order cancellation reason' }, + restock: { type: 'boolean', description: 'Whether to restock cancelled items' }, + cancelNotifyCustomer: { type: 'boolean', description: 'Whether to notify the customer' }, + refundOriginalPayment: { + type: 'boolean', + description: 'Whether to refund to the original payment method', + }, staffNote: { type: 'string', description: 'Staff note for order cancellation' }, // Customer inputs customerId: { type: 'string', description: 'Customer ID' }, @@ -808,7 +961,7 @@ export const ShopifyBlock: BlockConfig = { phone: { type: 'string', description: 'Customer phone' }, customerNote: { type: 'string', description: 'Customer note' }, customerTags: { type: 'string', description: 'Customer tags' }, - acceptsMarketing: { type: 'boolean', description: 'Accepts marketing' }, + customerQuery: { type: 'string', description: 'Customer search query' }, // Inventory inputs inventoryQuery: { type: 'string', description: 'Inventory search query' }, inventoryItemId: { type: 'string', description: 'Inventory item ID' }, @@ -820,30 +973,81 @@ export const ShopifyBlock: BlockConfig = { trackingCompany: { type: 'string', description: 'Shipping carrier name' }, trackingUrl: { type: 'string', description: 'Tracking URL' }, notifyCustomer: { type: 'boolean', description: 'Send shipping notification email' }, + includeInactive: { type: 'boolean', description: 'Include inactive locations in results' }, // Collection inputs collectionId: { type: 'string', description: 'Collection ID' }, collectionQuery: { type: 'string', description: 'Collection search query' }, + productsFirst: { type: 'number', description: 'Maximum number of products to return' }, }, outputs: { // Product outputs - product: { type: 'json', description: 'Product data' }, - products: { type: 'json', description: 'Products list' }, + product: { + type: 'json', + description: + 'Product details (id, title, handle, descriptionHtml, vendor, productType, tags, status, variants, images)', + }, + products: { + type: 'json', + description: 'List of products with core product fields and media summaries', + }, // Order outputs - order: { type: 'json', description: 'Order data' }, - orders: { type: 'json', description: 'Orders list' }, + order: { + type: 'json', + description: + 'Order details or cancellation result depending on the operation (order fields, customer, totals, notes, line items, or cancellation job status)', + }, + orders: { + type: 'json', + description: 'List of orders with status, totals, customer, and shipping summary fields', + }, // Customer outputs - customer: { type: 'json', description: 'Customer data' }, - customers: { type: 'json', description: 'Customers list' }, + customer: { + type: 'json', + description: + 'Customer details (id, email, name, phone, note, tags, amountSpent, addresses, defaultAddress)', + }, + customers: { + type: 'json', + description: 'List of customers with contact details, tags, spend, and default address', + }, // Inventory outputs - inventoryItems: { type: 'json', description: 'Inventory items list' }, - inventoryLevel: { type: 'json', description: 'Inventory level data' }, + inventoryItems: { + type: 'json', + description: + 'Inventory items with SKU, tracking status, variant details, and per-location stock', + }, + inventoryLevel: { + type: 'json', + description: + 'Inventory levels for an item or an inventory adjustment result (levels by location, or adjustmentGroup and changes)', + }, // Location outputs - locations: { type: 'json', description: 'Locations list' }, + locations: { + type: 'json', + description: + 'Store locations with id, name, active status, fulfillment capability, and address', + }, // Fulfillment outputs - fulfillment: { type: 'json', description: 'Fulfillment data' }, + fulfillment: { + type: 'json', + description: + 'Fulfillment result (id, status, trackingInfo, createdAt, updatedAt, fulfillmentLineItems)', + }, // Collection outputs - collection: { type: 'json', description: 'Collection data with products' }, - collections: { type: 'json', description: 'Collections list' }, + collection: { + type: 'json', + description: + 'Collection details (id, title, handle, descriptionHtml, image, sortOrder, productsCount, products)', + }, + collections: { + type: 'json', + description: + 'List of collections with id, title, handle, product counts, sort order, and image', + }, + pageInfo: { + type: 'json', + description: 'Pagination info for list operations (hasNextPage, hasPreviousPage)', + }, // Delete outputs deletedId: { type: 'string', description: 'ID of deleted resource' }, // Success indicator diff --git a/apps/sim/blocks/blocks/trello.ts b/apps/sim/blocks/blocks/trello.ts index c0d9511bfcc..80e119f4fa4 100644 --- a/apps/sim/blocks/blocks/trello.ts +++ b/apps/sim/blocks/blocks/trello.ts @@ -2,23 +2,109 @@ import { TrelloIcon } from '@/components/icons' import { getScopesForService } from '@/lib/oauth/utils' import type { BlockConfig } from '@/blocks/types' import { AuthMode, IntegrationType } from '@/blocks/types' -import type { ToolResponse } from '@/tools/types' +import type { TrelloResponse } from '@/tools/trello' + +function getTrimmedString(value: unknown): string | undefined { + if (typeof value !== 'string') { + return undefined + } + + const trimmed = value.trim() + return trimmed.length > 0 ? trimmed : undefined +} + +function parseOptionalNumber(value: unknown): number | undefined { + if (typeof value === 'number' && Number.isFinite(value)) { + return value + } + + if (typeof value !== 'string') { + return undefined + } + + const trimmed = value.trim() + if (trimmed.length === 0) { + return undefined + } + + const parsed = Number(trimmed) + return Number.isFinite(parsed) ? parsed : undefined +} + +function parseOptionalBoolean(value: unknown): boolean | undefined { + if (typeof value === 'boolean') { + return value + } + + if (typeof value === 'number') { + return value !== 0 + } + + if (typeof value !== 'string') { + return undefined + } + + const normalized = value.trim().toLowerCase() + if (normalized.length === 0) { + return undefined + } + + if (normalized === 'true' || normalized === '1') { + return true + } + + if (normalized === 'false' || normalized === '0') { + return false + } + + return undefined +} + +function parseStringArray(value: unknown): string[] | undefined { + if (Array.isArray(value)) { + const items = value + .flatMap((item) => (typeof item === 'string' ? [item.trim()] : [])) + .filter((item) => item.length > 0) + + return items.length > 0 ? items : undefined + } + + if (typeof value !== 'string') { + return undefined + } + + const trimmed = value.trim() + if (trimmed.length === 0) { + return undefined + } + + if (trimmed.startsWith('[')) { + try { + const parsed = JSON.parse(trimmed) + return parseStringArray(parsed) + } catch { + return undefined + } + } + + const items = trimmed + .split(',') + .map((item) => item.trim()) + .filter((item) => item.length > 0) + return items.length > 0 ? items : undefined +} /** - * Trello Block - * - * Note: Trello uses OAuth 1.0a authentication with a unique credential ID format - * (non-UUID strings like CUID2). This is different from most OAuth 2.0 providers - * that use UUID-based credential IDs. The OAuth credentials API has been updated - * to accept both UUID and non-UUID credential ID formats to support Trello. + * Trello uses a custom token flow and non-UUID credential IDs, so the block keeps + * the normal OAuth block UX while relying on the custom Trello auth routes. */ -export const TrelloBlock: BlockConfig = { +export const TrelloBlock: BlockConfig = { type: 'trello', name: 'Trello', - description: 'Manage Trello boards and cards', + description: 'Manage Trello lists, cards, and activity', authMode: AuthMode.OAuth, longDescription: - 'Integrate with Trello to manage boards and cards. List boards, list cards, create cards, update cards, get actions, and add comments.', + 'Integrate with Trello to list board lists, list cards, create cards, update cards, review activity, and add comments.', docsLink: 'https://docs.sim.ai/tools/trello', category: 'tools', integrationType: IntegrationType.Productivity, @@ -60,7 +146,6 @@ export const TrelloBlock: BlockConfig = { placeholder: 'Enter credential ID', required: true, }, - { id: 'boardSelector', title: 'Board', @@ -74,111 +159,105 @@ export const TrelloBlock: BlockConfig = { mode: 'basic', condition: { field: 'operation', - value: [ - 'trello_list_lists', - 'trello_list_cards', - 'trello_create_card', - 'trello_get_actions', - ], + value: ['trello_list_lists', 'trello_list_cards', 'trello_get_actions'], }, required: { field: 'operation', - value: ['trello_list_lists', 'trello_list_cards', 'trello_create_card'], + value: 'trello_list_lists', }, }, { - id: 'boardId', + id: 'manualBoardId', title: 'Board ID', type: 'short-input', canonicalParamId: 'boardId', - placeholder: 'Enter board ID', + placeholder: 'Enter Trello board ID', mode: 'advanced', condition: { field: 'operation', - value: [ - 'trello_list_lists', - 'trello_list_cards', - 'trello_create_card', - 'trello_get_actions', - ], + value: ['trello_list_lists', 'trello_list_cards', 'trello_get_actions'], }, required: { field: 'operation', - value: ['trello_list_lists', 'trello_list_cards', 'trello_create_card'], + value: 'trello_list_lists', }, }, { id: 'listId', - title: 'List (Optional)', + title: 'List ID', type: 'short-input', - placeholder: 'Enter list ID to filter cards by list', + placeholder: 'Enter Trello list ID', condition: { field: 'operation', - value: 'trello_list_cards', + value: ['trello_list_cards', 'trello_create_card'], + }, + required: { + field: 'operation', + value: 'trello_create_card', }, }, { - id: 'listId', - title: 'List', + id: 'cardId', + title: 'Card ID', type: 'short-input', - placeholder: 'Enter list ID or search for a list', + placeholder: 'Enter Trello card ID', condition: { field: 'operation', - value: 'trello_create_card', + value: ['trello_update_card', 'trello_get_actions', 'trello_add_comment'], + }, + required: { + field: 'operation', + value: ['trello_update_card', 'trello_add_comment'], }, - required: true, }, - { id: 'name', title: 'Card Name', type: 'short-input', - placeholder: 'Enter card name/title', + placeholder: 'Enter card name', condition: { + field: 'operation', + value: ['trello_create_card', 'trello_update_card'], + }, + required: { field: 'operation', value: 'trello_create_card', }, - required: true, }, - { id: 'desc', title: 'Description', type: 'long-input', - placeholder: 'Enter card description (optional)', + placeholder: 'Enter card description', condition: { field: 'operation', - value: 'trello_create_card', + value: ['trello_create_card', 'trello_update_card'], }, }, - { id: 'pos', title: 'Position', - type: 'dropdown', - options: [ - { label: 'Top', id: 'top' }, - { label: 'Bottom', id: 'bottom' }, - ], + type: 'short-input', + placeholder: 'top, bottom, or a positive float', + mode: 'advanced', condition: { field: 'operation', value: 'trello_create_card', }, }, - { id: 'due', title: 'Due Date', type: 'short-input', - placeholder: 'YYYY-MM-DD or ISO 8601', + placeholder: 'YYYY-MM-DD or ISO 8601 timestamp', condition: { field: 'operation', - value: 'trello_create_card', + value: ['trello_create_card', 'trello_update_card'], }, wandConfig: { enabled: true, prompt: `Generate a date or timestamp based on the user's description. -The timestamp should be in ISO 8601 format: YYYY-MM-DD or YYYY-MM-DDTHH:MM:SSZ (UTC timezone). +The timestamp should be in ISO 8601 format: YYYY-MM-DD or YYYY-MM-DDTHH:MM:SSZ. Examples: - "tomorrow" -> Calculate tomorrow's date in YYYY-MM-DD format - "next Friday" -> Calculate the next Friday in YYYY-MM-DD format @@ -186,129 +265,77 @@ Examples: - "end of month" -> Calculate the last day of the current month - "next week at 3pm" -> Calculate next week's date at 15:00:00Z -Return ONLY the date/timestamp string - no explanations, no quotes, no extra text.`, - placeholder: 'Describe the due date (e.g., "next Friday", "in 2 weeks")...', +Return ONLY the date/timestamp string - no explanations, no extra text.`, + placeholder: 'Describe the due date (e.g. "next Friday", "in 2 weeks")...', generationType: 'timestamp', }, }, - - { - id: 'labels', - title: 'Labels', - type: 'short-input', - placeholder: 'Comma-separated label IDs (optional)', - condition: { - field: 'operation', - value: 'trello_create_card', - }, - }, - { - id: 'cardId', - title: 'Card', - type: 'short-input', - placeholder: 'Enter card ID or search for a card', + id: 'dueComplete', + title: 'Due Status', + type: 'dropdown', + options: [ + { label: 'Leave Unset', id: '' }, + { label: 'Complete', id: 'true' }, + { label: 'Incomplete', id: 'false' }, + ], + value: () => '', + mode: 'advanced', condition: { field: 'operation', - value: 'trello_update_card', + value: ['trello_create_card', 'trello_update_card'], }, - required: true, }, - { - id: 'name', - title: 'New Card Name', + id: 'labelIds', + title: 'Label IDs', type: 'short-input', - placeholder: 'Enter new card name (leave empty to keep current)', + placeholder: 'Comma-separated label IDs', + mode: 'advanced', condition: { field: 'operation', - value: 'trello_update_card', + value: 'trello_create_card', }, - }, - - { - id: 'desc', - title: 'New Description', - type: 'long-input', - placeholder: 'Enter new description (leave empty to keep current)', - condition: { - field: 'operation', - value: 'trello_update_card', + wandConfig: { + enabled: true, + prompt: + 'Generate a comma-separated list of Trello label IDs. Return ONLY the comma-separated values - no explanations, no extra text.', + placeholder: 'Describe the label IDs to include...', }, }, - { id: 'closed', - title: 'Archive Card', - type: 'switch', - condition: { - field: 'operation', - value: 'trello_update_card', - }, - }, - - { - id: 'dueComplete', - title: 'Mark Due Date Complete', - type: 'switch', + title: 'Archive Status', + type: 'dropdown', + options: [ + { label: 'Leave Unchanged', id: '' }, + { label: 'Archive Card', id: 'true' }, + { label: 'Reopen Card', id: 'false' }, + ], + value: () => '', + mode: 'advanced', condition: { field: 'operation', value: 'trello_update_card', }, }, - { id: 'idList', - title: 'Move to List', - type: 'short-input', - placeholder: 'Enter list ID to move card', - condition: { - field: 'operation', - value: 'trello_update_card', - }, - }, - - { - id: 'due', - title: 'Due Date', + title: 'Move to List ID', type: 'short-input', - placeholder: 'YYYY-MM-DD or ISO 8601', + placeholder: 'Enter Trello list ID', + mode: 'advanced', condition: { field: 'operation', value: 'trello_update_card', }, - wandConfig: { - enabled: true, - prompt: `Generate a date or timestamp based on the user's description. -The timestamp should be in ISO 8601 format: YYYY-MM-DD or YYYY-MM-DDTHH:MM:SSZ (UTC timezone). -Examples: -- "tomorrow" -> Calculate tomorrow's date in YYYY-MM-DD format -- "next Friday" -> Calculate the next Friday in YYYY-MM-DD format -- "in 3 days" -> Calculate 3 days from now in YYYY-MM-DD format -- "end of month" -> Calculate the last day of the current month -- "next week at 3pm" -> Calculate next week's date at 15:00:00Z - -Return ONLY the date/timestamp string - no explanations, no quotes, no extra text.`, - placeholder: 'Describe the due date (e.g., "next Friday", "in 2 weeks")...', - generationType: 'timestamp', - }, - }, - - { - id: 'cardId', - title: 'Card ID', - type: 'short-input', - placeholder: 'Enter card ID to get card actions', - condition: { - field: 'operation', - value: 'trello_get_actions', - }, }, { id: 'filter', title: 'Action Filter', type: 'short-input', - placeholder: 'e.g., commentCard,updateCard', + placeholder: 'commentCard,updateCard,createCard or all', + mode: 'advanced', condition: { field: 'operation', value: 'trello_get_actions', @@ -316,26 +343,26 @@ Return ONLY the date/timestamp string - no explanations, no quotes, no extra tex }, { id: 'limit', - title: 'Limit', + title: 'Board Action Limit', type: 'short-input', - placeholder: '50', + placeholder: 'Maximum number of board actions', + mode: 'advanced', condition: { field: 'operation', value: 'trello_get_actions', }, }, { - id: 'cardId', - title: 'Card', + id: 'page', + title: 'Action Page', type: 'short-input', - placeholder: 'Enter card ID or search for a card', + placeholder: 'Page number for board or card actions', + mode: 'advanced', condition: { field: 'operation', - value: 'trello_add_comment', + value: 'trello_get_actions', }, - required: true, }, - { id: 'text', title: 'Comment', @@ -358,99 +385,190 @@ Return ONLY the date/timestamp string - no explanations, no quotes, no extra tex 'trello_add_comment', ], config: { - tool: (params) => { - switch (params.operation) { - case 'trello_list_lists': - return 'trello_list_lists' - case 'trello_list_cards': - return 'trello_list_cards' - case 'trello_create_card': - return 'trello_create_card' - case 'trello_update_card': - return 'trello_update_card' - case 'trello_get_actions': - return 'trello_get_actions' - case 'trello_add_comment': - return 'trello_add_comment' - default: - return 'trello_list_lists' - } - }, + tool: (params) => getTrimmedString(params.operation) ?? 'trello_list_lists', params: (params) => { - const { operation, limit, closed, dueComplete, ...rest } = params + const operation = getTrimmedString(params.operation) ?? 'trello_list_lists' + const baseParams: Record = { + oauthCredential: params.oauthCredential, + } - const result: Record = { ...rest } + switch (operation) { + case 'trello_list_lists': { + const boardId = getTrimmedString(params.boardId) - if (limit && operation === 'trello_get_actions') { - result.limit = Number.parseInt(limit, 10) - } + if (!boardId) { + throw new Error('Board ID is required.') + } - if (closed !== undefined && operation === 'trello_update_card') { - if (typeof closed === 'string') { - result.closed = closed.toLowerCase() === 'true' || closed === '1' - } else if (typeof closed === 'number') { - result.closed = closed !== 0 - } else { - result.closed = Boolean(closed) + return { + ...baseParams, + boardId, + } } - } - if (dueComplete !== undefined && operation === 'trello_update_card') { - if (typeof dueComplete === 'string') { - result.dueComplete = dueComplete.toLowerCase() === 'true' || dueComplete === '1' - } else if (typeof dueComplete === 'number') { - result.dueComplete = dueComplete !== 0 - } else { - result.dueComplete = Boolean(dueComplete) + case 'trello_list_cards': { + const boardId = getTrimmedString(params.boardId) + const listId = getTrimmedString(params.listId) + + if (boardId && listId) { + throw new Error('Provide either a board ID or list ID, not both.') + } + + if (!boardId && !listId) { + throw new Error('Provide either a board ID or list ID.') + } + + return { + ...baseParams, + boardId, + listId, + } + } + + case 'trello_create_card': { + const listId = getTrimmedString(params.listId) + const name = getTrimmedString(params.name) + + if (!listId) { + throw new Error('List ID is required.') + } + + if (!name) { + throw new Error('Card name is required.') + } + + return { + ...baseParams, + listId, + name, + desc: getTrimmedString(params.desc), + pos: getTrimmedString(params.pos), + due: getTrimmedString(params.due), + dueComplete: parseOptionalBoolean(params.dueComplete), + labelIds: parseStringArray(params.labelIds), + } } - } - return result + case 'trello_update_card': { + const cardId = getTrimmedString(params.cardId) + + if (!cardId) { + throw new Error('Card ID is required.') + } + + return { + ...baseParams, + cardId, + name: getTrimmedString(params.name), + desc: getTrimmedString(params.desc), + closed: parseOptionalBoolean(params.closed), + idList: getTrimmedString(params.idList), + due: getTrimmedString(params.due), + dueComplete: parseOptionalBoolean(params.dueComplete), + } + } + + case 'trello_get_actions': { + const boardId = getTrimmedString(params.boardId) + const cardId = getTrimmedString(params.cardId) + + if (boardId && cardId) { + throw new Error('Provide either a board ID or card ID, not both.') + } + + if (!boardId && !cardId) { + throw new Error('Provide either a board ID or card ID.') + } + + return { + ...baseParams, + boardId, + cardId, + filter: getTrimmedString(params.filter), + limit: parseOptionalNumber(params.limit), + page: parseOptionalNumber(params.page), + } + } + + case 'trello_add_comment': { + const cardId = getTrimmedString(params.cardId) + const text = getTrimmedString(params.text) + + if (!cardId) { + throw new Error('Card ID is required.') + } + + if (!text) { + throw new Error('Comment text is required.') + } + + return { + ...baseParams, + cardId, + text, + } + } + + default: + return baseParams + } }, }, }, inputs: { operation: { type: 'string', description: 'Trello operation to perform' }, oauthCredential: { type: 'string', description: 'Trello OAuth credential' }, - boardId: { type: 'string', description: 'Board ID' }, - listId: { type: 'string', description: 'List ID' }, - cardId: { type: 'string', description: 'Card ID' }, - name: { type: 'string', description: 'Card name/title' }, - desc: { type: 'string', description: 'Card or board description' }, - pos: { type: 'string', description: 'Card position (top, bottom, or number)' }, + boardId: { type: 'string', description: 'Trello board ID' }, + listId: { type: 'string', description: 'Trello list ID' }, + cardId: { type: 'string', description: 'Trello card ID' }, + name: { type: 'string', description: 'Card name' }, + desc: { type: 'string', description: 'Card description' }, + pos: { type: 'string', description: 'Card position (top, bottom, or positive float)' }, due: { type: 'string', description: 'Due date in ISO 8601 format' }, - labels: { type: 'string', description: 'Comma-separated label IDs' }, - closed: { type: 'boolean', description: 'Archive/close status' }, - idList: { type: 'string', description: 'ID of list to move card to' }, - dueComplete: { type: 'boolean', description: 'Mark due date as complete' }, - filter: { type: 'string', description: 'Action type filter' }, - limit: { type: 'number', description: 'Maximum number of results' }, + dueComplete: { type: 'boolean', description: 'Whether the due date is complete' }, + labelIds: { + type: 'json', + description: 'Label IDs as an array or comma-separated string', + }, + closed: { type: 'boolean', description: 'Whether the card should be archived or reopened' }, + idList: { type: 'string', description: 'List ID to move the card to' }, + filter: { type: 'string', description: 'Trello action filter' }, + limit: { type: 'number', description: 'Maximum number of board actions to return' }, + page: { type: 'number', description: 'Page number for action results' }, text: { type: 'string', description: 'Comment text' }, }, outputs: { lists: { - type: 'array', - description: 'Array of list objects (for list_lists operation)', + type: 'json', + description: 'Board lists (id, name, closed, pos, idBoard)', }, cards: { - type: 'array', - description: 'Array of card objects (for list_cards operation)', + type: 'json', + description: + 'Cards (id, name, desc, url, idBoard, idList, closed, labelIds, labels, due, dueComplete)', }, card: { type: 'json', - description: 'Card object (for create_card and update_card operations)', + description: + 'Created or updated card (id, name, desc, url, idBoard, idList, closed, labelIds, labels, due, dueComplete)', }, actions: { - type: 'array', - description: 'Array of action objects (for get_actions operation)', + type: 'json', + description: + 'Actions (id, type, date, idMemberCreator, text, memberCreator, card, board, list)', }, comment: { type: 'json', - description: 'Comment object (for add_comment operation)', + description: + 'Created comment action (id, type, date, idMemberCreator, text, memberCreator, card, board, list)', }, count: { type: 'number', - description: 'Number of items returned (lists, cards, actions)', + description: 'Number of returned lists, cards, or actions', + }, + error: { + type: 'string', + description: 'Error message when the Trello operation fails', }, }, } diff --git a/apps/sim/blocks/blocks/whatsapp.ts b/apps/sim/blocks/blocks/whatsapp.ts index c42ea29d3d3..870f46234aa 100644 --- a/apps/sim/blocks/blocks/whatsapp.ts +++ b/apps/sim/blocks/blocks/whatsapp.ts @@ -32,6 +32,20 @@ export const WhatsAppBlock: BlockConfig = { placeholder: 'Enter your message', required: true, }, + { + id: 'previewUrl', + title: 'Preview First Link', + type: 'dropdown', + options: [ + { label: 'No', id: 'false' }, + { label: 'Yes', id: 'true' }, + ], + defaultValue: 'false', + description: + 'Have WhatsApp attempt to render a link preview for the first URL in the message.', + required: false, + mode: 'advanced', + }, { id: 'phoneNumberId', title: 'WhatsApp Phone Number ID', @@ -53,25 +67,109 @@ export const WhatsAppBlock: BlockConfig = { access: ['whatsapp_send_message'], config: { tool: () => 'whatsapp_send_message', + params: (params) => ({ + ...params, + previewUrl: + params.previewUrl === 'true' ? true : params.previewUrl === 'false' ? false : undefined, + }), }, }, inputs: { phoneNumber: { type: 'string', description: 'Recipient phone number' }, message: { type: 'string', description: 'Message text' }, + previewUrl: { type: 'boolean', description: 'Whether to render a preview for the first URL' }, phoneNumberId: { type: 'string', description: 'WhatsApp phone number ID' }, accessToken: { type: 'string', description: 'WhatsApp access token' }, }, outputs: { - // Send operation outputs success: { type: 'boolean', description: 'Send success status' }, messageId: { type: 'string', description: 'WhatsApp message identifier' }, + messageStatus: { + type: 'string', + description: 'Initial delivery state returned by the send API, such as accepted or paused', + }, + messagingProduct: { + type: 'string', + description: 'Messaging product returned by the send API', + }, + inputPhoneNumber: { + type: 'string', + description: 'Recipient phone number echoed by the send API', + }, + whatsappUserId: { + type: 'string', + description: 'Resolved WhatsApp user ID for the recipient', + }, + contacts: { + type: 'array', + description: + 'Recipient contacts returned by the send API (each item includes input and wa_id)', + }, + eventType: { + type: 'string', + description: 'Webhook classification such as incoming_message, message_status, or mixed', + }, + from: { type: 'string', description: 'Sender phone number from the first incoming message' }, + recipientId: { + type: 'string', + description: 'Recipient phone number from the first status update in the batch', + }, + phoneNumberId: { + type: 'string', + description: 'Business phone number ID from the first message or status item in the batch', + }, + displayPhoneNumber: { + type: 'string', + description: + 'Business display phone number from the first message or status item in the batch', + }, + text: { type: 'string', description: 'Text body from the first incoming text message' }, + timestamp: { + type: 'string', + description: 'Timestamp from the first message or status item in the batch', + }, + messageType: { + type: 'string', + description: + 'Type of the first incoming message in the batch, such as text, image, or system', + }, + status: { + type: 'string', + description: 'First outgoing message status in the batch, such as sent, delivered, or read', + }, + contact: { + type: 'json', + description: 'First sender contact in the webhook batch (wa_id, profile.name)', + }, + messages: { + type: 'json', + description: + 'All incoming message objects from the webhook batch, flattened across entries/changes', + }, + statuses: { + type: 'json', + description: + 'All message status objects from the webhook batch, flattened across entries/changes', + }, + webhookContacts: { + type: 'json', + description: 'All sender contact profiles from the webhook batch', + }, + conversation: { + type: 'json', + description: + 'Conversation metadata from the first status update in the batch (id, expiration_timestamp, origin.type)', + }, + pricing: { + type: 'json', + description: + 'Pricing metadata from the first status update in the batch (billable, pricing_model, category)', + }, + raw: { + type: 'json', + description: 'Full structured WhatsApp webhook payload', + }, error: { type: 'string', description: 'Error information if sending fails' }, - // Webhook trigger outputs - from: { type: 'string', description: 'Sender phone number' }, - to: { type: 'string', description: 'Recipient phone number' }, - text: { type: 'string', description: 'Message text content' }, - timestamp: { type: 'string', description: 'Message timestamp' }, - type: { type: 'string', description: 'Message type (text, image, etc.)' }, }, triggers: { enabled: true, diff --git a/apps/sim/blocks/registry.ts b/apps/sim/blocks/registry.ts index aa915441d73..38165082858 100644 --- a/apps/sim/blocks/registry.ts +++ b/apps/sim/blocks/registry.ts @@ -30,6 +30,7 @@ import { CloudWatchBlock } from '@/blocks/blocks/cloudwatch' import { ConditionBlock } from '@/blocks/blocks/condition' import { ConfluenceBlock, ConfluenceV2Block } from '@/blocks/blocks/confluence' import { CredentialBlock } from '@/blocks/blocks/credential' +import { CrowdStrikeBlock } from '@/blocks/blocks/crowdstrike' import { CursorBlock, CursorV2Block } from '@/blocks/blocks/cursor' import { DagsterBlock } from '@/blocks/blocks/dagster' import { DatabricksBlock } from '@/blocks/blocks/databricks' @@ -249,6 +250,7 @@ export const registry: Record = { cloudflare: CloudflareBlock, cloudformation: CloudFormationBlock, cloudwatch: CloudWatchBlock, + crowdstrike: CrowdStrikeBlock, clay: ClayBlock, clerk: ClerkBlock, condition: ConditionBlock, diff --git a/apps/sim/components/icons.tsx b/apps/sim/components/icons.tsx index 0e4de0bf5e5..2f91eebc395 100644 --- a/apps/sim/components/icons.tsx +++ b/apps/sim/components/icons.tsx @@ -28,6 +28,17 @@ export function AgentMailIcon(props: SVGProps) { ) } +export function CrowdStrikeIcon(props: SVGProps) { + return ( + + + + ) +} + export function SearchIcon(props: SVGProps) { return ( = { data: 'Access Wealthbox data', // Linear scopes - read: 'Read access to workspace', - write: 'Write access to Linear workspace', + read: 'Read access to connected account data', + write: 'Write access to connected account data', // Slack scopes 'channels:read': 'View public channels', diff --git a/apps/sim/lib/webhooks/providers/whatsapp.test.ts b/apps/sim/lib/webhooks/providers/whatsapp.test.ts new file mode 100644 index 00000000000..f648bd776a0 --- /dev/null +++ b/apps/sim/lib/webhooks/providers/whatsapp.test.ts @@ -0,0 +1,201 @@ +/** + * @vitest-environment node + */ +import { createHmac } from 'node:crypto' +import { NextRequest } from 'next/server' +import { describe, expect, it, vi } from 'vitest' + +vi.mock('@sim/db', () => ({ + db: {}, + workflowDeploymentVersion: {}, +})) + +vi.mock('@sim/db/schema', () => ({ + webhook: {}, +})) + +import { whatsappHandler } from './whatsapp' + +function reqWithHeaders(headers: Record): NextRequest { + return new NextRequest('http://localhost/test', { headers }) +} + +describe('WhatsApp webhook provider', () => { + it('rejects deliveries when the app secret is not configured', async () => { + const response = await whatsappHandler.verifyAuth!({ + webhook: { id: 'wh_1' }, + workflow: { id: 'wf_1' }, + request: reqWithHeaders({}), + rawBody: '{}', + requestId: 'wa-auth-missing-secret', + providerConfig: {}, + }) + + expect(response?.status).toBe(401) + await expect(response?.text()).resolves.toBe( + 'Unauthorized - WhatsApp app secret not configured' + ) + }) + + it('accepts a valid X-Hub-Signature-256 header for the exact raw payload', async () => { + const secret = 'test-secret' + const rawBody = + '{"entry":[{"changes":[{"field":"messages","value":{"messages":[{"id":"wamid.1"}]}}]}]}' + const signature = `sha256=${createHmac('sha256', secret).update(rawBody, 'utf8').digest('hex')}` + + const response = await whatsappHandler.verifyAuth!({ + webhook: { id: 'wh_2' }, + workflow: { id: 'wf_2' }, + request: reqWithHeaders({ 'x-hub-signature-256': signature }), + rawBody, + requestId: 'wa-auth-valid-signature', + providerConfig: { appSecret: secret }, + }) + + expect(response).toBeNull() + }) + + it('builds a stable idempotency key for batched message and status payloads', () => { + const key = whatsappHandler.extractIdempotencyId!({ + entry: [ + { + changes: [ + { + field: 'messages', + value: { + messages: [{ id: 'wamid.message.1' }], + statuses: [ + { + id: 'wamid.status.1', + status: 'delivered', + timestamp: '1700000001', + }, + ], + }, + }, + ], + }, + ], + }) + + expect(key).toMatch(/^whatsapp:2:[a-f0-9]{64}$/) + }) + + it('flattens batched messages and statuses into trigger-friendly outputs', async () => { + const result = await whatsappHandler.formatInput!({ + webhook: { id: 'wh_3', providerConfig: {} }, + workflow: { id: 'wf_3', userId: 'user_3' }, + body: { + object: 'whatsapp_business_account', + entry: [ + { + changes: [ + { + field: 'messages', + value: { + metadata: { + phone_number_id: '12345', + display_phone_number: '+1 555 0100', + }, + contacts: [ + { + wa_id: '15550101', + profile: { name: 'Alice' }, + }, + ], + messages: [ + { + id: 'wamid.message.1', + from: '15550101', + timestamp: '1700000000', + type: 'text', + text: { body: 'hello' }, + }, + ], + }, + }, + { + field: 'messages', + value: { + metadata: { + phone_number_id: '12345', + display_phone_number: '+1 555 0100', + }, + statuses: [ + { + id: 'wamid.status.1', + recipient_id: '15550102', + status: 'delivered', + timestamp: '1700000001', + conversation: { id: 'conv_1' }, + pricing: { category: 'utility' }, + }, + ], + }, + }, + ], + }, + ], + }, + headers: {}, + requestId: 'wa-format-batch', + }) + + const input = result.input as Record + + expect(input.eventType).toBe('mixed') + expect(input.messageId).toBe('wamid.message.1') + expect(input.phoneNumberId).toBe('12345') + expect(input.displayPhoneNumber).toBe('+1 555 0100') + expect(input.text).toBe('hello') + expect(input.status).toBe('delivered') + expect(input.contact).toEqual({ + wa_id: '15550101', + profile: { name: 'Alice' }, + }) + expect(input.webhookContacts).toEqual([ + { + wa_id: '15550101', + profile: { name: 'Alice' }, + }, + ]) + expect(input.messages).toEqual([ + { + messageId: 'wamid.message.1', + from: '15550101', + phoneNumberId: '12345', + displayPhoneNumber: '+1 555 0100', + text: 'hello', + timestamp: '1700000000', + messageType: 'text', + raw: { + id: 'wamid.message.1', + from: '15550101', + timestamp: '1700000000', + type: 'text', + text: { body: 'hello' }, + }, + }, + ]) + expect(input.statuses).toEqual([ + { + messageId: 'wamid.status.1', + recipientId: '15550102', + phoneNumberId: '12345', + displayPhoneNumber: '+1 555 0100', + status: 'delivered', + timestamp: '1700000001', + conversation: { id: 'conv_1' }, + pricing: { category: 'utility' }, + raw: { + id: 'wamid.status.1', + recipient_id: '15550102', + status: 'delivered', + timestamp: '1700000001', + conversation: { id: 'conv_1' }, + pricing: { category: 'utility' }, + }, + }, + ]) + }) +}) diff --git a/apps/sim/lib/webhooks/providers/whatsapp.ts b/apps/sim/lib/webhooks/providers/whatsapp.ts index 5f0116b2a1b..e1a76ef2188 100644 --- a/apps/sim/lib/webhooks/providers/whatsapp.ts +++ b/apps/sim/lib/webhooks/providers/whatsapp.ts @@ -1,8 +1,10 @@ +import { createHash, createHmac } from 'crypto' import { db, workflowDeploymentVersion } from '@sim/db' import { webhook } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq, isNull, or } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' +import { safeCompare } from '@/lib/core/security/encryption' import type { FormatInputContext, FormatInputResult, @@ -11,6 +13,122 @@ import type { const logger = createLogger('WebhookProvider:WhatsApp') +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +function getWhatsAppChanges( + body: unknown +): Array<{ field?: string; value: Record }> { + if (!isRecord(body) || !Array.isArray(body.entry)) { + return [] + } + + const changes: Array<{ field?: string; value: Record }> = [] + + for (const entry of body.entry) { + if (!isRecord(entry) || !Array.isArray(entry.changes)) { + continue + } + + for (const change of entry.changes) { + if (!isRecord(change) || !isRecord(change.value)) { + continue + } + + changes.push({ + field: typeof change.field === 'string' ? change.field : undefined, + value: change.value, + }) + } + } + + return changes +} + +function normalizeWhatsAppContact(contact: Record) { + const profile = isRecord(contact.profile) ? contact.profile : undefined + + return { + wa_id: typeof contact.wa_id === 'string' ? contact.wa_id : undefined, + profile: profile + ? { + name: typeof profile.name === 'string' ? profile.name : undefined, + } + : undefined, + } +} + +function normalizeWhatsAppMessage( + message: Record, + metadata?: Record +) { + const text = isRecord(message.text) ? message.text : undefined + + return { + messageId: typeof message.id === 'string' ? message.id : undefined, + from: typeof message.from === 'string' ? message.from : undefined, + phoneNumberId: + typeof metadata?.phone_number_id === 'string' ? metadata.phone_number_id : undefined, + displayPhoneNumber: + typeof metadata?.display_phone_number === 'string' + ? metadata.display_phone_number + : undefined, + text: typeof text?.body === 'string' ? text.body : undefined, + timestamp: typeof message.timestamp === 'string' ? message.timestamp : undefined, + messageType: typeof message.type === 'string' ? message.type : undefined, + raw: message, + } +} + +function normalizeWhatsAppStatus( + status: Record, + metadata?: Record +) { + return { + messageId: typeof status.id === 'string' ? status.id : undefined, + recipientId: typeof status.recipient_id === 'string' ? status.recipient_id : undefined, + phoneNumberId: + typeof metadata?.phone_number_id === 'string' ? metadata.phone_number_id : undefined, + displayPhoneNumber: + typeof metadata?.display_phone_number === 'string' + ? metadata.display_phone_number + : undefined, + status: typeof status.status === 'string' ? status.status : undefined, + timestamp: typeof status.timestamp === 'string' ? status.timestamp : undefined, + conversation: isRecord(status.conversation) ? status.conversation : undefined, + pricing: isRecord(status.pricing) ? status.pricing : undefined, + raw: status, + } +} + +function validateWhatsAppSignature(secret: string, signature: string, body: string): boolean { + try { + if (!signature.startsWith('sha256=')) { + logger.warn('WhatsApp signature has invalid format') + return false + } + + const providedSignature = signature.substring(7) + const computedSignature = createHmac('sha256', secret).update(body, 'utf8').digest('hex') + + return safeCompare(computedSignature, providedSignature) + } catch (error) { + logger.error('Error validating WhatsApp signature:', error) + return false + } +} + +function buildWhatsAppIdempotencyKey(keys: Set): string | null { + if (keys.size === 0) { + return null + } + + const sortedKeys = Array.from(keys).sort() + const digest = createHash('sha256').update(sortedKeys.join('|'), 'utf8').digest('hex') + return `whatsapp:${sortedKeys.length}:${digest}` +} + /** * Handle WhatsApp verification requests */ @@ -42,6 +160,7 @@ export async function handleWhatsAppVerification( .where( and( eq(webhook.provider, 'whatsapp'), + eq(webhook.path, path), eq(webhook.isActive, true), or( eq(webhook.deploymentVersionId, workflowDeploymentVersion.id), @@ -78,6 +197,29 @@ export async function handleWhatsAppVerification( } export const whatsappHandler: WebhookProviderHandler = { + verifyAuth({ request, rawBody, requestId, providerConfig }) { + const appSecret = providerConfig.appSecret as string | undefined + if (!appSecret) { + logger.warn( + `[${requestId}] WhatsApp webhook missing appSecret in providerConfig — rejecting request` + ) + return new NextResponse('Unauthorized - WhatsApp app secret not configured', { status: 401 }) + } + + const signature = request.headers.get('x-hub-signature-256') + if (!signature) { + logger.warn(`[${requestId}] WhatsApp webhook missing signature header`) + return new NextResponse('Unauthorized - Missing WhatsApp signature', { status: 401 }) + } + + if (!validateWhatsAppSignature(appSecret, signature, rawBody)) { + logger.warn(`[${requestId}] WhatsApp signature verification failed`) + return new NextResponse('Unauthorized - Invalid WhatsApp signature', { status: 401 }) + } + + return null + }, + async handleChallenge(_body: unknown, request: NextRequest, requestId: string, path: string) { const url = new URL(request.url) const mode = url.searchParams.get('hub.mode') @@ -86,33 +228,148 @@ export const whatsappHandler: WebhookProviderHandler = { return handleWhatsAppVerification(requestId, path, mode, token, challenge) }, + extractIdempotencyId(body: unknown) { + const keys = new Set() + + for (const { field, value } of getWhatsAppChanges(body)) { + if (Array.isArray(value.messages)) { + for (const message of value.messages) { + if (!isRecord(message) || typeof message.id !== 'string') { + continue + } + + keys.add(`${field ?? 'messages'}:message:${message.id}`) + } + } + + if (Array.isArray(value.statuses)) { + for (const status of value.statuses) { + if (!isRecord(status) || typeof status.id !== 'string') { + continue + } + + const statusValue = typeof status.status === 'string' ? status.status : '' + const timestamp = typeof status.timestamp === 'string' ? status.timestamp : '' + keys.add(`${field ?? 'messages'}:status:${status.id}:${statusValue}:${timestamp}`) + } + } + + if (Array.isArray(value.groups)) { + for (const group of value.groups) { + if (!isRecord(group) || typeof group.request_id !== 'string') { + continue + } + + keys.add(`${field ?? 'groups'}:group:${group.request_id}`) + } + } + } + + return buildWhatsAppIdempotencyKey(keys) + }, + + formatSuccessResponse() { + return new NextResponse(null, { status: 200 }) + }, + async formatInput({ body }: FormatInputContext): Promise { - const b = body as Record - const entry = b?.entry as Array> | undefined - const changes = entry?.[0]?.changes as Array> | undefined - const data = changes?.[0]?.value as Record | undefined - const messages = (data?.messages as Array>) || [] - - if (messages.length > 0) { - const message = messages[0] - const metadata = data?.metadata as Record | undefined - const text = message.text as Record | undefined - return { - input: { - messageId: message.id, - from: message.from, - phoneNumberId: metadata?.phone_number_id, - text: text?.body, - timestamp: message.timestamp, - raw: JSON.stringify(message), - }, + const payload = isRecord(body) ? body : undefined + const contacts: Array<{ wa_id?: string; profile?: { name?: string } }> = [] + const messages: Array<{ + messageId?: string + from?: string + phoneNumberId?: string + displayPhoneNumber?: string + text?: string + timestamp?: string + messageType?: string + raw: Record + }> = [] + const statuses: Array<{ + messageId?: string + recipientId?: string + phoneNumberId?: string + displayPhoneNumber?: string + status?: string + timestamp?: string + conversation?: Record + pricing?: Record + raw: Record + }> = [] + + for (const { value } of getWhatsAppChanges(body)) { + const metadata = isRecord(value.metadata) ? value.metadata : undefined + + if (Array.isArray(value.contacts)) { + for (const contact of value.contacts) { + if (!isRecord(contact)) { + continue + } + + contacts.push(normalizeWhatsAppContact(contact)) + } + } + + if (Array.isArray(value.messages)) { + for (const message of value.messages) { + if (!isRecord(message)) { + continue + } + + messages.push(normalizeWhatsAppMessage(message, metadata)) + } } + + if (Array.isArray(value.statuses)) { + for (const status of value.statuses) { + if (!isRecord(status)) { + continue + } + + statuses.push(normalizeWhatsAppStatus(status, metadata)) + } + } + } + + if (messages.length === 0 && statuses.length === 0) { + return { input: null } + } + + const firstMessage = messages[0] + const firstStatus = statuses[0] + + return { + input: { + eventType: + messages.length > 0 && statuses.length > 0 + ? 'mixed' + : messages.length > 0 + ? 'incoming_message' + : 'message_status', + messageId: firstMessage?.messageId ?? firstStatus?.messageId, + from: firstMessage?.from, + recipientId: firstStatus?.recipientId, + phoneNumberId: firstMessage?.phoneNumberId ?? firstStatus?.phoneNumberId, + displayPhoneNumber: firstMessage?.displayPhoneNumber ?? firstStatus?.displayPhoneNumber, + text: firstMessage?.text, + timestamp: firstMessage?.timestamp ?? firstStatus?.timestamp, + messageType: firstMessage?.messageType, + status: firstStatus?.status, + contact: contacts[0], + webhookContacts: contacts, + messages, + statuses, + conversation: firstStatus?.conversation, + pricing: firstStatus?.pricing, + raw: payload ?? body, + }, } - return { input: null } }, handleEmptyInput(requestId: string) { - logger.info(`[${requestId}] No messages in WhatsApp payload, skipping execution`) - return { message: 'No messages in WhatsApp payload' } + logger.info( + `[${requestId}] No messages or status updates in WhatsApp payload, skipping execution` + ) + return { message: 'No messages or status updates in WhatsApp payload' } }, } diff --git a/apps/sim/tools/crowdstrike/index.ts b/apps/sim/tools/crowdstrike/index.ts new file mode 100644 index 00000000000..b02f1fd3a29 --- /dev/null +++ b/apps/sim/tools/crowdstrike/index.ts @@ -0,0 +1,5 @@ +export { crowdstrikeQueryBehaviorsTool } from './query_behaviors' +export { crowdstrikeQueryCrowdScoreTool } from './query_crowdscore' +export { crowdstrikeQueryIncidentsTool } from './query_incidents' +export { crowdstrikeQuerySensorsTool } from './query_sensors' +export * from './types' diff --git a/apps/sim/tools/crowdstrike/query_behaviors.ts b/apps/sim/tools/crowdstrike/query_behaviors.ts new file mode 100644 index 00000000000..a4ec19a8c5b --- /dev/null +++ b/apps/sim/tools/crowdstrike/query_behaviors.ts @@ -0,0 +1,130 @@ +import type { + CrowdStrikeQueryBehaviorsParams, + CrowdStrikeQueryBehaviorsResponse, +} from '@/tools/crowdstrike/types' +import type { ToolConfig } from '@/tools/types' + +export const crowdstrikeQueryBehaviorsTool: ToolConfig< + CrowdStrikeQueryBehaviorsParams, + CrowdStrikeQueryBehaviorsResponse +> = { + id: 'crowdstrike_query_behaviors', + name: 'CrowdStrike Query Behaviors', + description: 'Search CrowdStrike behaviors by filter, sort order, and pagination', + version: '1.0.0', + + params: { + clientId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'CrowdStrike Falcon API client ID', + }, + clientSecret: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'CrowdStrike Falcon API client secret', + }, + cloud: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'CrowdStrike Falcon cloud region', + }, + filter: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Falcon Query Language filter for behavior search', + }, + limit: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Maximum number of behavior records to return', + }, + offset: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Pagination offset for the behavior query', + }, + sort: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Sort expression for behavior results', + }, + }, + + request: { + url: '/api/tools/crowdstrike/query', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + cloud: params.cloud, + clientId: params.clientId, + clientSecret: params.clientSecret, + filter: params.filter, + limit: params.limit, + offset: params.offset, + operation: 'crowdstrike_query_behaviors', + sort: params.sort, + }), + }, + + transformResponse: async (response) => { + const data = await response.json() + + if (!response.ok || data.success === false) { + throw new Error(data.error || 'Failed to query CrowdStrike behaviors') + } + + return { + success: true, + output: data.output, + } + }, + + outputs: { + behaviors: { + type: 'array', + description: 'Matching CrowdStrike behavior records', + items: { + type: 'object', + properties: { + behaviorId: { type: 'string', description: 'Behavior identifier', optional: true }, + incidentId: { type: 'string', description: 'Parent incident identifier', optional: true }, + name: { type: 'string', description: 'Behavior name', optional: true }, + createdTimestamp: { + type: 'string', + description: 'Behavior creation timestamp', + optional: true, + }, + }, + }, + }, + count: { + type: 'number', + description: 'Number of behaviors returned', + }, + pagination: { + type: 'json', + description: 'Pagination metadata (offset, limit, total, expiresAt)', + optional: true, + properties: { + expiresAt: { + type: 'number', + description: 'Pagination cursor expiry timestamp', + optional: true, + }, + limit: { type: 'number', description: 'Page size used for the query', optional: true }, + offset: { type: 'number', description: 'Offset returned by CrowdStrike', optional: true }, + total: { type: 'number', description: 'Total records available', optional: true }, + }, + }, + }, +} diff --git a/apps/sim/tools/crowdstrike/query_crowdscore.ts b/apps/sim/tools/crowdstrike/query_crowdscore.ts new file mode 100644 index 00000000000..828d5657a43 --- /dev/null +++ b/apps/sim/tools/crowdstrike/query_crowdscore.ts @@ -0,0 +1,126 @@ +import type { + CrowdStrikeQueryCrowdScoreParams, + CrowdStrikeQueryCrowdScoreResponse, +} from '@/tools/crowdstrike/types' +import type { ToolConfig } from '@/tools/types' + +export const crowdstrikeQueryCrowdScoreTool: ToolConfig< + CrowdStrikeQueryCrowdScoreParams, + CrowdStrikeQueryCrowdScoreResponse +> = { + id: 'crowdstrike_query_crowdscore', + name: 'CrowdStrike Query CrowdScore', + description: 'Retrieve environment-wide CrowdScore entities from CrowdStrike Falcon', + version: '1.0.0', + + params: { + clientId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'CrowdStrike Falcon API client ID', + }, + clientSecret: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'CrowdStrike Falcon API client secret', + }, + cloud: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'CrowdStrike Falcon cloud region', + }, + filter: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Falcon Query Language filter for CrowdScore search', + }, + limit: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Maximum number of CrowdScore records to return', + }, + offset: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Pagination offset for CrowdScore results', + }, + sort: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Sort expression for CrowdScore results', + }, + }, + + request: { + url: '/api/tools/crowdstrike/query', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + cloud: params.cloud, + clientId: params.clientId, + clientSecret: params.clientSecret, + filter: params.filter, + limit: params.limit, + offset: params.offset, + operation: 'crowdstrike_query_crowdscore', + sort: params.sort, + }), + }, + + transformResponse: async (response) => { + const data = await response.json() + + if (!response.ok || data.success === false) { + throw new Error(data.error || 'Failed to query CrowdStrike CrowdScore') + } + + return { + success: true, + output: data.output, + } + }, + + outputs: { + crowdScores: { + type: 'array', + description: 'CrowdStrike CrowdScore entities', + items: { + type: 'object', + properties: { + entityId: { type: 'string', description: 'Entity identifier', optional: true }, + entityType: { type: 'string', description: 'Entity type', optional: true }, + lastUpdated: { type: 'string', description: 'Last update timestamp', optional: true }, + score: { type: 'number', description: 'CrowdScore value', optional: true }, + }, + }, + }, + count: { + type: 'number', + description: 'Number of CrowdScore records returned', + }, + pagination: { + type: 'json', + description: 'Pagination metadata (offset, limit, total, expiresAt)', + optional: true, + properties: { + expiresAt: { + type: 'number', + description: 'Pagination cursor expiry timestamp', + optional: true, + }, + limit: { type: 'number', description: 'Page size used for the query', optional: true }, + offset: { type: 'number', description: 'Offset returned by CrowdStrike', optional: true }, + total: { type: 'number', description: 'Total records available', optional: true }, + }, + }, + }, +} diff --git a/apps/sim/tools/crowdstrike/query_incidents.ts b/apps/sim/tools/crowdstrike/query_incidents.ts new file mode 100644 index 00000000000..ea05893f11d --- /dev/null +++ b/apps/sim/tools/crowdstrike/query_incidents.ts @@ -0,0 +1,131 @@ +import type { + CrowdStrikeQueryIncidentsParams, + CrowdStrikeQueryIncidentsResponse, +} from '@/tools/crowdstrike/types' +import type { ToolConfig } from '@/tools/types' + +export const crowdstrikeQueryIncidentsTool: ToolConfig< + CrowdStrikeQueryIncidentsParams, + CrowdStrikeQueryIncidentsResponse +> = { + id: 'crowdstrike_query_incidents', + name: 'CrowdStrike Query Incidents', + description: 'Search CrowdStrike incidents by filter, sort order, and pagination', + version: '1.0.0', + + params: { + clientId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'CrowdStrike Falcon API client ID', + }, + clientSecret: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'CrowdStrike Falcon API client secret', + }, + cloud: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'CrowdStrike Falcon cloud region', + }, + filter: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Falcon Query Language filter for incident search', + }, + limit: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Maximum number of incident records to return', + }, + offset: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Pagination offset for the incident query', + }, + sort: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Sort expression for incident results', + }, + }, + + request: { + url: '/api/tools/crowdstrike/query', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + cloud: params.cloud, + clientId: params.clientId, + clientSecret: params.clientSecret, + filter: params.filter, + limit: params.limit, + offset: params.offset, + operation: 'crowdstrike_query_incidents', + sort: params.sort, + }), + }, + + transformResponse: async (response) => { + const data = await response.json() + + if (!response.ok || data.success === false) { + throw new Error(data.error || 'Failed to query CrowdStrike incidents') + } + + return { + success: true, + output: data.output, + } + }, + + outputs: { + incidents: { + type: 'array', + description: 'Matching CrowdStrike incident records', + items: { + type: 'object', + properties: { + incidentId: { type: 'string', description: 'Incident identifier', optional: true }, + name: { type: 'string', description: 'Incident name', optional: true }, + createdTimestamp: { + type: 'string', + description: 'Incident creation timestamp', + optional: true, + }, + status: { type: 'string', description: 'Incident status', optional: true }, + severity: { type: 'string', description: 'Incident severity', optional: true }, + }, + }, + }, + count: { + type: 'number', + description: 'Number of incidents returned', + }, + pagination: { + type: 'json', + description: 'Pagination metadata (offset, limit, total, expiresAt)', + optional: true, + properties: { + expiresAt: { + type: 'number', + description: 'Pagination cursor expiry timestamp', + optional: true, + }, + limit: { type: 'number', description: 'Page size used for the query', optional: true }, + offset: { type: 'number', description: 'Offset returned by CrowdStrike', optional: true }, + total: { type: 'number', description: 'Total records available', optional: true }, + }, + }, + }, +} diff --git a/apps/sim/tools/crowdstrike/query_sensors.ts b/apps/sim/tools/crowdstrike/query_sensors.ts new file mode 100644 index 00000000000..43ee4736526 --- /dev/null +++ b/apps/sim/tools/crowdstrike/query_sensors.ts @@ -0,0 +1,126 @@ +import type { + CrowdStrikeQuerySensorsParams, + CrowdStrikeQuerySensorsResponse, +} from '@/tools/crowdstrike/types' +import type { ToolConfig } from '@/tools/types' + +export const crowdstrikeQuerySensorsTool: ToolConfig< + CrowdStrikeQuerySensorsParams, + CrowdStrikeQuerySensorsResponse +> = { + id: 'crowdstrike_query_sensors', + name: 'CrowdStrike Query Sensors', + description: 'Search CrowdStrike identity protection sensors by hostname, IP, or related fields', + version: '1.0.0', + + params: { + clientId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'CrowdStrike Falcon API client ID', + }, + clientSecret: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'CrowdStrike Falcon API client secret', + }, + cloud: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'CrowdStrike Falcon cloud region', + }, + filter: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Falcon Query Language filter for identity sensor search', + }, + limit: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Maximum number of sensor records to return', + }, + offset: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Pagination offset for the identity sensor query', + }, + sort: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Sort expression for identity sensor results', + }, + }, + + request: { + url: '/api/tools/crowdstrike/query', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + cloud: params.cloud, + clientId: params.clientId, + clientSecret: params.clientSecret, + filter: params.filter, + limit: params.limit, + offset: params.offset, + operation: 'crowdstrike_query_sensors', + sort: params.sort, + }), + }, + + transformResponse: async (response) => { + const data = await response.json() + + if (!response.ok || data.success === false) { + throw new Error(data.error || 'Failed to query CrowdStrike sensors') + } + + return { + success: true, + output: data.output, + } + }, + + outputs: { + sensors: { + type: 'array', + description: 'Matching CrowdStrike identity sensor records', + items: { + type: 'object', + properties: { + agentId: { type: 'string', description: 'Sensor agent identifier', optional: true }, + hostname: { type: 'string', description: 'Sensor hostname', optional: true }, + ipAddress: { type: 'string', description: 'Sensor IP address', optional: true }, + macAddress: { type: 'string', description: 'Sensor MAC address', optional: true }, + }, + }, + }, + count: { + type: 'number', + description: 'Number of sensors returned', + }, + pagination: { + type: 'json', + description: 'Pagination metadata (offset, limit, total, expiresAt)', + optional: true, + properties: { + expiresAt: { + type: 'number', + description: 'Pagination cursor expiry timestamp', + optional: true, + }, + limit: { type: 'number', description: 'Page size used for the query', optional: true }, + offset: { type: 'number', description: 'Offset returned by CrowdStrike', optional: true }, + total: { type: 'number', description: 'Total records available', optional: true }, + }, + }, + }, +} diff --git a/apps/sim/tools/crowdstrike/types.ts b/apps/sim/tools/crowdstrike/types.ts new file mode 100644 index 00000000000..316d209a4a4 --- /dev/null +++ b/apps/sim/tools/crowdstrike/types.ts @@ -0,0 +1,111 @@ +import type { ToolResponse } from '@/tools/types' + +export type CrowdStrikeCloud = 'us-1' | 'us-2' | 'eu-1' | 'us-gov-1' | 'us-gov-2' + +export interface CrowdStrikeBaseParams { + clientId: string + clientSecret: string + cloud: CrowdStrikeCloud +} + +export interface CrowdStrikeQuerySensorsParams extends CrowdStrikeBaseParams { + filter?: string + limit?: number + offset?: number + sort?: string +} + +export interface CrowdStrikeQueryCrowdScoreParams extends CrowdStrikeBaseParams { + filter?: string + limit?: number + offset?: number + sort?: string +} + +export interface CrowdStrikeQueryIncidentsParams extends CrowdStrikeBaseParams { + filter?: string + limit?: number + offset?: number + sort?: string +} + +export interface CrowdStrikeQueryBehaviorsParams extends CrowdStrikeBaseParams { + filter?: string + limit?: number + offset?: number + sort?: string +} + +export interface CrowdStrikePagination { + expiresAt: number | null + limit: number | null + offset: number | string | null + total: number | null +} + +export interface CrowdStrikeSensor { + agentId: string | null + hostname: string | null + ipAddress: string | null + macAddress: string | null +} + +export interface CrowdStrikeCrowdScore { + entityId: string | null + entityType: string | null + lastUpdated: string | null + score: number | null +} + +export interface CrowdStrikeIncident { + createdTimestamp: string | null + incidentId: string | null + name: string | null + severity: string | null + status: string | null +} + +export interface CrowdStrikeBehavior { + behaviorId: string | null + createdTimestamp: string | null + incidentId: string | null + name: string | null +} + +export interface CrowdStrikeQuerySensorsResponse extends ToolResponse { + output: { + count: number + pagination: CrowdStrikePagination | null + sensors: CrowdStrikeSensor[] + } +} + +export interface CrowdStrikeQueryCrowdScoreResponse extends ToolResponse { + output: { + count: number + crowdScores: CrowdStrikeCrowdScore[] + pagination: CrowdStrikePagination | null + } +} + +export interface CrowdStrikeQueryIncidentsResponse extends ToolResponse { + output: { + count: number + incidents: CrowdStrikeIncident[] + pagination: CrowdStrikePagination | null + } +} + +export interface CrowdStrikeQueryBehaviorsResponse extends ToolResponse { + output: { + behaviors: CrowdStrikeBehavior[] + count: number + pagination: CrowdStrikePagination | null + } +} + +export type CrowdStrikeResponse = + | CrowdStrikeQuerySensorsResponse + | CrowdStrikeQueryCrowdScoreResponse + | CrowdStrikeQueryIncidentsResponse + | CrowdStrikeQueryBehaviorsResponse diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index 76b98a0d87c..23e2d9971da 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -352,6 +352,12 @@ import { confluenceUpdateTool, confluenceUploadAttachmentTool, } from '@/tools/confluence' +import { + crowdstrikeQueryBehaviorsTool, + crowdstrikeQueryCrowdScoreTool, + crowdstrikeQueryIncidentsTool, + crowdstrikeQuerySensorsTool, +} from '@/tools/crowdstrike' import { cursorAddFollowupTool, cursorAddFollowupV2Tool, @@ -3465,6 +3471,10 @@ export const tools: Record = { cloudwatch_list_metrics: cloudwatchListMetricsTool, cloudwatch_put_metric_data: cloudwatchPutMetricDataTool, cloudwatch_query_logs: cloudwatchQueryLogsTool, + crowdstrike_query_behaviors: crowdstrikeQueryBehaviorsTool, + crowdstrike_query_crowdscore: crowdstrikeQueryCrowdScoreTool, + crowdstrike_query_incidents: crowdstrikeQueryIncidentsTool, + crowdstrike_query_sensors: crowdstrikeQuerySensorsTool, dynamodb_get: dynamodbGetTool, dynamodb_put: dynamodbPutTool, dynamodb_query: dynamodbQueryTool, diff --git a/apps/sim/tools/shopify/adjust_inventory.ts b/apps/sim/tools/shopify/adjust_inventory.ts index a373406ad65..04780af9aaf 100644 --- a/apps/sim/tools/shopify/adjust_inventory.ts +++ b/apps/sim/tools/shopify/adjust_inventory.ts @@ -1,10 +1,13 @@ -import type { ShopifyAdjustInventoryParams, ShopifyInventoryResponse } from '@/tools/shopify/types' +import type { + ShopifyAdjustInventoryParams, + ShopifyInventoryAdjustmentResponse, +} from '@/tools/shopify/types' import { INVENTORY_ADJUSTMENT_OUTPUT_PROPERTIES } from '@/tools/shopify/types' import type { ToolConfig } from '@/tools/types' export const shopifyAdjustInventoryTool: ToolConfig< ShopifyAdjustInventoryParams, - ShopifyInventoryResponse + ShopifyInventoryAdjustmentResponse > = { id: 'shopify_adjust_inventory', name: 'Shopify Adjust Inventory', @@ -101,8 +104,8 @@ export const shopifyAdjustInventoryTool: ToolConfig< name: 'available', changes: [ { - inventoryItemId: params.inventoryItemId, - locationId: params.locationId, + inventoryItemId: params.inventoryItemId.trim(), + locationId: params.locationId.trim(), delta: params.delta, }, ], diff --git a/apps/sim/tools/shopify/cancel_order.ts b/apps/sim/tools/shopify/cancel_order.ts index 775eefe5f4f..97c9eb317c6 100644 --- a/apps/sim/tools/shopify/cancel_order.ts +++ b/apps/sim/tools/shopify/cancel_order.ts @@ -1,8 +1,11 @@ -import type { ShopifyCancelOrderParams, ShopifyOrderResponse } from '@/tools/shopify/types' +import type { ShopifyCancelOrderParams, ShopifyCancelOrderResponse } from '@/tools/shopify/types' import { CANCEL_ORDER_OUTPUT_PROPERTIES } from '@/tools/shopify/types' import type { ToolConfig } from '@/tools/types' -export const shopifyCancelOrderTool: ToolConfig = { +export const shopifyCancelOrderTool: ToolConfig< + ShopifyCancelOrderParams, + ShopifyCancelOrderResponse +> = { id: 'shopify_cancel_order', name: 'Shopify Cancel Order', description: 'Cancel an order in your Shopify store', @@ -38,17 +41,18 @@ export const shopifyCancelOrderTool: ToolConfig - fulfillmentLineItems: Array<{ - id: string - quantity: number - lineItem: { - title: string - } - }> - } - } -} +import type { ToolConfig } from '@/tools/types' export const shopifyCreateFulfillmentTool: ToolConfig< ShopifyCreateFulfillmentParams, - ShopifyCreateFulfillmentResponse + ShopifyFulfillmentResponse > = { id: 'shopify_create_fulfillment', name: 'Shopify Create Fulfillment', @@ -125,7 +97,7 @@ export const shopifyCreateFulfillmentTool: ToolConfig< } = { lineItemsByFulfillmentOrder: [ { - fulfillmentOrderId: params.fulfillmentOrderId, + fulfillmentOrderId: params.fulfillmentOrderId.trim(), }, ], notifyCustomer: params.notifyCustomer !== false, // Default to true @@ -138,8 +110,8 @@ export const shopifyCreateFulfillmentTool: ToolConfig< return { query: ` - mutation fulfillmentCreateV2($fulfillment: FulfillmentV2Input!) { - fulfillmentCreateV2(fulfillment: $fulfillment) { + mutation fulfillmentCreate($fulfillment: FulfillmentInput!) { + fulfillmentCreate(fulfillment: $fulfillment) { fulfillment { id status @@ -187,7 +159,7 @@ export const shopifyCreateFulfillmentTool: ToolConfig< } } - const result = data.data?.fulfillmentCreateV2 + const result = data.data?.fulfillmentCreate if (!result) { return { success: false, diff --git a/apps/sim/tools/shopify/create_product.ts b/apps/sim/tools/shopify/create_product.ts index a3b5855f536..bfee95f7c5f 100644 --- a/apps/sim/tools/shopify/create_product.ts +++ b/apps/sim/tools/shopify/create_product.ts @@ -101,8 +101,8 @@ export const shopifyCreateProductTool: ToolConfig< return { query: ` - mutation productCreate($input: ProductInput!) { - productCreate(input: $input) { + mutation productCreate($product: ProductCreateInput!) { + productCreate(product: $product) { product { id title @@ -144,7 +144,7 @@ export const shopifyCreateProductTool: ToolConfig< } `, variables: { - input, + product: input, }, } }, diff --git a/apps/sim/tools/shopify/get_collection.ts b/apps/sim/tools/shopify/get_collection.ts index c1e506838ed..480ea0ab07b 100644 --- a/apps/sim/tools/shopify/get_collection.ts +++ b/apps/sim/tools/shopify/get_collection.ts @@ -1,47 +1,10 @@ -import type { ShopifyBaseParams } from '@/tools/shopify/types' +import type { ShopifyCollectionResponse, ShopifyGetCollectionParams } from '@/tools/shopify/types' import { COLLECTION_WITH_PRODUCTS_OUTPUT_PROPERTIES } from '@/tools/shopify/types' -import type { ToolConfig, ToolResponse } from '@/tools/types' - -interface ShopifyGetCollectionParams extends ShopifyBaseParams { - collectionId: string - productsFirst?: number -} - -interface ShopifyGetCollectionResponse extends ToolResponse { - output: { - collection?: { - id: string - title: string - handle: string - description: string | null - descriptionHtml: string | null - productsCount: number - sortOrder: string - updatedAt: string - image: { - url: string - altText: string | null - } | null - products: Array<{ - id: string - title: string - handle: string - status: string - vendor: string - productType: string - totalInventory: number - featuredImage: { - url: string - altText: string | null - } | null - }> - } - } -} +import type { ToolConfig } from '@/tools/types' export const shopifyGetCollectionTool: ToolConfig< ShopifyGetCollectionParams, - ShopifyGetCollectionResponse + ShopifyCollectionResponse > = { id: 'shopify_get_collection', name: 'Shopify Get Collection', @@ -106,6 +69,7 @@ export const shopifyGetCollectionTool: ToolConfig< sortOrder updatedAt image { + id url altText } @@ -134,7 +98,7 @@ export const shopifyGetCollectionTool: ToolConfig< } `, variables: { - id: params.collectionId, + id: params.collectionId.trim(), productsFirst, }, } diff --git a/apps/sim/tools/shopify/get_inventory_level.ts b/apps/sim/tools/shopify/get_inventory_level.ts index cf0a7d01e60..b39f9ca56f9 100644 --- a/apps/sim/tools/shopify/get_inventory_level.ts +++ b/apps/sim/tools/shopify/get_inventory_level.ts @@ -84,13 +84,13 @@ export const shopifyGetInventoryLevelTool: ToolConfig< } `, variables: { - id: params.inventoryItemId, + id: params.inventoryItemId.trim(), }, } }, }, - transformResponse: async (response) => { + transformResponse: async (response, params) => { const data = await response.json() if (data.errors) { @@ -110,31 +110,45 @@ export const shopifyGetInventoryLevelTool: ToolConfig< } } - const inventoryLevels = inventoryItem.inventoryLevels.edges.map( - (edge: { - node: { - id: string - quantities: Array<{ name: string; quantity: number }> - location: { id: string; name: string } - } - }) => { - const node = edge.node - // Extract quantities into a more usable format - const quantitiesMap: Record = {} - node.quantities.forEach((q) => { - quantitiesMap[q.name] = q.quantity - }) - return { - id: node.id, - available: quantitiesMap.available ?? 0, - onHand: quantitiesMap.on_hand ?? 0, - committed: quantitiesMap.committed ?? 0, - incoming: quantitiesMap.incoming ?? 0, - reserved: quantitiesMap.reserved ?? 0, - location: node.location, + const requestedLocationId = params?.locationId?.trim() + const inventoryLevels = inventoryItem.inventoryLevels.edges + .map( + (edge: { + node: { + id: string + quantities: Array<{ name: string; quantity: number }> + location: { id: string; name: string } + } + }) => { + const node = edge.node + // Extract quantities into a more usable format + const quantitiesMap: Record = {} + node.quantities.forEach((q) => { + quantitiesMap[q.name] = q.quantity + }) + return { + id: node.id, + available: quantitiesMap.available ?? 0, + onHand: quantitiesMap.on_hand ?? 0, + committed: quantitiesMap.committed ?? 0, + incoming: quantitiesMap.incoming ?? 0, + reserved: quantitiesMap.reserved ?? 0, + location: node.location, + } } + ) + .filter( + (level: { location: { id: string } }) => + !requestedLocationId || level.location.id === requestedLocationId + ) + + if (requestedLocationId && inventoryLevels.length === 0) { + return { + success: false, + error: 'No inventory level found for the provided location', + output: {}, } - ) + } return { success: true, diff --git a/apps/sim/tools/shopify/index.ts b/apps/sim/tools/shopify/index.ts index 438199559e6..5d0e541a8fd 100644 --- a/apps/sim/tools/shopify/index.ts +++ b/apps/sim/tools/shopify/index.ts @@ -24,6 +24,7 @@ export { shopifyListInventoryItemsTool } from './list_inventory_items' export { shopifyListLocationsTool } from './list_locations' export { shopifyListOrdersTool } from './list_orders' export { shopifyListProductsTool } from './list_products' +export * from './types' export { shopifyUpdateCustomerTool } from './update_customer' export { shopifyUpdateOrderTool } from './update_order' export { shopifyUpdateProductTool } from './update_product' diff --git a/apps/sim/tools/shopify/list_collections.ts b/apps/sim/tools/shopify/list_collections.ts index 55a791cc502..e50ec507753 100644 --- a/apps/sim/tools/shopify/list_collections.ts +++ b/apps/sim/tools/shopify/list_collections.ts @@ -1,34 +1,9 @@ -import type { ShopifyBaseParams } from '@/tools/shopify/types' +import type { + ShopifyCollectionsResponse, + ShopifyListCollectionsParams, +} from '@/tools/shopify/types' import { COLLECTION_OUTPUT_PROPERTIES, PAGE_INFO_OUTPUT_PROPERTIES } from '@/tools/shopify/types' -import type { ToolConfig, ToolResponse } from '@/tools/types' - -interface ShopifyListCollectionsParams extends ShopifyBaseParams { - first?: number - query?: string -} - -interface ShopifyCollectionsResponse extends ToolResponse { - output: { - collections?: Array<{ - id: string - title: string - handle: string - description: string | null - descriptionHtml: string | null - productsCount: number - sortOrder: string - updatedAt: string - image: { - url: string - altText: string | null - } | null - }> - pageInfo?: { - hasNextPage: boolean - hasPreviousPage: boolean - } - } -} +import type { ToolConfig } from '@/tools/types' export const shopifyListCollectionsTool: ToolConfig< ShopifyListCollectionsParams, @@ -100,6 +75,7 @@ export const shopifyListCollectionsTool: ToolConfig< sortOrder updatedAt image { + id url altText } @@ -151,7 +127,7 @@ export const shopifyListCollectionsTool: ToolConfig< productsCount: { count: number } sortOrder: string updatedAt: string - image: { url: string; altText: string | null } | null + image: { id: string; url: string; altText: string | null } | null } }) => ({ id: edge.node.id, diff --git a/apps/sim/tools/shopify/list_inventory_items.ts b/apps/sim/tools/shopify/list_inventory_items.ts index 9e4a33dc004..97c3992a733 100644 --- a/apps/sim/tools/shopify/list_inventory_items.ts +++ b/apps/sim/tools/shopify/list_inventory_items.ts @@ -1,46 +1,10 @@ -import type { ShopifyBaseParams } from '@/tools/shopify/types' import { INVENTORY_ITEM_OUTPUT_PROPERTIES, PAGE_INFO_OUTPUT_PROPERTIES, + type ShopifyInventoryItemsResponse, + type ShopifyListInventoryItemsParams, } from '@/tools/shopify/types' -import type { ToolConfig, ToolResponse } from '@/tools/types' - -interface ShopifyListInventoryItemsParams extends ShopifyBaseParams { - first?: number - query?: string -} - -interface ShopifyInventoryItemsResponse extends ToolResponse { - output: { - inventoryItems?: Array<{ - id: string - sku: string | null - tracked: boolean - createdAt: string - updatedAt: string - variant?: { - id: string - title: string - product?: { - id: string - title: string - } - } - inventoryLevels: Array<{ - id: string - available: number - location: { - id: string - name: string - } - }> - }> - pageInfo?: { - hasNextPage: boolean - hasPreviousPage: boolean - } - } -} +import type { ToolConfig } from '@/tools/types' export const shopifyListInventoryItemsTool: ToolConfig< ShopifyListInventoryItemsParams, diff --git a/apps/sim/tools/shopify/list_locations.ts b/apps/sim/tools/shopify/list_locations.ts index dd12e9b3ff8..6d6e675f5be 100644 --- a/apps/sim/tools/shopify/list_locations.ts +++ b/apps/sim/tools/shopify/list_locations.ts @@ -1,37 +1,6 @@ -import type { ShopifyBaseParams } from '@/tools/shopify/types' +import type { ShopifyListLocationsParams, ShopifyLocationsResponse } from '@/tools/shopify/types' import { LOCATION_OUTPUT_PROPERTIES, PAGE_INFO_OUTPUT_PROPERTIES } from '@/tools/shopify/types' -import type { ToolConfig, ToolResponse } from '@/tools/types' - -interface ShopifyListLocationsParams extends ShopifyBaseParams { - first?: number - includeInactive?: boolean -} - -interface ShopifyLocationsResponse extends ToolResponse { - output: { - locations?: Array<{ - id: string - name: string - isActive: boolean - fulfillsOnlineOrders: boolean - address: { - address1: string | null - address2: string | null - city: string | null - province: string | null - provinceCode: string | null - country: string | null - countryCode: string | null - zip: string | null - phone: string | null - } | null - }> - pageInfo?: { - hasNextPage: boolean - hasPreviousPage: boolean - } - } -} +import type { ToolConfig } from '@/tools/types' export const shopifyListLocationsTool: ToolConfig< ShopifyListLocationsParams, diff --git a/apps/sim/tools/shopify/types.ts b/apps/sim/tools/shopify/types.ts index 986384fe00c..66471b6a59b 100644 --- a/apps/sim/tools/shopify/types.ts +++ b/apps/sim/tools/shopify/types.ts @@ -64,6 +64,11 @@ const IMAGE_PROPERTIES = { altText: { type: 'string', description: 'Alternative text for accessibility', optional: true }, } as const satisfies Record +const FEATURED_IMAGE_OUTPUT_PROPERTIES = { + url: { type: 'string', description: 'Featured image URL' }, + altText: { type: 'string', description: 'Alternative text for accessibility', optional: true }, +} as const satisfies Record + /** Tracking info properties from Shopify FulfillmentTrackingInfo object */ const TRACKING_INFO_PROPERTIES = { company: { type: 'string', description: 'Shipping carrier name', optional: true }, @@ -156,6 +161,7 @@ export const CUSTOMER_OUTPUT_PROPERTIES = { type: 'object', properties: ADDRESS_PROPERTIES, }, + optional: true, }, defaultAddress: { type: 'object', @@ -245,16 +251,19 @@ export const ORDER_OUTPUT_PROPERTIES = { type: 'object', description: 'Order subtotal (before shipping and taxes)', properties: MONEY_BAG_PROPERTIES, + optional: true, }, totalTaxSet: { type: 'object', description: 'Total tax amount', properties: MONEY_BAG_PROPERTIES, + optional: true, }, totalShippingPriceSet: { type: 'object', description: 'Total shipping price', properties: MONEY_BAG_PROPERTIES, + optional: true, }, note: { type: 'string', description: 'Order note', optional: true }, tags: { @@ -287,6 +296,7 @@ export const ORDER_OUTPUT_PROPERTIES = { }, }, }, + optional: true, }, shippingAddress: { type: 'object', @@ -307,6 +317,7 @@ export const ORDER_OUTPUT_PROPERTIES = { type: 'object', properties: FULFILLMENT_PROPERTIES, }, + optional: true, }, } as const satisfies Record @@ -401,7 +412,7 @@ export const COLLECTION_WITH_PRODUCTS_OUTPUT_PROPERTIES = { featuredImage: { type: 'object', description: 'Featured product image', - properties: IMAGE_PROPERTIES, + properties: FEATURED_IMAGE_OUTPUT_PROPERTIES, optional: true, }, }, @@ -770,10 +781,12 @@ export interface ShopifyUpdateOrderParams extends ShopifyBaseParams { export interface ShopifyCancelOrderParams extends ShopifyBaseParams { orderId: string - reason: 'CUSTOMER' | 'FRAUD' | 'INVENTORY' | 'DECLINED' | 'OTHER' + reason: 'CUSTOMER' | 'DECLINED' | 'FRAUD' | 'INVENTORY' | 'OTHER' | 'STAFF' + restock: boolean notifyCustomer?: boolean - refund?: boolean - restock?: boolean + refundMethod?: { + originalPaymentMethodsRefund?: boolean + } staffNote?: string } @@ -839,14 +852,33 @@ export interface ShopifySetInventoryParams extends ShopifyBaseParams { // Fulfillment Tool Params export interface ShopifyCreateFulfillmentParams extends ShopifyBaseParams { - orderId: string - lineItemIds?: string[] + fulfillmentOrderId: string trackingNumber?: string trackingCompany?: string trackingUrl?: string notifyCustomer?: boolean } +export interface ShopifyListInventoryItemsParams extends ShopifyBaseParams { + first?: number + query?: string +} + +export interface ShopifyListLocationsParams extends ShopifyBaseParams { + first?: number + includeInactive?: boolean +} + +export interface ShopifyListCollectionsParams extends ShopifyBaseParams { + first?: number + query?: string +} + +export interface ShopifyGetCollectionParams extends ShopifyBaseParams { + collectionId: string + productsFirst?: number +} + // Tool Response Types export interface ShopifyProductResponse extends ToolResponse { output: { @@ -870,6 +902,16 @@ export interface ShopifyOrderResponse extends ToolResponse { } } +export interface ShopifyCancelOrderResponse extends ToolResponse { + output: { + order?: { + id: string + cancelled: boolean + message: string + } + } +} + export interface ShopifyOrdersResponse extends ToolResponse { output: { orders?: ShopifyOrder[] @@ -902,9 +944,156 @@ export interface ShopifyInventoryResponse extends ToolResponse { } } +export interface ShopifyInventoryAdjustmentResponse extends ToolResponse { + output: { + inventoryLevel?: { + adjustmentGroup: { + createdAt: string + reason: string + } + changes: Array<{ + name: string + delta: number + quantityAfterChange: number + item: { + id: string + sku: string | null + } + location: { + id: string + name: string + } + }> + } + } +} + export interface ShopifyFulfillmentResponse extends ToolResponse { output: { - fulfillment?: ShopifyFulfillment + fulfillment?: ShopifyFulfillment & { + fulfillmentLineItems: Array<{ + id: string + quantity: number + lineItem: { + title: string + } + }> + } + } +} + +export interface ShopifyInventoryItemsResponse extends ToolResponse { + output: { + inventoryItems?: Array<{ + id: string + sku: string | null + tracked: boolean + createdAt: string + updatedAt: string + variant?: { + id: string + title: string + product?: { + id: string + title: string + } + } + inventoryLevels: Array<{ + id: string + available: number + location: { + id: string + name: string + } + }> + }> + pageInfo?: { + hasNextPage: boolean + hasPreviousPage: boolean + } + } +} + +export interface ShopifyLocationsResponse extends ToolResponse { + output: { + locations?: Array<{ + id: string + name: string + isActive: boolean + fulfillsOnlineOrders: boolean + address: { + address1: string | null + address2: string | null + city: string | null + province: string | null + provinceCode: string | null + country: string | null + countryCode: string | null + zip: string | null + phone: string | null + } | null + }> + pageInfo?: { + hasNextPage: boolean + hasPreviousPage: boolean + } + } +} + +export interface ShopifyCollectionsResponse extends ToolResponse { + output: { + collections?: Array<{ + id: string + title: string + handle: string + description: string | null + descriptionHtml: string | null + productsCount: number + sortOrder: string + updatedAt: string + image: { + id: string + url: string + altText: string | null + } | null + }> + pageInfo?: { + hasNextPage: boolean + hasPreviousPage: boolean + } + } +} + +export interface ShopifyCollectionResponse extends ToolResponse { + output: { + collection?: { + id: string + title: string + handle: string + description: string | null + descriptionHtml: string | null + productsCount: number + sortOrder: string + updatedAt: string + image: { + id: string + url: string + altText: string | null + } | null + products: Array<{ + id: string + title: string + handle: string + status: string + vendor: string + productType: string + totalInventory: number + featuredImage: { + url: string + altText: string | null + } | null + }> + } } } diff --git a/apps/sim/tools/shopify/update_product.ts b/apps/sim/tools/shopify/update_product.ts index 8554158bad7..caa2adbf930 100644 --- a/apps/sim/tools/shopify/update_product.ts +++ b/apps/sim/tools/shopify/update_product.ts @@ -110,8 +110,8 @@ export const shopifyUpdateProductTool: ToolConfig< return { query: ` - mutation productUpdate($input: ProductInput!) { - productUpdate(input: $input) { + mutation productUpdate($product: ProductUpdateInput!) { + productUpdate(product: $product) { product { id title @@ -153,7 +153,7 @@ export const shopifyUpdateProductTool: ToolConfig< } `, variables: { - input, + product: input, }, } }, diff --git a/apps/sim/tools/trello/add_comment.ts b/apps/sim/tools/trello/add_comment.ts index efd9aefa23e..2fa35b5391d 100644 --- a/apps/sim/tools/trello/add_comment.ts +++ b/apps/sim/tools/trello/add_comment.ts @@ -1,4 +1,9 @@ import { env } from '@/lib/core/config/env' +import { + extractTrelloErrorMessage, + mapTrelloComment, + TRELLO_API_BASE_URL, +} from '@/tools/trello/shared' import type { TrelloAddCommentParams, TrelloAddCommentResponse } from '@/tools/trello/types' import type { ToolConfig } from '@/tools/types' @@ -39,56 +44,150 @@ export const trelloAddCommentTool: ToolConfig ({ - 'Content-Type': 'application/json', - Accept: 'application/json', - }), - body: (params) => { if (!params.text) { throw new Error('Comment text is required') } + const apiKey = env.TRELLO_API_KEY - return { - text: params.text, + if (!apiKey) { + throw new Error('TRELLO_API_KEY environment variable is not set') } + + const url = new URL(`${TRELLO_API_BASE_URL}/cards/${params.cardId.trim()}/actions/comments`) + url.searchParams.set('key', apiKey) + url.searchParams.set('token', params.accessToken) + url.searchParams.set('text', params.text) + + return url.toString() }, + method: 'POST', + headers: () => ({ + Accept: 'application/json', + }), }, transformResponse: async (response) => { - const data = await response.json() + const data = await response.json().catch(() => null) + + if (!response.ok) { + const error = extractTrelloErrorMessage(response, data, 'Failed to add comment') - if (!data?.id) { return { success: false, output: { - error: data?.message || 'Failed to add comment', + error, }, - error: data?.message || 'Failed to add comment', + error, } } - return { - success: true, - output: { - comment: { - id: data.id, - text: data.data?.text, - date: data.date, - memberCreator: data.memberCreator, + try { + const comment = mapTrelloComment(data) + + return { + success: true, + output: { + comment, }, - }, + } + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to parse created comment' + + return { + success: false, + output: { + error: message, + }, + error: message, + } } }, outputs: { comment: { - type: 'object', - description: 'The created comment object with id, text, date, and member creator', + type: 'json', + description: + 'Created comment action (id, type, date, idMemberCreator, text, memberCreator, card, board, list)', + optional: true, + properties: { + id: { type: 'string', description: 'Action ID' }, + type: { type: 'string', description: 'Action type' }, + date: { type: 'string', description: 'Action timestamp' }, + idMemberCreator: { + type: 'string', + description: 'ID of the member who created the comment', + }, + text: { + type: 'string', + description: 'Comment text', + optional: true, + }, + memberCreator: { + type: 'object', + description: 'Member who created the comment', + optional: true, + properties: { + id: { type: 'string', description: 'Member ID' }, + fullName: { + type: 'string', + description: 'Member full name', + optional: true, + }, + username: { + type: 'string', + description: 'Member username', + optional: true, + }, + }, + }, + card: { + type: 'object', + description: 'Card referenced by the comment', + optional: true, + properties: { + id: { type: 'string', description: 'Card ID' }, + name: { type: 'string', description: 'Card name' }, + shortLink: { + type: 'string', + description: 'Short card link', + optional: true, + }, + idShort: { + type: 'number', + description: 'Board-local card number', + optional: true, + }, + due: { + type: 'string', + description: 'Card due date', + optional: true, + }, + }, + }, + board: { + type: 'object', + description: 'Board referenced by the comment', + optional: true, + properties: { + id: { type: 'string', description: 'Board ID' }, + name: { type: 'string', description: 'Board name' }, + shortLink: { + type: 'string', + description: 'Short board link', + optional: true, + }, + }, + }, + list: { + type: 'object', + description: 'List referenced by the comment', + optional: true, + properties: { + id: { type: 'string', description: 'List ID' }, + name: { type: 'string', description: 'List name' }, + }, + }, + }, }, }, } diff --git a/apps/sim/tools/trello/create_card.ts b/apps/sim/tools/trello/create_card.ts index e552cab404b..50712859237 100644 --- a/apps/sim/tools/trello/create_card.ts +++ b/apps/sim/tools/trello/create_card.ts @@ -1,11 +1,16 @@ import { env } from '@/lib/core/config/env' +import { + extractTrelloErrorMessage, + mapTrelloCard, + TRELLO_API_BASE_URL, +} from '@/tools/trello/shared' import type { TrelloCreateCardParams, TrelloCreateCardResponse } from '@/tools/trello/types' import type { ToolConfig } from '@/tools/types' export const trelloCreateCardTool: ToolConfig = { id: 'trello_create_card', name: 'Trello Create Card', - description: 'Create a new card on a Trello board', + description: 'Create a new card in a Trello list', version: '1.0.0', oauth: { @@ -20,12 +25,6 @@ export const trelloCreateCardTool: ToolConfig { - const apiKey = env.TRELLO_API_KEY || '' - const token = params.accessToken - return `https://api.trello.com/1/cards?key=${apiKey}&token=${token}` + const apiKey = env.TRELLO_API_KEY + + if (!apiKey) { + throw new Error('TRELLO_API_KEY environment variable is not set') + } + + const url = new URL(`${TRELLO_API_BASE_URL}/cards`) + url.searchParams.set('key', apiKey) + url.searchParams.set('token', params.accessToken) + + return url.toString() }, method: 'POST', headers: () => ({ @@ -83,45 +100,107 @@ export const trelloCreateCardTool: ToolConfig = { - idList: params.listId, + const body: Record = { + idList: params.listId.trim(), name: params.name, } if (params.desc) body.desc = params.desc if (params.pos) body.pos = params.pos if (params.due) body.due = params.due - if (params.labels) body.idLabels = params.labels + if (params.dueComplete !== undefined) body.dueComplete = params.dueComplete + if (params.labelIds?.length) body.idLabels = params.labelIds return body }, }, transformResponse: async (response) => { - const data = await response.json() + const data = await response.json().catch(() => null) + + if (!response.ok) { + const error = extractTrelloErrorMessage(response, data, 'Failed to create card') - if (!data?.id) { return { success: false, output: { - error: data?.message || 'Failed to create card', + error, }, - error: data?.message || 'Failed to create card', + error, } } - return { - success: true, - output: { - card: data, - }, + try { + const card = mapTrelloCard(data) + + return { + success: true, + output: { + card, + }, + } + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to parse created card' + + return { + success: false, + output: { + error: message, + }, + error: message, + } } }, outputs: { card: { - type: 'object', - description: 'The created card object with id, name, desc, url, and other properties', + type: 'json', + description: + 'Created card (id, name, desc, url, idBoard, idList, closed, labelIds, labels, due, dueComplete)', + optional: true, + properties: { + id: { type: 'string', description: 'Card ID' }, + name: { type: 'string', description: 'Card name' }, + desc: { type: 'string', description: 'Card description' }, + url: { type: 'string', description: 'Full card URL' }, + idBoard: { type: 'string', description: 'Board ID containing the card' }, + idList: { type: 'string', description: 'List ID containing the card' }, + closed: { type: 'boolean', description: 'Whether the card is archived' }, + labelIds: { + type: 'array', + description: 'Label IDs applied to the card', + items: { + type: 'string', + description: 'A Trello label ID', + }, + }, + labels: { + type: 'array', + description: 'Labels applied to the card', + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'Label ID' }, + name: { type: 'string', description: 'Label name' }, + color: { + type: 'string', + description: 'Label color', + optional: true, + }, + }, + }, + }, + due: { + type: 'string', + description: 'Card due date in ISO 8601 format', + optional: true, + }, + dueComplete: { + type: 'boolean', + description: 'Whether the due date is complete', + optional: true, + }, + }, }, }, } diff --git a/apps/sim/tools/trello/get_actions.ts b/apps/sim/tools/trello/get_actions.ts index 1067a8639c6..fd77dfbb9f9 100644 --- a/apps/sim/tools/trello/get_actions.ts +++ b/apps/sim/tools/trello/get_actions.ts @@ -1,4 +1,9 @@ import { env } from '@/lib/core/config/env' +import { + extractTrelloErrorMessage, + mapTrelloAction, + TRELLO_API_BASE_URL, +} from '@/tools/trello/shared' import type { TrelloGetActionsParams, TrelloGetActionsResponse } from '@/tools/trello/types' import type { ToolConfig } from '@/tools/types' @@ -42,7 +47,13 @@ export const trelloGetActionsTool: ToolConfig ({ @@ -78,33 +100,148 @@ export const trelloGetActionsTool: ToolConfig { - const data = await response.json() + const data = await response.json().catch(() => null) + + if (!response.ok) { + const error = extractTrelloErrorMessage(response, data, 'Failed to get Trello actions') + + return { + success: false, + output: { + actions: [], + count: 0, + error, + }, + error, + } + } if (!Array.isArray(data)) { + const error = 'Trello returned an invalid action collection' + return { success: false, output: { actions: [], count: 0, - error: 'Invalid response from Trello API', + error, }, - error: 'Invalid response from Trello API', + error, } } - return { - success: true, - output: { - actions: data, - count: data.length, - }, + try { + const actions = data.map((item) => mapTrelloAction(item)) + + return { + success: true, + output: { + actions, + count: actions.length, + }, + } + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to parse Trello actions' + + return { + success: false, + output: { + actions: [], + count: 0, + error: message, + }, + error: message, + } } }, outputs: { actions: { type: 'array', - description: 'Array of action objects with type, date, member, and data', + description: + 'Action items (id, type, date, idMemberCreator, text, memberCreator, card, board, list)', + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'Action ID' }, + type: { type: 'string', description: 'Action type' }, + date: { type: 'string', description: 'Action timestamp' }, + idMemberCreator: { + type: 'string', + description: 'ID of the member who created the action', + }, + text: { + type: 'string', + description: 'Comment text when present', + optional: true, + }, + memberCreator: { + type: 'object', + description: 'Member who created the action', + optional: true, + properties: { + id: { type: 'string', description: 'Member ID' }, + fullName: { + type: 'string', + description: 'Member full name', + optional: true, + }, + username: { + type: 'string', + description: 'Member username', + optional: true, + }, + }, + }, + card: { + type: 'object', + description: 'Card referenced by the action', + optional: true, + properties: { + id: { type: 'string', description: 'Card ID' }, + name: { type: 'string', description: 'Card name' }, + shortLink: { + type: 'string', + description: 'Short card link', + optional: true, + }, + idShort: { + type: 'number', + description: 'Board-local card number', + optional: true, + }, + due: { + type: 'string', + description: 'Card due date', + optional: true, + }, + }, + }, + board: { + type: 'object', + description: 'Board referenced by the action', + optional: true, + properties: { + id: { type: 'string', description: 'Board ID' }, + name: { type: 'string', description: 'Board name' }, + shortLink: { + type: 'string', + description: 'Short board link', + optional: true, + }, + }, + }, + list: { + type: 'object', + description: 'List referenced by the action', + optional: true, + properties: { + id: { type: 'string', description: 'List ID' }, + name: { type: 'string', description: 'List name' }, + }, + }, + }, + }, }, count: { type: 'number', description: 'Number of actions returned' }, }, diff --git a/apps/sim/tools/trello/index.ts b/apps/sim/tools/trello/index.ts index 1804235a176..e420abf3893 100644 --- a/apps/sim/tools/trello/index.ts +++ b/apps/sim/tools/trello/index.ts @@ -13,3 +13,5 @@ export { trelloGetActionsTool, trelloAddCommentTool, } + +export * from '@/tools/trello/types' diff --git a/apps/sim/tools/trello/list_cards.ts b/apps/sim/tools/trello/list_cards.ts index c8554611834..0f585420983 100644 --- a/apps/sim/tools/trello/list_cards.ts +++ b/apps/sim/tools/trello/list_cards.ts @@ -1,11 +1,16 @@ import { env } from '@/lib/core/config/env' +import { + extractTrelloErrorMessage, + mapTrelloCard, + TRELLO_API_BASE_URL, +} from '@/tools/trello/shared' import type { TrelloListCardsParams, TrelloListCardsResponse } from '@/tools/trello/types' import type { ToolConfig } from '@/tools/types' export const trelloListCardsTool: ToolConfig = { id: 'trello_list_cards', name: 'Trello List Cards', - description: 'List all cards on a Trello board', + description: 'List cards from a Trello board or list', version: '1.0.0', oauth: { required: true, @@ -21,30 +26,42 @@ export const trelloListCardsTool: ToolConfig { - if (!params.boardId) { - throw new Error('Board ID is required') + const apiKey = env.TRELLO_API_KEY + + if (!apiKey) { + throw new Error('TRELLO_API_KEY environment variable is not set') } - const apiKey = env.TRELLO_API_KEY || '' - const token = params.accessToken - let url = `https://api.trello.com/1/boards/${params.boardId}/cards?key=${apiKey}&token=${token}&fields=id,name,desc,url,idBoard,idList,closed,labels,due,dueComplete` - if (params.listId) { - url += `&list=${params.listId}` + + if (params.boardId && params.listId) { + throw new Error('Provide either a board ID or list ID, not both') } - return url + + if (!params.listId && !params.boardId) { + throw new Error('Either a board ID or list ID is required') + } + + const path = params.listId + ? `/lists/${params.listId.trim()}/cards` + : `/boards/${params.boardId?.trim()}/cards` + const url = new URL(`${TRELLO_API_BASE_URL}${path}`) + url.searchParams.set('key', apiKey) + url.searchParams.set('token', params.accessToken) + + return url.toString() }, method: 'GET', headers: () => ({ @@ -53,34 +70,111 @@ export const trelloListCardsTool: ToolConfig { - const data = await response.json() + const data = await response.json().catch(() => null) + + if (!response.ok) { + const error = extractTrelloErrorMessage(response, data, 'Failed to list Trello cards') + + return { + success: false, + output: { + cards: [], + count: 0, + error, + }, + error, + } + } if (!Array.isArray(data)) { + const error = 'Trello returned an invalid card collection' + return { success: false, output: { cards: [], count: 0, - error: 'Invalid response from Trello API', + error, }, - error: 'Invalid response from Trello API', + error, } } - return { - success: true, - output: { - cards: data, - count: data.length, - }, + try { + const cards = data.map((item) => mapTrelloCard(item)) + + return { + success: true, + output: { + cards, + count: cards.length, + }, + } + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to parse Trello cards' + + return { + success: false, + output: { + cards: [], + count: 0, + error: message, + }, + error: message, + } } }, outputs: { cards: { type: 'array', - description: - 'Array of card objects with id, name, desc, url, board/list IDs, labels, and due date', + description: 'Cards returned from the selected Trello board or list', + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'Card ID' }, + name: { type: 'string', description: 'Card name' }, + desc: { type: 'string', description: 'Card description' }, + url: { type: 'string', description: 'Full card URL' }, + idBoard: { type: 'string', description: 'Board ID containing the card' }, + idList: { type: 'string', description: 'List ID containing the card' }, + closed: { type: 'boolean', description: 'Whether the card is archived' }, + labelIds: { + type: 'array', + description: 'Label IDs applied to the card', + items: { + type: 'string', + description: 'A Trello label ID', + }, + }, + labels: { + type: 'array', + description: 'Labels applied to the card', + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'Label ID' }, + name: { type: 'string', description: 'Label name' }, + color: { + type: 'string', + description: 'Label color', + optional: true, + }, + }, + }, + }, + due: { + type: 'string', + description: 'Card due date in ISO 8601 format', + optional: true, + }, + dueComplete: { + type: 'boolean', + description: 'Whether the due date is complete', + optional: true, + }, + }, + }, }, count: { type: 'number', description: 'Number of cards returned' }, }, diff --git a/apps/sim/tools/trello/list_lists.ts b/apps/sim/tools/trello/list_lists.ts index cbf50c7dcdc..8911ca04cc9 100644 --- a/apps/sim/tools/trello/list_lists.ts +++ b/apps/sim/tools/trello/list_lists.ts @@ -1,10 +1,15 @@ import { env } from '@/lib/core/config/env' +import { + extractTrelloErrorMessage, + mapTrelloList, + TRELLO_API_BASE_URL, +} from '@/tools/trello/shared' import type { TrelloListListsParams, TrelloListListsResponse } from '@/tools/trello/types' import type { ToolConfig } from '@/tools/types' export const trelloListListsTool: ToolConfig = { id: 'trello_list_lists', - name: 'Trello List Lists', + name: 'Trello Get Lists', description: 'List all lists on a Trello board', version: '1.0.0', @@ -33,18 +38,21 @@ export const trelloListListsTool: ToolConfig ({ @@ -53,33 +61,75 @@ export const trelloListListsTool: ToolConfig { - const data = await response.json() + const data = await response.json().catch(() => null) + + if (!response.ok) { + const error = extractTrelloErrorMessage(response, data, 'Failed to list Trello lists') + + return { + success: false, + output: { + lists: [], + count: 0, + error, + }, + error, + } + } if (!Array.isArray(data)) { + const error = 'Trello returned an invalid list collection' + return { success: false, output: { lists: [], count: 0, - error: data?.message || data?.error || 'Invalid response from Trello API', + error, }, - error: data?.message || data?.error || 'Invalid response from Trello API', + error, } } - return { - success: true, - output: { - lists: data, - count: data.length, - }, + try { + const lists = data.map((item) => mapTrelloList(item)) + + return { + success: true, + output: { + lists, + count: lists.length, + }, + } + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to parse Trello lists' + + return { + success: false, + output: { + lists: [], + count: 0, + error: message, + }, + error: message, + } } }, outputs: { lists: { type: 'array', - description: 'Array of list objects with id, name, closed, pos, and idBoard', + description: 'Lists on the selected board', + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'List ID' }, + name: { type: 'string', description: 'List name' }, + closed: { type: 'boolean', description: 'Whether the list is archived' }, + pos: { type: 'number', description: 'List position on the board' }, + idBoard: { type: 'string', description: 'Board ID containing the list' }, + }, + }, }, count: { type: 'number', description: 'Number of lists returned' }, }, diff --git a/apps/sim/tools/trello/shared.ts b/apps/sim/tools/trello/shared.ts new file mode 100644 index 00000000000..e99e83d73b1 --- /dev/null +++ b/apps/sim/tools/trello/shared.ts @@ -0,0 +1,236 @@ +import type { + TrelloAction, + TrelloActionBoardTarget, + TrelloActionCardTarget, + TrelloActionListTarget, + TrelloCard, + TrelloComment, + TrelloLabel, + TrelloList, + TrelloMember, +} from '@/tools/trello/types' + +type TrelloRecord = Record + +export const TRELLO_API_BASE_URL = 'https://api.trello.com/1' + +function isRecord(value: unknown): value is TrelloRecord { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function getRequiredString(value: unknown, field: string): string { + if (typeof value === 'string' && value.trim().length > 0) { + return value + } + + throw new Error(`Trello response is missing required field: ${field}`) +} + +function getOptionalString(value: unknown): string | null { + return typeof value === 'string' ? value : null +} + +function getOptionalBoolean(value: unknown): boolean | null { + return typeof value === 'boolean' ? value : null +} + +function getNumber(value: unknown): number { + if (typeof value === 'number' && Number.isFinite(value)) { + return value + } + + if (typeof value === 'string' && value.trim().length > 0) { + const parsed = Number(value) + if (Number.isFinite(parsed)) { + return parsed + } + } + + return 0 +} + +function getOptionalNumber(value: unknown): number | null { + if (typeof value === 'number' && Number.isFinite(value)) { + return value + } + + return null +} + +function getIdArray(value: unknown): string[] { + if (!Array.isArray(value)) { + return [] + } + + return value.flatMap((item) => { + if (typeof item === 'string' && item.trim().length > 0) { + return [item] + } + + if (isRecord(item) && typeof item.id === 'string' && item.id.trim().length > 0) { + return [item.id] + } + + return [] + }) +} + +function mapTrelloLabel(value: unknown): TrelloLabel | null { + if (!isRecord(value) || typeof value.id !== 'string') { + return null + } + + return { + id: value.id, + name: typeof value.name === 'string' ? value.name : '', + color: getOptionalString(value.color), + } +} + +function mapTrelloMember(value: unknown): TrelloMember | null { + if (!isRecord(value) || typeof value.id !== 'string') { + return null + } + + return { + id: value.id, + fullName: getOptionalString(value.fullName), + username: getOptionalString(value.username), + } +} + +function mapActionCardTarget(value: unknown): TrelloActionCardTarget | null { + if (!isRecord(value) || typeof value.id !== 'string' || typeof value.name !== 'string') { + return null + } + + return { + id: value.id, + name: value.name, + shortLink: getOptionalString(value.shortLink), + idShort: getOptionalNumber(value.idShort), + due: getOptionalString(value.due), + } +} + +function mapActionBoardTarget(value: unknown): TrelloActionBoardTarget | null { + if (!isRecord(value) || typeof value.id !== 'string' || typeof value.name !== 'string') { + return null + } + + return { + id: value.id, + name: value.name, + shortLink: getOptionalString(value.shortLink), + } +} + +function mapActionListTarget(value: unknown): TrelloActionListTarget | null { + if (!isRecord(value) || typeof value.id !== 'string' || typeof value.name !== 'string') { + return null + } + + return { + id: value.id, + name: value.name, + } +} + +export function mapTrelloList(value: unknown): TrelloList { + if (!isRecord(value)) { + throw new Error('Trello returned an invalid list object') + } + + return { + id: getRequiredString(value.id, 'id'), + name: getRequiredString(value.name, 'name'), + closed: typeof value.closed === 'boolean' ? value.closed : false, + pos: getNumber(value.pos), + idBoard: getRequiredString(value.idBoard, 'idBoard'), + } +} + +export function mapTrelloCard(value: unknown): TrelloCard { + if (!isRecord(value)) { + throw new Error('Trello returned an invalid card object') + } + + const rawLabels = Array.isArray(value.labels) ? value.labels : [] + const labels = rawLabels + .map((label) => mapTrelloLabel(label)) + .filter((label): label is TrelloLabel => label !== null) + const labelIds = getIdArray(value.idLabels) + + if (labelIds.length === 0) { + labelIds.push(...rawLabels.filter((label): label is string => typeof label === 'string')) + } + + return { + id: getRequiredString(value.id, 'id'), + name: getRequiredString(value.name, 'name'), + desc: typeof value.desc === 'string' ? value.desc : '', + url: getRequiredString(value.url, 'url'), + idBoard: getRequiredString(value.idBoard, 'idBoard'), + idList: getRequiredString(value.idList, 'idList'), + closed: typeof value.closed === 'boolean' ? value.closed : false, + labelIds, + labels, + due: getOptionalString(value.due), + dueComplete: getOptionalBoolean(value.dueComplete), + } +} + +export function mapTrelloAction(value: unknown): TrelloAction { + if (!isRecord(value)) { + throw new Error('Trello returned an invalid action object') + } + + const data = isRecord(value.data) ? value.data : null + + return { + id: getRequiredString(value.id, 'id'), + type: getRequiredString(value.type, 'type'), + date: getRequiredString(value.date, 'date'), + idMemberCreator: getRequiredString(value.idMemberCreator, 'idMemberCreator'), + text: data ? getOptionalString(data.text) : null, + memberCreator: mapTrelloMember(value.memberCreator), + card: data ? mapActionCardTarget(data.card) : null, + board: data ? mapActionBoardTarget(data.board) : null, + list: data ? mapActionListTarget(data.list) : null, + } +} + +export function mapTrelloComment(value: unknown): TrelloComment { + return mapTrelloAction(value) +} + +export function extractTrelloErrorMessage( + response: Response, + data: unknown, + fallback: string +): string { + const parts: string[] = [] + + if (isRecord(data)) { + const message = data.message + const error = data.error + + if (typeof message === 'string' && message.trim().length > 0) { + parts.push(message) + } + + if (typeof error === 'string' && error.trim().length > 0 && error !== message) { + parts.push(error) + } + } + + if (parts.length > 0) { + return `${fallback}: ${parts.join(' - ')}` + } + + if (response.statusText) { + return `${fallback}: ${response.status} ${response.statusText}` + } + + return fallback +} diff --git a/apps/sim/tools/trello/types.ts b/apps/sim/tools/trello/types.ts index 92ec68f2e9a..37edaee5654 100644 --- a/apps/sim/tools/trello/types.ts +++ b/apps/sim/tools/trello/types.ts @@ -8,6 +8,18 @@ export interface TrelloBoard { closed: boolean } +export interface TrelloLabel { + id: string + name: string + color: string | null +} + +export interface TrelloMember { + id: string + fullName: string | null + username: string | null +} + export interface TrelloList { id: string name: string @@ -24,38 +36,45 @@ export interface TrelloCard { idBoard: string idList: string closed: boolean - labels: Array<{ - id: string - name: string - color: string - }> - due?: string - dueComplete?: boolean + labelIds: string[] + labels: TrelloLabel[] + due: string | null + dueComplete: boolean | null } -export interface TrelloAction { +export interface TrelloActionCardTarget { id: string - type: string - date: string - memberCreator: { - id: string - fullName: string - username: string - } - data: Record + name: string + shortLink: string | null + idShort: number | null + due: string | null } -export interface TrelloComment { +export interface TrelloActionBoardTarget { id: string - text: string + name: string + shortLink: string | null +} + +export interface TrelloActionListTarget { + id: string + name: string +} + +export interface TrelloAction { + id: string + type: string date: string - memberCreator: { - id: string - fullName: string - username: string - } + idMemberCreator: string + text: string | null + memberCreator: TrelloMember | null + card: TrelloActionCardTarget | null + board: TrelloActionBoardTarget | null + list: TrelloActionListTarget | null } +export interface TrelloComment extends TrelloAction {} + export interface TrelloListListsParams { accessToken: string boardId: string @@ -63,19 +82,19 @@ export interface TrelloListListsParams { export interface TrelloListCardsParams { accessToken: string - boardId: string + boardId?: string listId?: string } export interface TrelloCreateCardParams { accessToken: string - boardId: string listId: string name: string desc?: string pos?: string due?: string - labels?: string + dueComplete?: boolean + labelIds?: string[] } export interface TrelloUpdateCardParams { @@ -95,6 +114,7 @@ export interface TrelloGetActionsParams { cardId?: string filter?: string limit?: number + page?: number } export interface TrelloAddCommentParams { diff --git a/apps/sim/tools/trello/update_card.ts b/apps/sim/tools/trello/update_card.ts index e41dbc7b5da..3d17a76fd77 100644 --- a/apps/sim/tools/trello/update_card.ts +++ b/apps/sim/tools/trello/update_card.ts @@ -1,4 +1,9 @@ import { env } from '@/lib/core/config/env' +import { + extractTrelloErrorMessage, + mapTrelloCard, + TRELLO_API_BASE_URL, +} from '@/tools/trello/shared' import type { TrelloUpdateCardParams, TrelloUpdateCardResponse } from '@/tools/trello/types' import type { ToolConfig } from '@/tools/types' @@ -69,9 +74,17 @@ export const trelloUpdateCardTool: ToolConfig ({ @@ -79,12 +92,12 @@ export const trelloUpdateCardTool: ToolConfig { - const body: Record = {} + const body: Record = {} if (params.name !== undefined) body.name = params.name if (params.desc !== undefined) body.desc = params.desc if (params.closed !== undefined) body.closed = params.closed - if (params.idList !== undefined) body.idList = params.idList + if (params.idList !== undefined) body.idList = params.idList.trim() if (params.due !== undefined) body.due = params.due if (params.dueComplete !== undefined) body.dueComplete = params.dueComplete @@ -97,30 +110,91 @@ export const trelloUpdateCardTool: ToolConfig { - const data = await response.json() + const data = await response.json().catch(() => null) + + if (!response.ok) { + const error = extractTrelloErrorMessage(response, data, 'Failed to update card') - if (!data?.id) { return { success: false, output: { - error: data?.message || 'Failed to update card', + error, }, - error: data?.message || 'Failed to update card', + error, } } - return { - success: true, - output: { - card: data, - }, + try { + const card = mapTrelloCard(data) + + return { + success: true, + output: { + card, + }, + } + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to parse updated card' + + return { + success: false, + output: { + error: message, + }, + error: message, + } } }, outputs: { card: { - type: 'object', - description: 'The updated card object with id, name, desc, url, and other properties', + type: 'json', + description: + 'Updated card (id, name, desc, url, idBoard, idList, closed, labelIds, labels, due, dueComplete)', + optional: true, + properties: { + id: { type: 'string', description: 'Card ID' }, + name: { type: 'string', description: 'Card name' }, + desc: { type: 'string', description: 'Card description' }, + url: { type: 'string', description: 'Full card URL' }, + idBoard: { type: 'string', description: 'Board ID containing the card' }, + idList: { type: 'string', description: 'List ID containing the card' }, + closed: { type: 'boolean', description: 'Whether the card is archived' }, + labelIds: { + type: 'array', + description: 'Label IDs applied to the card', + items: { + type: 'string', + description: 'A Trello label ID', + }, + }, + labels: { + type: 'array', + description: 'Labels applied to the card', + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'Label ID' }, + name: { type: 'string', description: 'Label name' }, + color: { + type: 'string', + description: 'Label color', + optional: true, + }, + }, + }, + }, + due: { + type: 'string', + description: 'Card due date in ISO 8601 format', + optional: true, + }, + dueComplete: { + type: 'boolean', + description: 'Whether the due date is complete', + optional: true, + }, + }, }, }, } diff --git a/apps/sim/tools/whatsapp/index.ts b/apps/sim/tools/whatsapp/index.ts index cde6dc68974..c596cad77bf 100644 --- a/apps/sim/tools/whatsapp/index.ts +++ b/apps/sim/tools/whatsapp/index.ts @@ -1,3 +1,2 @@ -import { sendMessageTool } from '@/tools/whatsapp/send_message' - -export const whatsappSendMessageTool = sendMessageTool +export { sendMessageTool as whatsappSendMessageTool } from '@/tools/whatsapp/send_message' +export * from '@/tools/whatsapp/types' diff --git a/apps/sim/tools/whatsapp/send_message.ts b/apps/sim/tools/whatsapp/send_message.ts index c643b88c750..fa8b3aabe29 100644 --- a/apps/sim/tools/whatsapp/send_message.ts +++ b/apps/sim/tools/whatsapp/send_message.ts @@ -1,10 +1,14 @@ import type { ToolConfig } from '@/tools/types' import type { WhatsAppResponse, WhatsAppSendMessageParams } from '@/tools/whatsapp/types' +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + export const sendMessageTool: ToolConfig = { id: 'whatsapp_send_message', - name: 'WhatsApp', - description: 'Send WhatsApp messages', + name: 'WhatsApp Send Message', + description: 'Send a text message through the WhatsApp Cloud API.', version: '1.0.0', params: { @@ -18,7 +22,7 @@ export const sendMessageTool: ToolConfig { @@ -47,12 +58,11 @@ export const sendMessageTool: ToolConfig { - // Check if required parameters exist if (!params.phoneNumber) { throw new Error('Phone number is required but was not provided') } @@ -61,34 +71,68 @@ export const sendMessageTool: ToolConfig = { + body: params.message, + } + + if (typeof params.previewUrl === 'boolean') { + text.preview_url = params.previewUrl + } return { messaging_product: 'whatsapp', recipient_type: 'individual', - to: formattedPhoneNumber, + to: params.phoneNumber.trim(), type: 'text', - text: { - body: params.message, - }, + text, } }, }, transformResponse: async (response: Response) => { - const data = await response.json() + const responseText = await response.text() + const parsed = responseText ? (JSON.parse(responseText) as unknown) : {} + const data = isRecord(parsed) ? parsed : {} + const error = isRecord(data.error) ? data.error : undefined + + if (!response.ok) { + const errorMessage = + (typeof error?.message === 'string' ? error.message : undefined) || + (typeof error?.error_user_msg === 'string' ? error.error_user_msg : undefined) || + (isRecord(error?.error_data) && typeof error.error_data.details === 'string' + ? error.error_data.details + : undefined) || + `WhatsApp API error (${response.status})` + throw new Error(errorMessage) + } + + const contacts = Array.isArray(data.contacts) + ? data.contacts.filter(isRecord).map((contact) => ({ + input: typeof contact.input === 'string' ? contact.input : '', + wa_id: typeof contact.wa_id === 'string' ? contact.wa_id : null, + })) + : [] + const firstMessage = + Array.isArray(data.messages) && isRecord(data.messages[0]) ? data.messages[0] : undefined + const messageId = typeof firstMessage?.id === 'string' ? firstMessage.id : undefined + const messageStatus = + typeof firstMessage?.message_status === 'string' ? firstMessage.message_status : undefined + + if (!messageId) { + throw new Error('WhatsApp API response did not include a message ID') + } return { success: true, output: { success: true, - messageId: data.messages?.[0]?.id, - phoneNumber: '', - status: '', - timestamp: '', + messageId, + messageStatus, + messagingProduct: + typeof data.messaging_product === 'string' ? data.messaging_product : undefined, + inputPhoneNumber: contacts[0]?.input ?? null, + whatsappUserId: contacts[0]?.wa_id ?? null, + contacts, }, } }, @@ -96,8 +140,41 @@ export const sendMessageTool: ToolConfigMeta for Developers Apps page and navigate to the "Build with us" --> "App Events" section.', - 'If you don\'t have an app:
  • Create an app from scratch
  • Give it a name and select your workspace
', - 'Select your App, then navigate to WhatsApp > Configuration.', - 'Find the Webhooks section and click "Edit".', + 'Go to your Meta App Dashboard and open the app connected to your WhatsApp Business Platform setup. If you used the WhatsApp use case flow, the configuration page may be under Use cases > Customize > Configuration instead of WhatsApp > Configuration.', + 'If you do not already have an app, create one first and add the WhatsApp product before configuring webhooks.', + 'Click "Save Configuration" above before verifying the callback URL so Sim has an active WhatsApp webhook config for this path. If this workflow is already deployed and you change the verification token or app secret, redeploy before re-verifying in Meta.', + 'In WhatsApp > Configuration, find the Webhooks section and click Edit.', 'Paste the Webhook URL above into the "Callback URL" field.', 'Paste the Verification Token into the "Verify token" field.', + "Copy your app's App Secret from App Settings > Basic and paste it into the App Secret field above so Sim can validate POST signatures.", 'Click "Verify and save".', - 'Click "Manage" next to Webhook fields and subscribe to `messages`.', + 'Click Manage next to webhook fields and subscribe to messages. That field covers incoming messages and outbound message status updates.', ] .map( (instruction, index) => @@ -56,65 +68,79 @@ export const whatsappWebhookTrigger: TriggerConfig = { ], outputs: { + eventType: { + type: 'string', + description: 'Webhook classification such as incoming_message, message_status, or mixed', + }, messageId: { type: 'string', - description: 'Unique message identifier (wamid)', + description: 'First WhatsApp message identifier (wamid) found in the webhook batch', }, from: { type: 'string', - description: 'Phone number of the message sender (with country code)', + description: 'Sender phone number from the first incoming message in the batch', + }, + recipientId: { + type: 'string', + description: 'Recipient phone number from the first status update in the batch', }, phoneNumberId: { type: 'string', - description: 'WhatsApp Business phone number ID that received the message', + description: 'Business phone number ID from the first message or status item in the batch', + }, + displayPhoneNumber: { + type: 'string', + description: + 'Business display phone number from the first message or status item in the batch', }, text: { type: 'string', - description: 'Message text content (for text messages)', + description: 'Text body from the first incoming text message in the batch', }, timestamp: { type: 'string', - description: 'Message timestamp (Unix timestamp)', + description: 'Timestamp from the first message or status item in the batch', }, messageType: { + type: 'string', + description: 'Type of the first incoming message in the batch (text, image, system, etc.)', + }, + status: { type: 'string', description: - 'Type of message (text, image, audio, video, document, sticker, location, contacts)', + 'First outgoing message status in the batch, such as sent, delivered, read, or failed', }, contact: { - type: 'object', - description: 'Contact information of the sender', - properties: { - wa_id: { type: 'string', description: 'WhatsApp ID (phone number with country code)' }, - profile: { - type: 'object', - description: 'Contact profile', - properties: { - name: { type: 'string', description: 'Contact display name' }, - }, - }, - }, + type: 'json', + description: 'First sender contact in the batch (wa_id, profile.name)', + }, + webhookContacts: { + type: 'json', + description: 'All sender contact profiles from the webhook batch', + }, + messages: { + type: 'json', + description: + 'All incoming message objects from the webhook batch, flattened across entries/changes', + }, + statuses: { + type: 'json', + description: + 'All message status objects from the webhook batch, flattened across entries/changes', + }, + conversation: { + type: 'json', + description: + 'Conversation metadata from the first status update in the batch (id, expiration_timestamp, origin.type)', + }, + pricing: { + type: 'json', + description: + 'Pricing metadata from the first status update in the batch (billable, pricing_model, category)', }, raw: { - type: 'object', - description: 'Complete raw webhook payload from WhatsApp', - properties: { - object: { type: 'string', description: 'Always "whatsapp_business_account"' }, - entry: { - type: 'array', - description: 'Array of entry objects', - items: { - type: 'object', - properties: { - id: { type: 'string', description: 'WhatsApp Business Account ID' }, - changes: { - type: 'array', - description: 'Array of change objects', - }, - }, - }, - }, - }, + type: 'json', + description: 'Complete structured webhook payload from WhatsApp', }, }, From 5e9e22bf43c110cf66cdfcf2c866dd6eb4f65185 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Sun, 12 Apr 2026 15:41:43 -0700 Subject: [PATCH 2/5] address comment --- apps/sim/app/api/tools/crowdstrike/query/route.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/sim/app/api/tools/crowdstrike/query/route.ts b/apps/sim/app/api/tools/crowdstrike/query/route.ts index 2ac022c77a9..a9b1fc67233 100644 --- a/apps/sim/app/api/tools/crowdstrike/query/route.ts +++ b/apps/sim/app/api/tools/crowdstrike/query/route.ts @@ -41,7 +41,7 @@ function getCloudBaseUrl(cloud: QueryRequest['cloud']): string { } function isJsonRecord(value: unknown): value is JsonRecord { - return typeof value === 'object' && value !== null + return typeof value === 'object' && value !== null && !Array.isArray(value) } function getString(value: unknown): string | null { From 6d4ce39ae7ae59a78fcf33d43677f777104db77a Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Sun, 12 Apr 2026 16:23:08 -0700 Subject: [PATCH 3/5] remove tools when unsure about docs shape --- .../content/docs/en/tools/crowdstrike.mdx | 140 +++--- .../integrations/data/integrations.json | 22 +- .../api/tools/crowdstrike/query/route.test.ts | 277 +++++++++++ .../app/api/tools/crowdstrike/query/route.ts | 443 ++++++++++++------ apps/sim/blocks/blocks/crowdstrike.ts | 146 +++--- apps/sim/blocks/utils.test.ts | 62 ++- apps/sim/blocks/utils.ts | 80 ++++ .../crowdstrike/get_sensor_aggregates.ts | 161 +++++++ .../tools/crowdstrike/get_sensor_details.ts | 193 ++++++++ apps/sim/tools/crowdstrike/index.ts | 5 +- apps/sim/tools/crowdstrike/query_behaviors.ts | 130 ----- .../sim/tools/crowdstrike/query_crowdscore.ts | 126 ----- apps/sim/tools/crowdstrike/query_incidents.ts | 131 ------ apps/sim/tools/crowdstrike/query_sensors.ts | 107 ++++- apps/sim/tools/crowdstrike/types.ts | 130 +++-- apps/sim/tools/registry.ts | 10 +- 16 files changed, 1421 insertions(+), 742 deletions(-) create mode 100644 apps/sim/app/api/tools/crowdstrike/query/route.test.ts create mode 100644 apps/sim/tools/crowdstrike/get_sensor_aggregates.ts create mode 100644 apps/sim/tools/crowdstrike/get_sensor_details.ts delete mode 100644 apps/sim/tools/crowdstrike/query_behaviors.ts delete mode 100644 apps/sim/tools/crowdstrike/query_crowdscore.ts delete mode 100644 apps/sim/tools/crowdstrike/query_incidents.ts diff --git a/apps/docs/content/docs/en/tools/crowdstrike.mdx b/apps/docs/content/docs/en/tools/crowdstrike.mdx index 6bbbded2308..fac75e1213e 100644 --- a/apps/docs/content/docs/en/tools/crowdstrike.mdx +++ b/apps/docs/content/docs/en/tools/crowdstrike.mdx @@ -1,6 +1,6 @@ --- title: CrowdStrike -description: Search Falcon sensors, CrowdScore, incidents, and behaviors +description: Query CrowdStrike Identity Protection sensors and documented aggregates --- import { BlockInfoCard } from "@/components/ui/block-info-card" @@ -12,15 +12,15 @@ import { BlockInfoCard } from "@/components/ui/block-info-card" ## Usage Instructions -Integrate CrowdStrike Falcon into workflows to search identity sensors, environment CrowdScore, incidents, and behaviors using documented Falcon query endpoints. +Integrate CrowdStrike Identity Protection into workflows to search sensors, fetch documented sensor details by device ID, and run documented sensor aggregate queries. ## Tools -### `crowdstrike_query_behaviors` +### `crowdstrike_get_sensor_aggregates` -Search CrowdStrike behaviors by filter, sort order, and pagination +Get documented CrowdStrike Identity Protection sensor aggregates from a JSON aggregate query body #### Input @@ -29,30 +29,32 @@ Search CrowdStrike behaviors by filter, sort order, and pagination | `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 behavior search | -| `limit` | number | No | Maximum number of behavior records to return | -| `offset` | number | No | Pagination offset for the behavior query | -| `sort` | string | No | Sort expression for behavior results | +| `aggregateQuery` | json | Yes | JSON aggregate query body documented by CrowdStrike for sensor aggregates | #### Output | Parameter | Type | Description | | --------- | ---- | ----------- | -| `behaviors` | array | Matching CrowdStrike behavior records | -| ↳ `behaviorId` | string | Behavior identifier | -| ↳ `incidentId` | string | Parent incident identifier | -| ↳ `name` | string | Behavior name | -| ↳ `createdTimestamp` | string | Behavior creation timestamp | -| `count` | number | Number of behaviors returned | -| `pagination` | json | Pagination metadata \(offset, limit, total, expiresAt\) | -| ↳ `expiresAt` | number | Pagination cursor expiry timestamp | -| ↳ `limit` | number | Page size used for the query | -| ↳ `offset` | number | Offset returned by CrowdStrike | -| ↳ `total` | number | Total records available | - -### `crowdstrike_query_crowdscore` - -Retrieve environment-wide CrowdScore entities from CrowdStrike Falcon +| `aggregates` | array | Aggregate result groups returned by CrowdStrike | +| ↳ `buckets` | array | Buckets within the aggregate result | +| ↳ `count` | number | Bucket document count | +| ↳ `from` | number | Bucket lower bound | +| ↳ `keyAsString` | string | String representation of the bucket key | +| ↳ `label` | json | Bucket label object | +| ↳ `stringFrom` | string | String lower bound | +| ↳ `stringTo` | string | String upper bound | +| ↳ `subAggregates` | json | Nested aggregate results for this bucket | +| ↳ `to` | number | Bucket upper bound | +| ↳ `value` | number | Bucket metric value | +| ↳ `valueAsString` | string | String representation of the bucket value | +| ↳ `docCountErrorUpperBound` | number | Upper bound for bucket count error | +| ↳ `name` | string | Aggregate result name | +| ↳ `sumOtherDocCount` | number | Document count not included in the returned buckets | +| `count` | number | Number of aggregate result groups returned | + +### `crowdstrike_get_sensor_details` + +Get documented CrowdStrike Identity Protection sensor details for one or more device IDs #### Input @@ -61,56 +63,34 @@ Retrieve environment-wide CrowdScore entities from CrowdStrike Falcon | `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 CrowdScore search | -| `limit` | number | No | Maximum number of CrowdScore records to return | -| `offset` | number | No | Pagination offset for CrowdScore results | -| `sort` | string | No | Sort expression for CrowdScore results | +| `ids` | json | Yes | JSON array of CrowdStrike sensor device IDs | #### Output | Parameter | Type | Description | | --------- | ---- | ----------- | -| `crowdScores` | array | CrowdStrike CrowdScore entities | -| ↳ `entityId` | string | Entity identifier | -| ↳ `entityType` | string | Entity type | -| ↳ `lastUpdated` | string | Last update timestamp | -| ↳ `score` | number | CrowdScore value | -| `count` | number | Number of CrowdScore records returned | -| `pagination` | json | Pagination metadata \(offset, limit, total, expiresAt\) | -| ↳ `expiresAt` | number | Pagination cursor expiry timestamp | -| ↳ `limit` | number | Page size used for the query | -| ↳ `offset` | number | Offset returned by CrowdStrike | -| ↳ `total` | number | Total records available | - -### `crowdstrike_query_incidents` - -Search CrowdStrike incidents by filter, sort order, and pagination - -#### Input - -| 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 incident search | -| `limit` | number | No | Maximum number of incident records to return | -| `offset` | number | No | Pagination offset for the incident query | -| `sort` | string | No | Sort expression for incident results | - -#### Output - -| Parameter | Type | Description | -| --------- | ---- | ----------- | -| `incidents` | array | Matching CrowdStrike incident records | -| ↳ `incidentId` | string | Incident identifier | -| ↳ `name` | string | Incident name | -| ↳ `createdTimestamp` | string | Incident creation timestamp | -| ↳ `status` | string | Incident status | -| ↳ `severity` | string | Incident severity | -| `count` | number | Number of incidents returned | -| `pagination` | json | Pagination metadata \(offset, limit, total, expiresAt\) | -| ↳ `expiresAt` | number | Pagination cursor expiry timestamp | +| `sensors` | array | CrowdStrike identity sensor detail records | +| ↳ `agentVersion` | string | Sensor agent version | +| ↳ `cid` | string | CrowdStrike customer identifier | +| ↳ `deviceId` | string | Sensor device identifier | +| ↳ `heartbeatTime` | number | Last heartbeat timestamp | +| ↳ `hostname` | string | Sensor hostname | +| ↳ `idpPolicyId` | string | Assigned Identity Protection policy ID | +| ↳ `idpPolicyName` | string | Assigned Identity Protection policy name | +| ↳ `ipAddress` | string | Sensor local IP address | +| ↳ `kerberosConfig` | string | Kerberos configuration status | +| ↳ `ldapConfig` | string | LDAP configuration status | +| ↳ `ldapsConfig` | string | LDAPS configuration status | +| ↳ `machineDomain` | string | Machine domain | +| ↳ `ntlmConfig` | string | NTLM configuration status | +| ↳ `osVersion` | string | Operating system version | +| ↳ `rdpToDcConfig` | string | RDP to domain controller configuration status | +| ↳ `smbToDcConfig` | string | SMB to domain controller configuration status | +| ↳ `status` | string | Sensor protection status | +| ↳ `statusCauses` | array | Documented causes behind the current status | +| ↳ `tiEnabled` | string | Threat intelligence enablement status | +| `count` | number | Number of sensors returned | +| `pagination` | json | Pagination metadata when returned by the underlying API | | ↳ `limit` | number | Page size used for the query | | ↳ `offset` | number | Offset returned by CrowdStrike | | ↳ `total` | number | Total records available | @@ -136,13 +116,27 @@ Search CrowdStrike identity protection sensors by hostname, IP, or related field | Parameter | Type | Description | | --------- | ---- | ----------- | | `sensors` | array | Matching CrowdStrike identity sensor records | -| ↳ `agentId` | string | Sensor agent identifier | +| ↳ `agentVersion` | string | Sensor agent version | +| ↳ `cid` | string | CrowdStrike customer identifier | +| ↳ `deviceId` | string | Sensor device identifier | +| ↳ `heartbeatTime` | number | Last heartbeat timestamp | | ↳ `hostname` | string | Sensor hostname | -| ↳ `ipAddress` | string | Sensor IP address | -| ↳ `macAddress` | string | Sensor MAC address | +| ↳ `idpPolicyId` | string | Assigned Identity Protection policy ID | +| ↳ `idpPolicyName` | string | Assigned Identity Protection policy name | +| ↳ `ipAddress` | string | Sensor local IP address | +| ↳ `kerberosConfig` | string | Kerberos configuration status | +| ↳ `ldapConfig` | string | LDAP configuration status | +| ↳ `ldapsConfig` | string | LDAPS configuration status | +| ↳ `machineDomain` | string | Machine domain | +| ↳ `ntlmConfig` | string | NTLM configuration status | +| ↳ `osVersion` | string | Operating system version | +| ↳ `rdpToDcConfig` | string | RDP to domain controller configuration status | +| ↳ `smbToDcConfig` | string | SMB to domain controller configuration status | +| ↳ `status` | string | Sensor protection status | +| ↳ `statusCauses` | array | Documented causes behind the current status | +| ↳ `tiEnabled` | string | Threat intelligence enablement status | | `count` | number | Number of sensors returned | -| `pagination` | json | Pagination metadata \(offset, limit, total, expiresAt\) | -| ↳ `expiresAt` | number | Pagination cursor expiry timestamp | +| `pagination` | json | Pagination metadata \(limit, offset, total\) | | ↳ `limit` | number | Page size used for the query | | ↳ `offset` | number | Offset returned by CrowdStrike | | ↳ `total` | number | Total records available | diff --git a/apps/sim/app/(landing)/integrations/data/integrations.json b/apps/sim/app/(landing)/integrations/data/integrations.json index 306f55aaa8d..241e229ae52 100644 --- a/apps/sim/app/(landing)/integrations/data/integrations.json +++ b/apps/sim/app/(landing)/integrations/data/integrations.json @@ -2349,36 +2349,32 @@ "type": "crowdstrike", "slug": "crowdstrike", "name": "CrowdStrike", - "description": "Search Falcon sensors, CrowdScore, incidents, and behaviors", - "longDescription": "Integrate CrowdStrike Falcon into workflows to search identity sensors, environment CrowdScore, incidents, and behaviors using documented Falcon query endpoints.", + "description": "Query CrowdStrike Identity Protection sensors and documented aggregates", + "longDescription": "Integrate CrowdStrike Identity Protection into workflows to search sensors, fetch documented sensor details by device ID, and run documented sensor aggregate queries.", "bgColor": "#E01F3D", "iconName": "CrowdStrikeIcon", "docsUrl": "https://docs.sim.ai/tools/crowdstrike", "operations": [ { - "name": "Search Identity Sensors", + "name": "Query Sensors", "description": "Search CrowdStrike identity protection sensors by hostname, IP, or related fields" }, { - "name": "Get CrowdScore", - "description": "Retrieve environment-wide CrowdScore entities from CrowdStrike Falcon" + "name": "Get Sensor Details", + "description": "Get documented CrowdStrike Identity Protection sensor details for one or more device IDs" }, { - "name": "Search Incidents", - "description": "Search CrowdStrike incidents by filter, sort order, and pagination" - }, - { - "name": "Search Behaviors", - "description": "Search CrowdStrike behaviors by filter, sort order, and pagination" + "name": "Get Sensor Aggregates", + "description": "Get documented CrowdStrike Identity Protection sensor aggregates from a JSON aggregate query body" } ], - "operationCount": 4, + "operationCount": 3, "triggers": [], "triggerCount": 0, "authType": "none", "category": "tools", "integrationTypes": ["security", "analytics", "developer-tools"], - "tags": ["monitoring", "incident-management"] + "tags": ["monitoring", "security"] }, { "type": "cursor_v2", diff --git a/apps/sim/app/api/tools/crowdstrike/query/route.test.ts b/apps/sim/app/api/tools/crowdstrike/query/route.test.ts new file mode 100644 index 00000000000..3e0b88d02de --- /dev/null +++ b/apps/sim/app/api/tools/crowdstrike/query/route.test.ts @@ -0,0 +1,277 @@ +/** + * @vitest-environment node + */ +import { createMockRequest } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { fetchMock, mockCheckInternalAuth } = vi.hoisted(() => ({ + fetchMock: vi.fn(), + mockCheckInternalAuth: vi.fn(), +})) + +vi.mock('@/lib/auth/hybrid', () => ({ + AuthType: { SESSION: 'session', API_KEY: 'api_key', INTERNAL_JWT: 'internal_jwt' }, + checkInternalAuth: mockCheckInternalAuth, +})) + +import { POST } from '@/app/api/tools/crowdstrike/query/route' + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }) +} + +const sensorResource = { + agent_version: '6.1.0', + cid: 'cid-1', + device_id: 'sensor-1', + heartbeat_time: 1700, + hostname: 'host-1', + idp_policy_id: 'policy-1', + idp_policy_name: 'Default Policy', + kerberos_config: 'configured', + ldap_config: 'configured', + ldaps_config: 'configured', + local_ip: '10.0.0.1', + machine_domain: 'corp.local', + ntlm_config: 'configured', + os_version: 'Windows Server 2022', + rdp_to_dc_config: 'configured', + smb_to_dc_config: 'configured', + status: 'protected', + status_causes: ['healthy'], + ti_enabled: 'enabled', +} + +const normalizedSensor = { + agentVersion: '6.1.0', + cid: 'cid-1', + deviceId: 'sensor-1', + heartbeatTime: 1700, + hostname: 'host-1', + idpPolicyId: 'policy-1', + idpPolicyName: 'Default Policy', + ipAddress: '10.0.0.1', + kerberosConfig: 'configured', + ldapConfig: 'configured', + ldapsConfig: 'configured', + machineDomain: 'corp.local', + ntlmConfig: 'configured', + osVersion: 'Windows Server 2022', + rdpToDcConfig: 'configured', + smbToDcConfig: 'configured', + status: 'protected', + statusCauses: ['healthy'], + tiEnabled: 'enabled', +} + +describe('CrowdStrike query route', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.stubGlobal('fetch', fetchMock) + + mockCheckInternalAuth.mockResolvedValue({ + success: true, + userId: 'user-123', + authType: 'internal_jwt', + }) + }) + + it('hydrates sensor details after querying sensor ids', async () => { + fetchMock + .mockResolvedValueOnce(jsonResponse({ access_token: 'token-123' })) + .mockResolvedValueOnce( + jsonResponse({ + meta: { pagination: { expires_at: 111, limit: 1, offset: 0, total: 1 } }, + resources: ['sensor-1'], + }) + ) + .mockResolvedValueOnce( + jsonResponse({ + resources: [sensorResource], + }) + ) + + const request = createMockRequest('POST', { + clientId: 'client-id', + clientSecret: 'client-secret', + cloud: 'us-1', + limit: 1, + operation: 'crowdstrike_query_sensors', + }) + + const response = await POST(request) + const data = await response.json() + + expect(response.status).toBe(200) + expect(fetchMock).toHaveBeenCalledTimes(3) + expect(fetchMock.mock.calls[1]?.[0]).toBe( + 'https://api.crowdstrike.com/identity-protection/queries/devices/v1?limit=1' + ) + expect(fetchMock.mock.calls[2]?.[0]).toBe( + 'https://api.crowdstrike.com/identity-protection/entities/devices/GET/v1' + ) + expect(fetchMock.mock.calls[2]?.[1]).toMatchObject({ + body: JSON.stringify({ ids: ['sensor-1'] }), + method: 'POST', + }) + expect(data.output).toEqual({ + count: 1, + pagination: { + limit: 1, + offset: 0, + total: 1, + }, + sensors: [normalizedSensor], + }) + }) + + it('fetches sensor details directly from device ids', async () => { + fetchMock + .mockResolvedValueOnce(jsonResponse({ access_token: 'token-123' })) + .mockResolvedValueOnce( + jsonResponse({ + resources: [sensorResource], + }) + ) + + const request = createMockRequest('POST', { + clientId: 'client-id', + clientSecret: 'client-secret', + cloud: 'us-1', + ids: ['sensor-1'], + operation: 'crowdstrike_get_sensor_details', + }) + + const response = await POST(request) + const data = await response.json() + + expect(response.status).toBe(200) + expect(fetchMock).toHaveBeenCalledTimes(2) + expect(fetchMock.mock.calls[1]?.[0]).toBe( + 'https://api.crowdstrike.com/identity-protection/entities/devices/GET/v1' + ) + expect(fetchMock.mock.calls[1]?.[1]).toMatchObject({ + body: JSON.stringify({ ids: ['sensor-1'] }), + method: 'POST', + }) + expect(data.output).toEqual({ + count: 1, + pagination: null, + sensors: [normalizedSensor], + }) + }) + + it('normalizes sensor aggregate results', async () => { + fetchMock + .mockResolvedValueOnce(jsonResponse({ access_token: 'token-123' })) + .mockResolvedValueOnce( + jsonResponse({ + resources: [ + { + buckets: [ + { + count: 2, + key_as_string: 'protected', + sub_aggregates: [ + { + buckets: [ + { + count: 2, + key_as_string: 'corp.local', + value: 2, + value_as_string: '2', + }, + ], + doc_count_error_upper_bound: 0, + name: 'machine_domain_counts', + sum_other_doc_count: 0, + }, + ], + value: 2, + value_as_string: '2', + }, + ], + doc_count_error_upper_bound: 0, + name: 'status_counts', + sum_other_doc_count: 0, + }, + ], + }) + ) + + const aggregateQuery = { + field: 'status', + name: 'status_counts', + size: 10, + type: 'terms', + } + + const request = createMockRequest('POST', { + aggregateQuery, + clientId: 'client-id', + clientSecret: 'client-secret', + cloud: 'us-1', + operation: 'crowdstrike_get_sensor_aggregates', + }) + + const response = await POST(request) + const data = await response.json() + + expect(response.status).toBe(200) + expect(fetchMock).toHaveBeenCalledTimes(2) + expect(fetchMock.mock.calls[1]?.[0]).toBe( + 'https://api.crowdstrike.com/identity-protection/aggregates/devices/GET/v1' + ) + expect(fetchMock.mock.calls[1]?.[1]).toMatchObject({ + body: JSON.stringify(aggregateQuery), + method: 'POST', + }) + expect(data.output).toEqual({ + aggregates: [ + { + buckets: [ + { + count: 2, + from: null, + keyAsString: 'protected', + label: null, + stringFrom: null, + stringTo: null, + subAggregates: [ + { + buckets: [ + { + count: 2, + from: null, + keyAsString: 'corp.local', + label: null, + stringFrom: null, + stringTo: null, + subAggregates: [], + to: null, + value: 2, + valueAsString: '2', + }, + ], + docCountErrorUpperBound: 0, + name: 'machine_domain_counts', + sumOtherDocCount: 0, + }, + ], + to: null, + value: 2, + valueAsString: '2', + }, + ], + docCountErrorUpperBound: 0, + name: 'status_counts', + sumOtherDocCount: 0, + }, + ], + count: 1, + }) + }) +}) diff --git a/apps/sim/app/api/tools/crowdstrike/query/route.ts b/apps/sim/app/api/tools/crowdstrike/query/route.ts index a9b1fc67233..29c67efb77d 100644 --- a/apps/sim/app/api/tools/crowdstrike/query/route.ts +++ b/apps/sim/app/api/tools/crowdstrike/query/route.ts @@ -3,33 +3,96 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateId } from '@/lib/core/utils/uuid' +import type { CrowdStrikeAggregateQuery, CrowdStrikeCloud } from '@/tools/crowdstrike/types' -const logger = createLogger('CrowdStrikeQueryAPI') +const logger = createLogger('CrowdStrikeIdentityProtectionAPI') const CROWDSTRIKE_CLOUDS = ['us-1', 'us-2', 'eu-1', 'us-gov-1', 'us-gov-2'] as const -const CROWDSTRIKE_OPERATIONS = [ - 'crowdstrike_query_behaviors', - 'crowdstrike_query_crowdscore', - 'crowdstrike_query_incidents', - 'crowdstrike_query_sensors', -] as const - -const QuerySchema = z.object({ - operation: z.enum(CROWDSTRIKE_OPERATIONS), + +type JsonRecord = Record + +const BaseRequestSchema = z.object({ clientId: z.string().min(1, 'Client ID is required'), clientSecret: z.string().min(1, 'Client Secret is required'), cloud: z.enum(CROWDSTRIKE_CLOUDS), - filter: z.string().nullish(), - limit: z.number().int().positive().nullish(), - offset: z.number().int().nonnegative().nullish(), - sort: z.string().nullish(), }) -type QueryRequest = z.infer -type JsonRecord = Record +const DateRangeSchema = z.object({ + from: z.string(), + to: z.string(), +}) + +const ExtendedBoundsSchema = z.object({ + max: z.string(), + min: z.string(), +}) + +const RangeSpecSchema = z.object({ + from: z.number(), + to: z.number(), +}) + +const AggregateQuerySchema: z.ZodType = z.lazy(() => + z.object({ + date_ranges: z.array(DateRangeSchema).optional(), + exclude: z.string().optional(), + extended_bounds: ExtendedBoundsSchema.optional(), + field: z.string().optional(), + filter: z.string().optional(), + from: z.number().int().nonnegative().optional(), + include: z.string().optional(), + interval: z.string().optional(), + max_doc_count: z.number().int().nonnegative().optional(), + min_doc_count: z.number().int().nonnegative().optional(), + missing: z.string().optional(), + name: z.string().optional(), + q: z.string().optional(), + ranges: z.array(RangeSpecSchema).optional(), + size: z.number().int().nonnegative().optional(), + sort: z.string().optional(), + sub_aggregates: z.array(AggregateQuerySchema).optional(), + time_zone: z.string().optional(), + type: z.string().optional(), + }) +) + +const QuerySensorsSchema = BaseRequestSchema.extend({ + operation: z.literal('crowdstrike_query_sensors'), + filter: z.string().optional(), + limit: z + .number() + .int() + .min(1, 'Limit must be at least 1') + .max(200, 'Limit must be at most 200') + .optional(), + offset: z.number().int().nonnegative('Offset must be 0 or greater').optional(), + sort: z.string().optional(), +}) -function getCloudBaseUrl(cloud: QueryRequest['cloud']): string { - const cloudMap: Record = { +const GetSensorDetailsSchema = BaseRequestSchema.extend({ + operation: z.literal('crowdstrike_get_sensor_details'), + ids: z + .array(z.string().trim().min(1, 'Sensor IDs must not be empty')) + .min(1, 'At least one sensor ID is required') + .max(5000, 'CrowdStrike supports up to 5000 sensor IDs per request'), +}) + +const GetSensorAggregatesSchema = BaseRequestSchema.extend({ + operation: z.literal('crowdstrike_get_sensor_aggregates'), + aggregateQuery: AggregateQuerySchema, +}) + +const RequestSchema = z.discriminatedUnion('operation', [ + QuerySensorsSchema, + GetSensorDetailsSchema, + GetSensorAggregatesSchema, +]) + +type CrowdStrikeAuthRequest = z.infer +type CrowdStrikeQuerySensorsRequest = z.infer + +function getCloudBaseUrl(cloud: CrowdStrikeCloud): string { + const cloudMap: Record = { 'eu-1': 'https://api.eu-1.crowdstrike.com', 'us-1': 'https://api.crowdstrike.com', 'us-2': 'https://api.us-2.crowdstrike.com', @@ -52,7 +115,15 @@ function getNumber(value: unknown): number | null { return typeof value === 'number' ? value : null } -function getArray(value: unknown): JsonRecord[] { +function getStringArray(value: unknown): string[] { + if (!Array.isArray(value)) { + return [] + } + + return value.filter((entry): entry is string => typeof entry === 'string') +} + +function getRecordArray(value: unknown): JsonRecord[] { if (!Array.isArray(value)) { return [] } @@ -60,13 +131,21 @@ function getArray(value: unknown): JsonRecord[] { return value.filter(isJsonRecord) } -function getResources(data: unknown): JsonRecord[] { +function getResourcesArray(data: unknown): unknown[] { const root = getResponseRoot(data) - if (!isJsonRecord(root)) { + if (!isJsonRecord(root) || !Array.isArray(root.resources)) { return [] } - return getArray(root.resources) + return root.resources +} + +function getRecordResources(data: unknown): JsonRecord[] { + return getResourcesArray(data).filter(isJsonRecord) +} + +function getStringResources(data: unknown): string[] { + return getStringArray(getResourcesArray(data)) } function getResponseRoot(data: unknown): unknown { @@ -88,10 +167,8 @@ function getPagination(data: unknown) { } return { - expiresAt: getNumber(root.meta.pagination.expires_at), limit: getNumber(root.meta.pagination.limit), - offset: - getNumber(root.meta.pagination.offset) ?? getString(root.meta.pagination.offset) ?? null, + offset: getNumber(root.meta.pagination.offset), total: getNumber(root.meta.pagination.total), } } @@ -118,44 +195,42 @@ function getErrorMessage(data: unknown, fallback: string): string { ) } -function buildOperationUrl(baseUrl: string, params: QueryRequest): string { +function buildQueryUrl(baseUrl: string, params: CrowdStrikeQuerySensorsRequest): string { const url = new URL(baseUrl) + url.pathname = '/identity-protection/queries/devices/v1' + + if (params.filter) { + url.searchParams.set('filter', params.filter) + } - switch (params.operation) { - case 'crowdstrike_query_sensors': - url.pathname = '/identity-protection/queries/devices/v1' - if (params.filter) url.searchParams.set('filter', params.filter) - if (params.limit != null) url.searchParams.set('limit', params.limit.toString()) - if (params.offset != null) url.searchParams.set('offset', params.offset.toString()) - if (params.sort) url.searchParams.set('sort', params.sort) - return url.toString() - case 'crowdstrike_query_crowdscore': - url.pathname = '/incidents/combined/crowdscores/v1' - if (params.filter) url.searchParams.set('filter', params.filter) - if (params.limit != null) url.searchParams.set('limit', params.limit.toString()) - if (params.offset != null) url.searchParams.set('offset', params.offset.toString()) - if (params.sort) url.searchParams.set('sort', params.sort) - return url.toString() - case 'crowdstrike_query_incidents': - url.pathname = '/incidents/queries/incidents/v1' - if (params.filter) url.searchParams.set('filter', params.filter) - if (params.limit != null) url.searchParams.set('limit', params.limit.toString()) - if (params.offset != null) url.searchParams.set('offset', params.offset.toString()) - if (params.sort) url.searchParams.set('sort', params.sort) - return url.toString() - case 'crowdstrike_query_behaviors': - url.pathname = '/incidents/queries/behaviors/v1' - if (params.filter) url.searchParams.set('filter', params.filter) - if (params.limit != null) url.searchParams.set('limit', params.limit.toString()) - if (params.offset != null) url.searchParams.set('offset', params.offset.toString()) - if (params.sort) url.searchParams.set('sort', params.sort) - return url.toString() + if (params.limit != null) { + url.searchParams.set('limit', params.limit.toString()) } - throw new Error(`Unsupported CrowdStrike operation: ${params.operation}`) + if (params.offset != null) { + url.searchParams.set('offset', params.offset.toString()) + } + + if (params.sort) { + url.searchParams.set('sort', params.sort) + } + + return url.toString() +} + +function buildSensorDetailsUrl(baseUrl: string): string { + const url = new URL(baseUrl) + url.pathname = '/identity-protection/entities/devices/GET/v1' + return url.toString() +} + +function buildSensorAggregatesUrl(baseUrl: string): string { + const url = new URL(baseUrl) + url.pathname = '/identity-protection/aggregates/devices/GET/v1' + return url.toString() } -async function getAccessToken(params: QueryRequest): Promise { +async function getAccessToken(params: CrowdStrikeAuthRequest): Promise { const baseUrl = getCloudBaseUrl(params.cloud) const response = await fetch(`${baseUrl}/oauth2/token`, { method: 'POST', @@ -183,70 +258,88 @@ async function getAccessToken(params: QueryRequest): Promise { return data.access_token } -function normalizeOutput(operation: QueryRequest['operation'], data: unknown) { - const pagination = getPagination(data) - - switch (operation) { - case 'crowdstrike_query_sensors': { - const sensors = getResources(data).map((resource) => ({ - agentId: getString(resource.agent_id), - hostname: getString(resource.hostname), - ipAddress: getString(resource.ip_address), - macAddress: getString(resource.mac_address), - })) - - return { - count: sensors.length, - pagination, - sensors, - } - } - case 'crowdstrike_query_crowdscore': { - const crowdScores = getResources(data).map((resource) => ({ - entityId: getString(resource.entity_uuid) ?? getString(resource.entity_id), - entityType: getString(resource.entity_type), - lastUpdated: getString(resource.last_updated), - score: getNumber(resource.score) ?? getNumber(resource.crowdscore), - })) - - return { - count: crowdScores.length, - crowdScores, - pagination, - } - } - case 'crowdstrike_query_incidents': { - const incidents = getResources(data).map((resource) => ({ - createdTimestamp: getString(resource.created_timestamp), - incidentId: getString(resource.incident_id), - name: getString(resource.name), - severity: getString(resource.severity), - status: getString(resource.status), - })) - - return { - count: incidents.length, - incidents, - pagination, - } - } - case 'crowdstrike_query_behaviors': { - const behaviors = getResources(data).map((resource) => ({ - behaviorId: getString(resource.behavior_id), - createdTimestamp: getString(resource.created_timestamp), - incidentId: getString(resource.incident_id), - name: getString(resource.name), - })) - - return { - behaviors, - count: behaviors.length, - pagination, - } - } +function normalizeSensor(resource: JsonRecord) { + return { + agentVersion: getString(resource.agent_version), + cid: getString(resource.cid), + deviceId: getString(resource.device_id), + heartbeatTime: getNumber(resource.heartbeat_time), + hostname: getString(resource.hostname), + idpPolicyId: getString(resource.idp_policy_id), + idpPolicyName: getString(resource.idp_policy_name), + ipAddress: getString(resource.local_ip), + kerberosConfig: getString(resource.kerberos_config), + ldapConfig: getString(resource.ldap_config), + ldapsConfig: getString(resource.ldaps_config), + machineDomain: getString(resource.machine_domain), + ntlmConfig: getString(resource.ntlm_config), + osVersion: getString(resource.os_version), + rdpToDcConfig: getString(resource.rdp_to_dc_config), + smbToDcConfig: getString(resource.smb_to_dc_config), + status: getString(resource.status), + statusCauses: getStringArray(resource.status_causes), + tiEnabled: getString(resource.ti_enabled), + } +} + +function normalizeSensorsOutput(data: unknown, paginationData?: unknown) { + const sensors = getRecordResources(data).map(normalizeSensor) + + return { + count: sensors.length, + pagination: paginationData == null ? null : getPagination(paginationData), + sensors, + } +} + +function normalizeAggregationResult(resource: JsonRecord) { + return { + buckets: getRecordArray(resource.buckets).map(normalizeAggregationBucket), + docCountErrorUpperBound: getNumber(resource.doc_count_error_upper_bound), + name: getString(resource.name), + sumOtherDocCount: getNumber(resource.sum_other_doc_count), + } +} + +function normalizeAggregationBucket(resource: JsonRecord) { + return { + count: getNumber(resource.count), + from: getNumber(resource.from), + keyAsString: getString(resource.key_as_string), + label: isJsonRecord(resource.label) ? resource.label : null, + stringFrom: getString(resource.string_from), + stringTo: getString(resource.string_to), + subAggregates: getRecordArray(resource.sub_aggregates).map(normalizeAggregationResult), + to: getNumber(resource.to), + value: getNumber(resource.value), + valueAsString: getString(resource.value_as_string), + } +} + +function normalizeAggregatesOutput(data: unknown) { + const aggregates = getRecordResources(data).map(normalizeAggregationResult) + + return { + aggregates, + count: aggregates.length, } +} - throw new Error(`Unsupported CrowdStrike operation: ${operation}`) +async function postCrowdStrikeJson( + url: string, + accessToken: string, + body: JsonRecord | CrowdStrikeAggregateQuery +) { + return fetch(url, { + method: 'POST', + headers: { + Accept: 'application/json', + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + cache: 'no-store', + }) } export async function POST(request: NextRequest) { @@ -262,50 +355,126 @@ export async function POST(request: NextRequest) { try { const rawBody: unknown = await request.json() - const params = QuerySchema.parse(rawBody) + const params = RequestSchema.parse(rawBody) const baseUrl = getCloudBaseUrl(params.cloud) const accessToken = await getAccessToken(params) - const apiUrl = buildOperationUrl(baseUrl, params) - logger.info(`[${requestId}] CrowdStrike query`, { + logger.info(`[${requestId}] CrowdStrike request`, { cloud: params.cloud, operation: params.operation, }) - const response = await fetch(apiUrl, { - method: 'GET', - headers: { - Accept: 'application/json', - Authorization: `Bearer ${accessToken}`, - }, - cache: 'no-store', - }) + if (params.operation === 'crowdstrike_query_sensors') { + const queryResponse = await fetch(buildQueryUrl(baseUrl, params), { + method: 'GET', + headers: { + Accept: 'application/json', + Authorization: `Bearer ${accessToken}`, + }, + cache: 'no-store', + }) + + const queryData: unknown = await queryResponse.json().catch(() => null) + if (!queryResponse.ok) { + return NextResponse.json( + { + success: false, + error: getErrorMessage(queryData, 'CrowdStrike request failed'), + }, + { status: queryResponse.status } + ) + } + + const ids = getStringResources(queryData) + if (ids.length === 0) { + return NextResponse.json({ + success: true, + output: normalizeSensorsOutput({ resources: [] }, queryData), + }) + } + + const detailResponse = await postCrowdStrikeJson( + buildSensorDetailsUrl(baseUrl), + accessToken, + { ids } + ) + + const detailData: unknown = await detailResponse.json().catch(() => null) + if (!detailResponse.ok) { + return NextResponse.json( + { + success: false, + error: getErrorMessage(detailData, 'Failed to fetch CrowdStrike sensor details'), + }, + { status: detailResponse.status } + ) + } + + return NextResponse.json({ + success: true, + output: normalizeSensorsOutput(detailData, queryData), + }) + } + + if (params.operation === 'crowdstrike_get_sensor_details') { + const detailResponse = await postCrowdStrikeJson( + buildSensorDetailsUrl(baseUrl), + accessToken, + { ids: params.ids } + ) + + const detailData: unknown = await detailResponse.json().catch(() => null) + if (!detailResponse.ok) { + return NextResponse.json( + { + success: false, + error: getErrorMessage(detailData, 'Failed to fetch CrowdStrike sensor details'), + }, + { status: detailResponse.status } + ) + } + + return NextResponse.json({ + success: true, + output: normalizeSensorsOutput(detailData), + }) + } + + const aggregateResponse = await postCrowdStrikeJson( + buildSensorAggregatesUrl(baseUrl), + accessToken, + params.aggregateQuery + ) - const data: unknown = await response.json().catch(() => null) - if (!response.ok) { + const aggregateData: unknown = await aggregateResponse.json().catch(() => null) + if (!aggregateResponse.ok) { return NextResponse.json( { success: false, - error: getErrorMessage(data, 'CrowdStrike request failed'), + error: getErrorMessage(aggregateData, 'Failed to fetch CrowdStrike sensor aggregates'), }, - { status: response.status } + { status: aggregateResponse.status } ) } return NextResponse.json({ success: true, - output: normalizeOutput(params.operation, data), + output: normalizeAggregatesOutput(aggregateData), }) } catch (error) { if (error instanceof z.ZodError) { return NextResponse.json( - { success: false, error: 'Invalid request data', details: error.errors }, + { + success: false, + error: error.errors[0]?.message ?? 'Invalid request data', + details: error.errors, + }, { status: 400 } ) } const message = error instanceof Error ? error.message : 'Unknown error' - logger.error(`[${requestId}] CrowdStrike query failed`, { error: message }) + logger.error(`[${requestId}] CrowdStrike request failed`, { error: message }) return NextResponse.json({ success: false, error: message }, { status: 500 }) } } diff --git a/apps/sim/blocks/blocks/crowdstrike.ts b/apps/sim/blocks/blocks/crowdstrike.ts index 2791eab051e..0eb4dcee1f0 100644 --- a/apps/sim/blocks/blocks/crowdstrike.ts +++ b/apps/sim/blocks/blocks/crowdstrike.ts @@ -1,37 +1,19 @@ import { CrowdStrikeIcon } from '@/components/icons' import type { BlockConfig } from '@/blocks/types' import { AuthMode, IntegrationType } from '@/blocks/types' +import { parseOptionalJsonInput, parseOptionalNumberInput } from '@/blocks/utils' import type { CrowdStrikeResponse } from '@/tools/crowdstrike/types' -const FILTER_OPERATIONS = [ - 'crowdstrike_query_sensors', - 'crowdstrike_query_crowdscore', - 'crowdstrike_query_incidents', - 'crowdstrike_query_behaviors', -] -const LIMIT_OFFSET_OPERATIONS = [ - 'crowdstrike_query_behaviors', - 'crowdstrike_query_crowdscore', - 'crowdstrike_query_incidents', - 'crowdstrike_query_sensors', -] -const SORT_OPERATIONS = [ - 'crowdstrike_query_behaviors', - 'crowdstrike_query_crowdscore', - 'crowdstrike_query_incidents', - 'crowdstrike_query_sensors', -] - export const CrowdStrikeBlock: BlockConfig = { type: 'crowdstrike', name: 'CrowdStrike', - description: 'Search Falcon sensors, CrowdScore, incidents, and behaviors', + description: 'Query CrowdStrike Identity Protection sensors and documented aggregates', longDescription: - 'Integrate CrowdStrike Falcon into workflows to search identity sensors, environment CrowdScore, incidents, and behaviors using documented Falcon query endpoints.', + 'Integrate CrowdStrike Identity Protection into workflows to search sensors, fetch documented sensor details by device ID, and run documented sensor aggregate queries.', docsLink: 'https://docs.sim.ai/tools/crowdstrike', category: 'tools', integrationType: IntegrationType.Security, - tags: ['monitoring', 'incident-management'], + tags: ['monitoring', 'security'], bgColor: '#E01F3D', icon: CrowdStrikeIcon, authMode: AuthMode.ApiKey, @@ -42,12 +24,12 @@ export const CrowdStrikeBlock: BlockConfig = { title: 'Operation', type: 'dropdown', options: [ - { label: 'Search Identity Sensors', id: 'crowdstrike_query_sensors' }, - { label: 'Get CrowdScore', id: 'crowdstrike_query_crowdscore' }, - { label: 'Search Incidents', id: 'crowdstrike_query_incidents' }, - { label: 'Search Behaviors', id: 'crowdstrike_query_behaviors' }, + { label: 'Query Sensors', id: 'crowdstrike_query_sensors' }, + { label: 'Get Sensor Details', id: 'crowdstrike_get_sensor_details' }, + { label: 'Get Sensor Aggregates', id: 'crowdstrike_get_sensor_aggregates' }, ], value: () => 'crowdstrike_query_sensors', + required: true, }, { id: 'clientId', @@ -82,17 +64,14 @@ export const CrowdStrikeBlock: BlockConfig = { id: 'filter', title: 'Filter', type: 'short-input', - placeholder: 'hostname:"server-01" or status:30', - condition: { - field: 'operation', - value: FILTER_OPERATIONS, - }, + placeholder: 'hostname:"server-01" or status:"protected"', + condition: { field: 'operation', value: 'crowdstrike_query_sensors' }, wandConfig: { enabled: true, prompt: - 'Generate a CrowdStrike Falcon Query Language filter string for the selected operation. Use exact field names, operators, and values only. Return ONLY the filter string - no explanations, no extra text.', + 'Generate a CrowdStrike Identity Protection Falcon Query Language filter string for sensor search. Use exact field names, operators, and values only. Return ONLY the filter string - no explanations, no extra text.', placeholder: - 'Describe the CrowdStrike records you want to filter, for example "sensors with hostnames starting with web" or "incidents in progress"...', + 'Describe the sensors you want to search, for example "sensors with hostnames starting with web" or "sensors with protected status"...', }, }, { @@ -100,7 +79,7 @@ export const CrowdStrikeBlock: BlockConfig = { title: 'Limit', type: 'short-input', placeholder: '100', - condition: { field: 'operation', value: LIMIT_OFFSET_OPERATIONS }, + condition: { field: 'operation', value: 'crowdstrike_query_sensors' }, mode: 'advanced', }, { @@ -108,28 +87,55 @@ export const CrowdStrikeBlock: BlockConfig = { title: 'Offset', type: 'short-input', placeholder: '0', - condition: { field: 'operation', value: LIMIT_OFFSET_OPERATIONS }, + condition: { field: 'operation', value: 'crowdstrike_query_sensors' }, mode: 'advanced', }, { id: 'sort', title: 'Sort', type: 'short-input', - placeholder: 'created_timestamp.asc', - condition: { field: 'operation', value: SORT_OPERATIONS }, + placeholder: 'status.asc', + condition: { field: 'operation', value: 'crowdstrike_query_sensors' }, mode: 'advanced', }, + { + id: 'ids', + title: 'Sensor IDs', + type: 'code', + language: 'json', + placeholder: '["device-id-1", "device-id-2"]', + condition: { field: 'operation', value: 'crowdstrike_get_sensor_details' }, + required: { field: 'operation', value: 'crowdstrike_get_sensor_details' }, + }, + { + id: 'aggregateQuery', + title: 'Aggregate Query', + type: 'code', + language: 'json', + placeholder: + '{\n "field": "field_name",\n "name": "aggregate_name",\n "size": 10,\n "type": "aggregate_type"\n}', + condition: { field: 'operation', value: 'crowdstrike_get_sensor_aggregates' }, + required: { field: 'operation', value: 'crowdstrike_get_sensor_aggregates' }, + wandConfig: { + enabled: true, + prompt: + 'Generate a CrowdStrike Identity Protection sensor aggregate query JSON object using documented aggregate body fields such as field, filter, size, sort, type, date_ranges, ranges, extended_bounds, and sub_aggregates. Return ONLY valid JSON.', + placeholder: + 'Describe the aggregation you want to run, for example "count sensors by status"...', + generationType: 'json-object', + }, + }, ], tools: { access: [ - 'crowdstrike_query_behaviors', - 'crowdstrike_query_crowdscore', - 'crowdstrike_query_incidents', + 'crowdstrike_get_sensor_aggregates', + 'crowdstrike_get_sensor_details', 'crowdstrike_query_sensors', ], config: { - tool: (params) => params.operation, + tool: (params) => + typeof params.operation === 'string' ? params.operation : 'crowdstrike_query_sensors', params: (params) => { const mapped: Record = { clientId: params.clientId, @@ -137,10 +143,33 @@ export const CrowdStrikeBlock: BlockConfig = { cloud: params.cloud, } - if (params.filter) mapped.filter = params.filter - if (params.limit != null && params.limit !== '') mapped.limit = Number(params.limit) - if (params.offset != null && params.offset !== '') mapped.offset = Number(params.offset) - if (params.sort) mapped.sort = params.sort + if (params.operation === 'crowdstrike_query_sensors') { + if (params.filter) mapped.filter = params.filter + + const limit = parseOptionalNumberInput(params.limit, 'limit', { + integer: true, + max: 200, + min: 1, + }) + const offset = parseOptionalNumberInput(params.offset, 'offset', { + integer: true, + min: 0, + }) + + if (limit != null) mapped.limit = limit + if (offset != null) mapped.offset = offset + if (params.sort) mapped.sort = params.sort + } + + if (params.operation === 'crowdstrike_get_sensor_details') { + const ids = parseOptionalJsonInput(params.ids, 'sensor IDs') + if (ids !== undefined) mapped.ids = ids + } + + if (params.operation === 'crowdstrike_get_sensor_aggregates') { + const aggregateQuery = parseOptionalJsonInput(params.aggregateQuery, 'aggregate query') + if (aggregateQuery !== undefined) mapped.aggregateQuery = aggregateQuery + } return mapped }, @@ -148,12 +177,18 @@ export const CrowdStrikeBlock: BlockConfig = { }, inputs: { + operation: { type: 'string', description: 'Selected CrowdStrike operation' }, clientId: { type: 'string', description: 'CrowdStrike Falcon API client ID' }, clientSecret: { type: 'string', description: 'CrowdStrike Falcon API client secret' }, cloud: { type: 'string', description: 'CrowdStrike Falcon cloud region' }, filter: { type: 'string', description: 'Falcon Query Language filter' }, - limit: { type: 'string', description: 'Maximum number of records to return' }, - offset: { type: 'string', description: 'Pagination offset' }, + ids: { type: 'json', description: 'JSON array of CrowdStrike sensor device IDs' }, + aggregateQuery: { + type: 'json', + description: 'CrowdStrike sensor aggregate query body as JSON', + }, + limit: { type: 'number', description: 'Maximum number of records to return' }, + offset: { type: 'number', description: 'Pagination offset' }, sort: { type: 'string', description: 'Sort expression' }, }, @@ -161,25 +196,16 @@ export const CrowdStrikeBlock: BlockConfig = { sensors: { type: 'json', description: - 'CrowdStrike identity sensor summaries (agentId, hostname, ipAddress, macAddress)', - }, - crowdScores: { - type: 'json', - description: 'CrowdStrike CrowdScore entities (entityId, entityType, score, lastUpdated)', - }, - incidents: { - type: 'json', - description: - 'CrowdStrike incident summaries (incidentId, name, createdTimestamp, status, severity)', + 'CrowdStrike identity sensor records (agentVersion, cid, deviceId, heartbeatTime, hostname, idpPolicyId, idpPolicyName, ipAddress, kerberosConfig, ldapConfig, ldapsConfig, machineDomain, ntlmConfig, osVersion, rdpToDcConfig, smbToDcConfig, status, statusCauses, tiEnabled)', }, - behaviors: { + aggregates: { type: 'json', description: - 'CrowdStrike behavior summaries (behaviorId, incidentId, name, createdTimestamp)', + 'CrowdStrike aggregate result groups (name, buckets, docCountErrorUpperBound, sumOtherDocCount)', }, pagination: { type: 'json', - description: 'Pagination metadata (offset, limit, total, expiresAt) for paginated responses', + description: 'Pagination metadata (limit, offset, total) for query responses', }, count: { type: 'number', description: 'Number of records returned by the selected operation' }, }, diff --git a/apps/sim/blocks/utils.test.ts b/apps/sim/blocks/utils.test.ts index 0e147a3c7f3..e9ed32609f9 100644 --- a/apps/sim/blocks/utils.test.ts +++ b/apps/sim/blocks/utils.test.ts @@ -66,7 +66,11 @@ vi.mock('@/lib/oauth/utils', () => ({ getScopesForService: vi.fn(() => []), })) -import { getApiKeyCondition } from '@/blocks/utils' +import { + getApiKeyCondition, + parseOptionalJsonInput, + parseOptionalNumberInput, +} from '@/blocks/utils' const BASE_CLOUD_MODELS: Record = { 'gpt-4o': 'openai', @@ -265,3 +269,59 @@ describe('getApiKeyCondition / shouldRequireApiKeyForModel', () => { }) }) }) + +describe('parseOptionalJsonInput', () => { + it('returns undefined for empty values', () => { + expect(parseOptionalJsonInput('', 'payload')).toBeUndefined() + expect(parseOptionalJsonInput(' ', 'payload')).toBeUndefined() + expect(parseOptionalJsonInput(undefined, 'payload')).toBeUndefined() + }) + + it('parses JSON strings', () => { + expect(parseOptionalJsonInput('{"a":1}', 'payload')).toEqual({ a: 1 }) + expect(parseOptionalJsonInput('["a","b"]', 'payload')).toEqual(['a', 'b']) + }) + + it('returns non-string values as-is', () => { + const value = { a: 1 } + expect(parseOptionalJsonInput(value, 'payload')).toBe(value) + }) + + it('throws a helpful error for invalid JSON', () => { + expect(() => parseOptionalJsonInput('{', 'payload')).toThrow(/Invalid JSON for payload/) + }) +}) + +describe('parseOptionalNumberInput', () => { + it('returns undefined for empty values', () => { + expect(parseOptionalNumberInput('', 'limit')).toBeUndefined() + expect(parseOptionalNumberInput(' ', 'limit')).toBeUndefined() + expect(parseOptionalNumberInput(undefined, 'limit')).toBeUndefined() + }) + + it('parses number strings and number values', () => { + expect(parseOptionalNumberInput('42', 'limit')).toBe(42) + expect(parseOptionalNumberInput(7, 'limit')).toBe(7) + }) + + it('validates integer-only values', () => { + expect(parseOptionalNumberInput('42', 'limit', { integer: true })).toBe(42) + expect(() => parseOptionalNumberInput('1.5', 'limit', { integer: true })).toThrow( + /expected an integer/i + ) + }) + + it('validates min and max bounds', () => { + expect(parseOptionalNumberInput('10', 'limit', { min: 1, max: 20 })).toBe(10) + expect(() => parseOptionalNumberInput('0', 'limit', { min: 1 })).toThrow( + /limit must be at least 1/i + ) + expect(() => parseOptionalNumberInput('21', 'limit', { max: 20 })).toThrow( + /limit must be at most 20/i + ) + }) + + it('throws a helpful error for invalid numbers', () => { + expect(() => parseOptionalNumberInput('abc', 'limit')).toThrow(/Invalid number for limit/i) + }) +}) diff --git a/apps/sim/blocks/utils.ts b/apps/sim/blocks/utils.ts index a31bd10c725..c6cc746b56a 100644 --- a/apps/sim/blocks/utils.ts +++ b/apps/sim/blocks/utils.ts @@ -361,6 +361,86 @@ export function createVersionedToolSelector> } } +interface ParseOptionalNumberInputOptions { + integer?: boolean + max?: number + min?: number +} + +/** + * Parses an optional JSON-capable block input value. + * Returns `undefined` for empty values and throws a helpful error for invalid JSON strings. + */ +export function parseOptionalJsonInput(value: unknown, label: string): T | undefined { + if (value === undefined || value === null || value === '') { + return undefined + } + + if (typeof value === 'string') { + const trimmed = value.trim() + if (trimmed.length === 0) { + return undefined + } + + try { + return JSON.parse(trimmed) as T + } catch (error) { + throw new Error( + `Invalid JSON for ${label}: ${error instanceof Error ? error.message : String(error)}` + ) + } + } + + return value as T +} + +/** + * Parses an optional numeric block input value. + * Returns `undefined` for empty values and throws when the provided value is not a valid number. + */ +export function parseOptionalNumberInput( + value: unknown, + label: string, + options: ParseOptionalNumberInputOptions = {} +): number | undefined { + if (value === undefined || value === null || value === '') { + return undefined + } + + let parsed: number + + if (typeof value === 'number') { + parsed = value + } else if (typeof value === 'string') { + const trimmed = value.trim() + if (trimmed.length === 0) { + return undefined + } + + parsed = Number(trimmed) + } else { + throw new Error(`Invalid number for ${label}: expected a valid number.`) + } + + if (!Number.isFinite(parsed)) { + throw new Error(`Invalid number for ${label}: expected a valid number.`) + } + + if (options.integer && !Number.isInteger(parsed)) { + throw new Error(`Invalid number for ${label}: expected an integer.`) + } + + if (options.min != null && parsed < options.min) { + throw new Error(`${label} must be at least ${options.min}.`) + } + + if (options.max != null && parsed > options.max) { + throw new Error(`${label} must be at most ${options.max}.`) + } + + return parsed +} + const DEFAULT_MULTIPLE_FILES_ERROR = 'File reference must be a single file, not an array. Use to select one file.' diff --git a/apps/sim/tools/crowdstrike/get_sensor_aggregates.ts b/apps/sim/tools/crowdstrike/get_sensor_aggregates.ts new file mode 100644 index 00000000000..529bd7a5976 --- /dev/null +++ b/apps/sim/tools/crowdstrike/get_sensor_aggregates.ts @@ -0,0 +1,161 @@ +import type { + CrowdStrikeGetSensorAggregatesParams, + CrowdStrikeGetSensorAggregatesResponse, +} from '@/tools/crowdstrike/types' +import type { ToolConfig } from '@/tools/types' + +export const crowdstrikeGetSensorAggregatesTool: ToolConfig< + CrowdStrikeGetSensorAggregatesParams, + CrowdStrikeGetSensorAggregatesResponse +> = { + id: 'crowdstrike_get_sensor_aggregates', + name: 'CrowdStrike Get Sensor Aggregates', + description: + 'Get documented CrowdStrike Identity Protection sensor aggregates from a JSON aggregate query body', + version: '1.0.0', + + params: { + clientId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'CrowdStrike Falcon API client ID', + }, + clientSecret: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'CrowdStrike Falcon API client secret', + }, + cloud: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'CrowdStrike Falcon cloud region', + }, + aggregateQuery: { + type: 'json', + required: true, + visibility: 'user-or-llm', + description: 'JSON aggregate query body documented by CrowdStrike for sensor aggregates', + }, + }, + + request: { + url: '/api/tools/crowdstrike/query', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + aggregateQuery: params.aggregateQuery, + cloud: params.cloud, + clientId: params.clientId, + clientSecret: params.clientSecret, + operation: 'crowdstrike_get_sensor_aggregates', + }), + }, + + transformResponse: async (response) => { + const data = await response.json() + + if (!response.ok || data.success === false) { + throw new Error(data.error || 'Failed to fetch CrowdStrike sensor aggregates') + } + + return { + success: true, + output: data.output, + } + }, + + outputs: { + aggregates: { + type: 'array', + description: 'Aggregate result groups returned by CrowdStrike', + items: { + type: 'object', + properties: { + buckets: { + type: 'array', + description: 'Buckets within the aggregate result', + items: { + type: 'object', + properties: { + count: { + type: 'number', + description: 'Bucket document count', + optional: true, + }, + from: { + type: 'number', + description: 'Bucket lower bound', + optional: true, + }, + keyAsString: { + type: 'string', + description: 'String representation of the bucket key', + optional: true, + }, + label: { + type: 'json', + description: 'Bucket label object', + optional: true, + }, + stringFrom: { + type: 'string', + description: 'String lower bound', + optional: true, + }, + stringTo: { + type: 'string', + description: 'String upper bound', + optional: true, + }, + subAggregates: { + type: 'json', + description: 'Nested aggregate results for this bucket', + optional: true, + }, + to: { + type: 'number', + description: 'Bucket upper bound', + optional: true, + }, + value: { + type: 'number', + description: 'Bucket metric value', + optional: true, + }, + valueAsString: { + type: 'string', + description: 'String representation of the bucket value', + optional: true, + }, + }, + }, + }, + docCountErrorUpperBound: { + type: 'number', + description: 'Upper bound for bucket count error', + optional: true, + }, + name: { + type: 'string', + description: 'Aggregate result name', + optional: true, + }, + sumOtherDocCount: { + type: 'number', + description: 'Document count not included in the returned buckets', + optional: true, + }, + }, + }, + }, + count: { + type: 'number', + description: 'Number of aggregate result groups returned', + }, + }, +} diff --git a/apps/sim/tools/crowdstrike/get_sensor_details.ts b/apps/sim/tools/crowdstrike/get_sensor_details.ts new file mode 100644 index 00000000000..dcbf44de73a --- /dev/null +++ b/apps/sim/tools/crowdstrike/get_sensor_details.ts @@ -0,0 +1,193 @@ +import type { + CrowdStrikeGetSensorDetailsParams, + CrowdStrikeGetSensorDetailsResponse, +} from '@/tools/crowdstrike/types' +import type { ToolConfig } from '@/tools/types' + +export const crowdstrikeGetSensorDetailsTool: ToolConfig< + CrowdStrikeGetSensorDetailsParams, + CrowdStrikeGetSensorDetailsResponse +> = { + id: 'crowdstrike_get_sensor_details', + name: 'CrowdStrike Get Sensor Details', + description: + 'Get documented CrowdStrike Identity Protection sensor details for one or more device IDs', + version: '1.0.0', + + params: { + clientId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'CrowdStrike Falcon API client ID', + }, + clientSecret: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'CrowdStrike Falcon API client secret', + }, + cloud: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'CrowdStrike Falcon cloud region', + }, + ids: { + type: 'json', + required: true, + visibility: 'user-or-llm', + description: 'JSON array of CrowdStrike sensor device IDs', + }, + }, + + request: { + url: '/api/tools/crowdstrike/query', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + cloud: params.cloud, + clientId: params.clientId, + clientSecret: params.clientSecret, + ids: params.ids, + operation: 'crowdstrike_get_sensor_details', + }), + }, + + transformResponse: async (response) => { + const data = await response.json() + + if (!response.ok || data.success === false) { + throw new Error(data.error || 'Failed to fetch CrowdStrike sensor details') + } + + return { + success: true, + output: data.output, + } + }, + + outputs: { + sensors: { + type: 'array', + description: 'CrowdStrike identity sensor detail records', + items: { + type: 'object', + properties: { + agentVersion: { + type: 'string', + description: 'Sensor agent version', + optional: true, + }, + cid: { + type: 'string', + description: 'CrowdStrike customer identifier', + }, + deviceId: { + type: 'string', + description: 'Sensor device identifier', + }, + heartbeatTime: { + type: 'number', + description: 'Last heartbeat timestamp', + optional: true, + }, + hostname: { + type: 'string', + description: 'Sensor hostname', + optional: true, + }, + idpPolicyId: { + type: 'string', + description: 'Assigned Identity Protection policy ID', + optional: true, + }, + idpPolicyName: { + type: 'string', + description: 'Assigned Identity Protection policy name', + optional: true, + }, + ipAddress: { + type: 'string', + description: 'Sensor local IP address', + optional: true, + }, + kerberosConfig: { + type: 'string', + description: 'Kerberos configuration status', + optional: true, + }, + ldapConfig: { + type: 'string', + description: 'LDAP configuration status', + optional: true, + }, + ldapsConfig: { + type: 'string', + description: 'LDAPS configuration status', + optional: true, + }, + machineDomain: { + type: 'string', + description: 'Machine domain', + optional: true, + }, + ntlmConfig: { + type: 'string', + description: 'NTLM configuration status', + optional: true, + }, + osVersion: { + type: 'string', + description: 'Operating system version', + optional: true, + }, + rdpToDcConfig: { + type: 'string', + description: 'RDP to domain controller configuration status', + optional: true, + }, + smbToDcConfig: { + type: 'string', + description: 'SMB to domain controller configuration status', + optional: true, + }, + status: { + type: 'string', + description: 'Sensor protection status', + optional: true, + }, + statusCauses: { + type: 'array', + description: 'Documented causes behind the current status', + optional: true, + items: { + type: 'string', + }, + }, + tiEnabled: { + type: 'string', + description: 'Threat intelligence enablement status', + optional: true, + }, + }, + }, + }, + count: { + type: 'number', + description: 'Number of sensors returned', + }, + pagination: { + type: 'json', + description: 'Pagination metadata when returned by the underlying API', + optional: true, + properties: { + limit: { type: 'number', description: 'Page size used for the query', optional: true }, + offset: { type: 'number', description: 'Offset returned by CrowdStrike', optional: true }, + total: { type: 'number', description: 'Total records available', optional: true }, + }, + }, + }, +} diff --git a/apps/sim/tools/crowdstrike/index.ts b/apps/sim/tools/crowdstrike/index.ts index b02f1fd3a29..e0878239332 100644 --- a/apps/sim/tools/crowdstrike/index.ts +++ b/apps/sim/tools/crowdstrike/index.ts @@ -1,5 +1,4 @@ -export { crowdstrikeQueryBehaviorsTool } from './query_behaviors' -export { crowdstrikeQueryCrowdScoreTool } from './query_crowdscore' -export { crowdstrikeQueryIncidentsTool } from './query_incidents' +export { crowdstrikeGetSensorAggregatesTool } from './get_sensor_aggregates' +export { crowdstrikeGetSensorDetailsTool } from './get_sensor_details' export { crowdstrikeQuerySensorsTool } from './query_sensors' export * from './types' diff --git a/apps/sim/tools/crowdstrike/query_behaviors.ts b/apps/sim/tools/crowdstrike/query_behaviors.ts deleted file mode 100644 index a4ec19a8c5b..00000000000 --- a/apps/sim/tools/crowdstrike/query_behaviors.ts +++ /dev/null @@ -1,130 +0,0 @@ -import type { - CrowdStrikeQueryBehaviorsParams, - CrowdStrikeQueryBehaviorsResponse, -} from '@/tools/crowdstrike/types' -import type { ToolConfig } from '@/tools/types' - -export const crowdstrikeQueryBehaviorsTool: ToolConfig< - CrowdStrikeQueryBehaviorsParams, - CrowdStrikeQueryBehaviorsResponse -> = { - id: 'crowdstrike_query_behaviors', - name: 'CrowdStrike Query Behaviors', - description: 'Search CrowdStrike behaviors by filter, sort order, and pagination', - version: '1.0.0', - - params: { - clientId: { - type: 'string', - required: true, - visibility: 'user-only', - description: 'CrowdStrike Falcon API client ID', - }, - clientSecret: { - type: 'string', - required: true, - visibility: 'user-only', - description: 'CrowdStrike Falcon API client secret', - }, - cloud: { - type: 'string', - required: true, - visibility: 'user-only', - description: 'CrowdStrike Falcon cloud region', - }, - filter: { - type: 'string', - required: false, - visibility: 'user-or-llm', - description: 'Falcon Query Language filter for behavior search', - }, - limit: { - type: 'number', - required: false, - visibility: 'user-or-llm', - description: 'Maximum number of behavior records to return', - }, - offset: { - type: 'number', - required: false, - visibility: 'user-or-llm', - description: 'Pagination offset for the behavior query', - }, - sort: { - type: 'string', - required: false, - visibility: 'user-or-llm', - description: 'Sort expression for behavior results', - }, - }, - - request: { - url: '/api/tools/crowdstrike/query', - method: 'POST', - headers: () => ({ - 'Content-Type': 'application/json', - }), - body: (params) => ({ - cloud: params.cloud, - clientId: params.clientId, - clientSecret: params.clientSecret, - filter: params.filter, - limit: params.limit, - offset: params.offset, - operation: 'crowdstrike_query_behaviors', - sort: params.sort, - }), - }, - - transformResponse: async (response) => { - const data = await response.json() - - if (!response.ok || data.success === false) { - throw new Error(data.error || 'Failed to query CrowdStrike behaviors') - } - - return { - success: true, - output: data.output, - } - }, - - outputs: { - behaviors: { - type: 'array', - description: 'Matching CrowdStrike behavior records', - items: { - type: 'object', - properties: { - behaviorId: { type: 'string', description: 'Behavior identifier', optional: true }, - incidentId: { type: 'string', description: 'Parent incident identifier', optional: true }, - name: { type: 'string', description: 'Behavior name', optional: true }, - createdTimestamp: { - type: 'string', - description: 'Behavior creation timestamp', - optional: true, - }, - }, - }, - }, - count: { - type: 'number', - description: 'Number of behaviors returned', - }, - pagination: { - type: 'json', - description: 'Pagination metadata (offset, limit, total, expiresAt)', - optional: true, - properties: { - expiresAt: { - type: 'number', - description: 'Pagination cursor expiry timestamp', - optional: true, - }, - limit: { type: 'number', description: 'Page size used for the query', optional: true }, - offset: { type: 'number', description: 'Offset returned by CrowdStrike', optional: true }, - total: { type: 'number', description: 'Total records available', optional: true }, - }, - }, - }, -} diff --git a/apps/sim/tools/crowdstrike/query_crowdscore.ts b/apps/sim/tools/crowdstrike/query_crowdscore.ts deleted file mode 100644 index 828d5657a43..00000000000 --- a/apps/sim/tools/crowdstrike/query_crowdscore.ts +++ /dev/null @@ -1,126 +0,0 @@ -import type { - CrowdStrikeQueryCrowdScoreParams, - CrowdStrikeQueryCrowdScoreResponse, -} from '@/tools/crowdstrike/types' -import type { ToolConfig } from '@/tools/types' - -export const crowdstrikeQueryCrowdScoreTool: ToolConfig< - CrowdStrikeQueryCrowdScoreParams, - CrowdStrikeQueryCrowdScoreResponse -> = { - id: 'crowdstrike_query_crowdscore', - name: 'CrowdStrike Query CrowdScore', - description: 'Retrieve environment-wide CrowdScore entities from CrowdStrike Falcon', - version: '1.0.0', - - params: { - clientId: { - type: 'string', - required: true, - visibility: 'user-only', - description: 'CrowdStrike Falcon API client ID', - }, - clientSecret: { - type: 'string', - required: true, - visibility: 'user-only', - description: 'CrowdStrike Falcon API client secret', - }, - cloud: { - type: 'string', - required: true, - visibility: 'user-only', - description: 'CrowdStrike Falcon cloud region', - }, - filter: { - type: 'string', - required: false, - visibility: 'user-or-llm', - description: 'Falcon Query Language filter for CrowdScore search', - }, - limit: { - type: 'number', - required: false, - visibility: 'user-or-llm', - description: 'Maximum number of CrowdScore records to return', - }, - offset: { - type: 'number', - required: false, - visibility: 'user-or-llm', - description: 'Pagination offset for CrowdScore results', - }, - sort: { - type: 'string', - required: false, - visibility: 'user-or-llm', - description: 'Sort expression for CrowdScore results', - }, - }, - - request: { - url: '/api/tools/crowdstrike/query', - method: 'POST', - headers: () => ({ - 'Content-Type': 'application/json', - }), - body: (params) => ({ - cloud: params.cloud, - clientId: params.clientId, - clientSecret: params.clientSecret, - filter: params.filter, - limit: params.limit, - offset: params.offset, - operation: 'crowdstrike_query_crowdscore', - sort: params.sort, - }), - }, - - transformResponse: async (response) => { - const data = await response.json() - - if (!response.ok || data.success === false) { - throw new Error(data.error || 'Failed to query CrowdStrike CrowdScore') - } - - return { - success: true, - output: data.output, - } - }, - - outputs: { - crowdScores: { - type: 'array', - description: 'CrowdStrike CrowdScore entities', - items: { - type: 'object', - properties: { - entityId: { type: 'string', description: 'Entity identifier', optional: true }, - entityType: { type: 'string', description: 'Entity type', optional: true }, - lastUpdated: { type: 'string', description: 'Last update timestamp', optional: true }, - score: { type: 'number', description: 'CrowdScore value', optional: true }, - }, - }, - }, - count: { - type: 'number', - description: 'Number of CrowdScore records returned', - }, - pagination: { - type: 'json', - description: 'Pagination metadata (offset, limit, total, expiresAt)', - optional: true, - properties: { - expiresAt: { - type: 'number', - description: 'Pagination cursor expiry timestamp', - optional: true, - }, - limit: { type: 'number', description: 'Page size used for the query', optional: true }, - offset: { type: 'number', description: 'Offset returned by CrowdStrike', optional: true }, - total: { type: 'number', description: 'Total records available', optional: true }, - }, - }, - }, -} diff --git a/apps/sim/tools/crowdstrike/query_incidents.ts b/apps/sim/tools/crowdstrike/query_incidents.ts deleted file mode 100644 index ea05893f11d..00000000000 --- a/apps/sim/tools/crowdstrike/query_incidents.ts +++ /dev/null @@ -1,131 +0,0 @@ -import type { - CrowdStrikeQueryIncidentsParams, - CrowdStrikeQueryIncidentsResponse, -} from '@/tools/crowdstrike/types' -import type { ToolConfig } from '@/tools/types' - -export const crowdstrikeQueryIncidentsTool: ToolConfig< - CrowdStrikeQueryIncidentsParams, - CrowdStrikeQueryIncidentsResponse -> = { - id: 'crowdstrike_query_incidents', - name: 'CrowdStrike Query Incidents', - description: 'Search CrowdStrike incidents by filter, sort order, and pagination', - version: '1.0.0', - - params: { - clientId: { - type: 'string', - required: true, - visibility: 'user-only', - description: 'CrowdStrike Falcon API client ID', - }, - clientSecret: { - type: 'string', - required: true, - visibility: 'user-only', - description: 'CrowdStrike Falcon API client secret', - }, - cloud: { - type: 'string', - required: true, - visibility: 'user-only', - description: 'CrowdStrike Falcon cloud region', - }, - filter: { - type: 'string', - required: false, - visibility: 'user-or-llm', - description: 'Falcon Query Language filter for incident search', - }, - limit: { - type: 'number', - required: false, - visibility: 'user-or-llm', - description: 'Maximum number of incident records to return', - }, - offset: { - type: 'number', - required: false, - visibility: 'user-or-llm', - description: 'Pagination offset for the incident query', - }, - sort: { - type: 'string', - required: false, - visibility: 'user-or-llm', - description: 'Sort expression for incident results', - }, - }, - - request: { - url: '/api/tools/crowdstrike/query', - method: 'POST', - headers: () => ({ - 'Content-Type': 'application/json', - }), - body: (params) => ({ - cloud: params.cloud, - clientId: params.clientId, - clientSecret: params.clientSecret, - filter: params.filter, - limit: params.limit, - offset: params.offset, - operation: 'crowdstrike_query_incidents', - sort: params.sort, - }), - }, - - transformResponse: async (response) => { - const data = await response.json() - - if (!response.ok || data.success === false) { - throw new Error(data.error || 'Failed to query CrowdStrike incidents') - } - - return { - success: true, - output: data.output, - } - }, - - outputs: { - incidents: { - type: 'array', - description: 'Matching CrowdStrike incident records', - items: { - type: 'object', - properties: { - incidentId: { type: 'string', description: 'Incident identifier', optional: true }, - name: { type: 'string', description: 'Incident name', optional: true }, - createdTimestamp: { - type: 'string', - description: 'Incident creation timestamp', - optional: true, - }, - status: { type: 'string', description: 'Incident status', optional: true }, - severity: { type: 'string', description: 'Incident severity', optional: true }, - }, - }, - }, - count: { - type: 'number', - description: 'Number of incidents returned', - }, - pagination: { - type: 'json', - description: 'Pagination metadata (offset, limit, total, expiresAt)', - optional: true, - properties: { - expiresAt: { - type: 'number', - description: 'Pagination cursor expiry timestamp', - optional: true, - }, - limit: { type: 'number', description: 'Page size used for the query', optional: true }, - offset: { type: 'number', description: 'Offset returned by CrowdStrike', optional: true }, - total: { type: 'number', description: 'Total records available', optional: true }, - }, - }, - }, -} diff --git a/apps/sim/tools/crowdstrike/query_sensors.ts b/apps/sim/tools/crowdstrike/query_sensors.ts index 43ee4736526..d6547815906 100644 --- a/apps/sim/tools/crowdstrike/query_sensors.ts +++ b/apps/sim/tools/crowdstrike/query_sensors.ts @@ -96,10 +96,102 @@ export const crowdstrikeQuerySensorsTool: ToolConfig< items: { type: 'object', properties: { - agentId: { type: 'string', description: 'Sensor agent identifier', optional: true }, - hostname: { type: 'string', description: 'Sensor hostname', optional: true }, - ipAddress: { type: 'string', description: 'Sensor IP address', optional: true }, - macAddress: { type: 'string', description: 'Sensor MAC address', optional: true }, + agentVersion: { + type: 'string', + description: 'Sensor agent version', + optional: true, + }, + cid: { + type: 'string', + description: 'CrowdStrike customer identifier', + }, + deviceId: { + type: 'string', + description: 'Sensor device identifier', + }, + heartbeatTime: { + type: 'number', + description: 'Last heartbeat timestamp', + optional: true, + }, + hostname: { + type: 'string', + description: 'Sensor hostname', + optional: true, + }, + idpPolicyId: { + type: 'string', + description: 'Assigned Identity Protection policy ID', + optional: true, + }, + idpPolicyName: { + type: 'string', + description: 'Assigned Identity Protection policy name', + optional: true, + }, + ipAddress: { + type: 'string', + description: 'Sensor local IP address', + optional: true, + }, + kerberosConfig: { + type: 'string', + description: 'Kerberos configuration status', + optional: true, + }, + ldapConfig: { + type: 'string', + description: 'LDAP configuration status', + optional: true, + }, + ldapsConfig: { + type: 'string', + description: 'LDAPS configuration status', + optional: true, + }, + machineDomain: { + type: 'string', + description: 'Machine domain', + optional: true, + }, + ntlmConfig: { + type: 'string', + description: 'NTLM configuration status', + optional: true, + }, + osVersion: { + type: 'string', + description: 'Operating system version', + optional: true, + }, + rdpToDcConfig: { + type: 'string', + description: 'RDP to domain controller configuration status', + optional: true, + }, + smbToDcConfig: { + type: 'string', + description: 'SMB to domain controller configuration status', + optional: true, + }, + status: { + type: 'string', + description: 'Sensor protection status', + optional: true, + }, + statusCauses: { + type: 'array', + description: 'Documented causes behind the current status', + optional: true, + items: { + type: 'string', + }, + }, + tiEnabled: { + type: 'string', + description: 'Threat intelligence enablement status', + optional: true, + }, }, }, }, @@ -109,14 +201,9 @@ export const crowdstrikeQuerySensorsTool: ToolConfig< }, pagination: { type: 'json', - description: 'Pagination metadata (offset, limit, total, expiresAt)', + description: 'Pagination metadata (limit, offset, total)', optional: true, properties: { - expiresAt: { - type: 'number', - description: 'Pagination cursor expiry timestamp', - optional: true, - }, limit: { type: 'number', description: 'Page size used for the query', optional: true }, offset: { type: 'number', description: 'Offset returned by CrowdStrike', optional: true }, total: { type: 'number', description: 'Total records available', optional: true }, diff --git a/apps/sim/tools/crowdstrike/types.ts b/apps/sim/tools/crowdstrike/types.ts index 316d209a4a4..101cd6afdea 100644 --- a/apps/sim/tools/crowdstrike/types.ts +++ b/apps/sim/tools/crowdstrike/types.ts @@ -15,61 +15,77 @@ export interface CrowdStrikeQuerySensorsParams extends CrowdStrikeBaseParams { sort?: string } -export interface CrowdStrikeQueryCrowdScoreParams extends CrowdStrikeBaseParams { - filter?: string - limit?: number - offset?: number - sort?: string +export interface CrowdStrikeGetSensorDetailsParams extends CrowdStrikeBaseParams { + ids: string[] } -export interface CrowdStrikeQueryIncidentsParams extends CrowdStrikeBaseParams { - filter?: string - limit?: number - offset?: number - sort?: string +export interface CrowdStrikeAggregateDateRangeSpec { + from: string + to: string } -export interface CrowdStrikeQueryBehaviorsParams extends CrowdStrikeBaseParams { +export interface CrowdStrikeAggregateExtendedBoundsSpec { + max: string + min: string +} + +export interface CrowdStrikeAggregateRangeSpec { + from: number + to: number +} + +export interface CrowdStrikeAggregateQuery { + date_ranges?: CrowdStrikeAggregateDateRangeSpec[] + exclude?: string + extended_bounds?: CrowdStrikeAggregateExtendedBoundsSpec + field?: string filter?: string - limit?: number - offset?: number + from?: number + include?: string + interval?: string + max_doc_count?: number + min_doc_count?: number + missing?: string + name?: string + q?: string + ranges?: CrowdStrikeAggregateRangeSpec[] + size?: number sort?: string + sub_aggregates?: CrowdStrikeAggregateQuery[] + time_zone?: string + type?: string +} + +export interface CrowdStrikeGetSensorAggregatesParams extends CrowdStrikeBaseParams { + aggregateQuery: CrowdStrikeAggregateQuery } export interface CrowdStrikePagination { - expiresAt: number | null limit: number | null - offset: number | string | null + offset: number | null total: number | null } export interface CrowdStrikeSensor { - agentId: string | null + agentVersion: string | null + cid: string | null + deviceId: string | null + heartbeatTime: number | null hostname: string | null + idpPolicyId: string | null + idpPolicyName: string | null ipAddress: string | null - macAddress: string | null -} - -export interface CrowdStrikeCrowdScore { - entityId: string | null - entityType: string | null - lastUpdated: string | null - score: number | null -} - -export interface CrowdStrikeIncident { - createdTimestamp: string | null - incidentId: string | null - name: string | null - severity: string | null + kerberosConfig: string | null + ldapConfig: string | null + ldapsConfig: string | null + machineDomain: string | null + ntlmConfig: string | null + osVersion: string | null + rdpToDcConfig: string | null + smbToDcConfig: string | null status: string | null -} - -export interface CrowdStrikeBehavior { - behaviorId: string | null - createdTimestamp: string | null - incidentId: string | null - name: string | null + statusCauses: string[] + tiEnabled: string | null } export interface CrowdStrikeQuerySensorsResponse extends ToolResponse { @@ -80,32 +96,42 @@ export interface CrowdStrikeQuerySensorsResponse extends ToolResponse { } } -export interface CrowdStrikeQueryCrowdScoreResponse extends ToolResponse { +export interface CrowdStrikeGetSensorDetailsResponse extends ToolResponse { output: { count: number - crowdScores: CrowdStrikeCrowdScore[] pagination: CrowdStrikePagination | null + sensors: CrowdStrikeSensor[] } } -export interface CrowdStrikeQueryIncidentsResponse extends ToolResponse { - output: { - count: number - incidents: CrowdStrikeIncident[] - pagination: CrowdStrikePagination | null - } +export interface CrowdStrikeSensorAggregateBucket { + count: number | null + from: number | null + keyAsString: string | null + label: Record | null + stringFrom: string | null + stringTo: string | null + subAggregates: CrowdStrikeSensorAggregateResult[] + to: number | null + value: number | null + valueAsString: string | null } -export interface CrowdStrikeQueryBehaviorsResponse extends ToolResponse { +export interface CrowdStrikeSensorAggregateResult { + buckets: CrowdStrikeSensorAggregateBucket[] + docCountErrorUpperBound: number | null + name: string | null + sumOtherDocCount: number | null +} + +export interface CrowdStrikeGetSensorAggregatesResponse extends ToolResponse { output: { - behaviors: CrowdStrikeBehavior[] + aggregates: CrowdStrikeSensorAggregateResult[] count: number - pagination: CrowdStrikePagination | null } } export type CrowdStrikeResponse = | CrowdStrikeQuerySensorsResponse - | CrowdStrikeQueryCrowdScoreResponse - | CrowdStrikeQueryIncidentsResponse - | CrowdStrikeQueryBehaviorsResponse + | CrowdStrikeGetSensorDetailsResponse + | CrowdStrikeGetSensorAggregatesResponse diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index 23e2d9971da..c26110f07ac 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -353,9 +353,8 @@ import { confluenceUploadAttachmentTool, } from '@/tools/confluence' import { - crowdstrikeQueryBehaviorsTool, - crowdstrikeQueryCrowdScoreTool, - crowdstrikeQueryIncidentsTool, + crowdstrikeGetSensorAggregatesTool, + crowdstrikeGetSensorDetailsTool, crowdstrikeQuerySensorsTool, } from '@/tools/crowdstrike' import { @@ -3471,9 +3470,8 @@ export const tools: Record = { cloudwatch_list_metrics: cloudwatchListMetricsTool, cloudwatch_put_metric_data: cloudwatchPutMetricDataTool, cloudwatch_query_logs: cloudwatchQueryLogsTool, - crowdstrike_query_behaviors: crowdstrikeQueryBehaviorsTool, - crowdstrike_query_crowdscore: crowdstrikeQueryCrowdScoreTool, - crowdstrike_query_incidents: crowdstrikeQueryIncidentsTool, + crowdstrike_get_sensor_aggregates: crowdstrikeGetSensorAggregatesTool, + crowdstrike_get_sensor_details: crowdstrikeGetSensorDetailsTool, crowdstrike_query_sensors: crowdstrikeQuerySensorsTool, dynamodb_get: dynamodbGetTool, dynamodb_put: dynamodbPutTool, From 2b741b2a8c174e80ef60ba4981e68378f5271cc0 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Sun, 12 Apr 2026 16:29:29 -0700 Subject: [PATCH 4/5] addresss comments --- .../app/api/tools/crowdstrike/query/route.ts | 11 ++++++--- apps/sim/blocks/blocks/shopify.ts | 18 +++------------ apps/sim/blocks/blocks/trello.ts | 23 +++---------------- 3 files changed, 14 insertions(+), 38 deletions(-) diff --git a/apps/sim/app/api/tools/crowdstrike/query/route.ts b/apps/sim/app/api/tools/crowdstrike/query/route.ts index 29c67efb77d..63ad81e2899 100644 --- a/apps/sim/app/api/tools/crowdstrike/query/route.ts +++ b/apps/sim/app/api/tools/crowdstrike/query/route.ts @@ -3,7 +3,12 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateId } from '@/lib/core/utils/uuid' -import type { CrowdStrikeAggregateQuery, CrowdStrikeCloud } from '@/tools/crowdstrike/types' +import type { + CrowdStrikeAggregateQuery, + CrowdStrikeCloud, + CrowdStrikeSensorAggregateBucket, + CrowdStrikeSensorAggregateResult, +} from '@/tools/crowdstrike/types' const logger = createLogger('CrowdStrikeIdentityProtectionAPI') @@ -292,7 +297,7 @@ function normalizeSensorsOutput(data: unknown, paginationData?: unknown) { } } -function normalizeAggregationResult(resource: JsonRecord) { +function normalizeAggregationResult(resource: JsonRecord): CrowdStrikeSensorAggregateResult { return { buckets: getRecordArray(resource.buckets).map(normalizeAggregationBucket), docCountErrorUpperBound: getNumber(resource.doc_count_error_upper_bound), @@ -301,7 +306,7 @@ function normalizeAggregationResult(resource: JsonRecord) { } } -function normalizeAggregationBucket(resource: JsonRecord) { +function normalizeAggregationBucket(resource: JsonRecord): CrowdStrikeSensorAggregateBucket { return { count: getNumber(resource.count), from: getNumber(resource.from), diff --git a/apps/sim/blocks/blocks/shopify.ts b/apps/sim/blocks/blocks/shopify.ts index 857b7fa72ed..6489abde8ef 100644 --- a/apps/sim/blocks/blocks/shopify.ts +++ b/apps/sim/blocks/blocks/shopify.ts @@ -2,6 +2,7 @@ import { ShopifyIcon } from '@/components/icons' import { getScopesForService } from '@/lib/oauth/utils' import type { BlockConfig } from '@/blocks/types' import { AuthMode, IntegrationType } from '@/blocks/types' +import { parseOptionalNumberInput } from '@/blocks/utils' interface ShopifyResponse { success: boolean @@ -18,19 +19,6 @@ const LIST_OPERATIONS = [ 'shopify_list_collections', ] as const -function parseOptionalNumber(value: unknown): number | undefined { - if (typeof value === 'number' && Number.isFinite(value)) { - return value - } - - if (typeof value === 'string' && value.trim()) { - const parsed = Number(value) - return Number.isFinite(parsed) ? parsed : undefined - } - - return undefined -} - function parseBooleanInput(value: unknown): boolean | undefined { if (typeof value === 'boolean') { return value @@ -663,7 +651,7 @@ export const ShopifyBlock: BlockConfig = { return params.operation || 'shopify_list_products' }, params: (params) => { - const first = parseOptionalNumber(params.first) + const first = parseOptionalNumberInput(params.first, 'first') const baseParams: Record = { oauthCredential: params.oauthCredential, shopDomain: params.shopDomain?.trim(), @@ -915,7 +903,7 @@ export const ShopifyBlock: BlockConfig = { return { ...baseParams, collectionId: params.collectionId.trim(), - productsFirst: parseOptionalNumber(params.productsFirst), + productsFirst: parseOptionalNumberInput(params.productsFirst, 'productsFirst'), } default: diff --git a/apps/sim/blocks/blocks/trello.ts b/apps/sim/blocks/blocks/trello.ts index 80e119f4fa4..84dbc38f49e 100644 --- a/apps/sim/blocks/blocks/trello.ts +++ b/apps/sim/blocks/blocks/trello.ts @@ -2,6 +2,7 @@ import { TrelloIcon } from '@/components/icons' import { getScopesForService } from '@/lib/oauth/utils' import type { BlockConfig } from '@/blocks/types' import { AuthMode, IntegrationType } from '@/blocks/types' +import { parseOptionalNumberInput } from '@/blocks/utils' import type { TrelloResponse } from '@/tools/trello' function getTrimmedString(value: unknown): string | undefined { @@ -13,24 +14,6 @@ function getTrimmedString(value: unknown): string | undefined { return trimmed.length > 0 ? trimmed : undefined } -function parseOptionalNumber(value: unknown): number | undefined { - if (typeof value === 'number' && Number.isFinite(value)) { - return value - } - - if (typeof value !== 'string') { - return undefined - } - - const trimmed = value.trim() - if (trimmed.length === 0) { - return undefined - } - - const parsed = Number(trimmed) - return Number.isFinite(parsed) ? parsed : undefined -} - function parseOptionalBoolean(value: unknown): boolean | undefined { if (typeof value === 'boolean') { return value @@ -485,8 +468,8 @@ Return ONLY the date/timestamp string - no explanations, no extra text.`, boardId, cardId, filter: getTrimmedString(params.filter), - limit: parseOptionalNumber(params.limit), - page: parseOptionalNumber(params.page), + limit: parseOptionalNumberInput(params.limit, 'limit'), + page: parseOptionalNumberInput(params.page, 'page'), } } From 7aa14adf942d23b2c0538ce33c2b79e3298d12e9 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Sun, 12 Apr 2026 16:39:20 -0700 Subject: [PATCH 5/5] fix build --- apps/sim/blocks/blocks/crowdstrike.ts | 2 +- apps/sim/blocks/blocks/shopify.ts | 25 +++++------------- apps/sim/blocks/blocks/trello.ts | 37 +++------------------------ apps/sim/blocks/utils.test.ts | 34 ++++++++++++++++++++++++ apps/sim/blocks/utils.ts | 37 +++++++++++++++++++++++++++ 5 files changed, 82 insertions(+), 53 deletions(-) diff --git a/apps/sim/blocks/blocks/crowdstrike.ts b/apps/sim/blocks/blocks/crowdstrike.ts index 0eb4dcee1f0..b2433e924d5 100644 --- a/apps/sim/blocks/blocks/crowdstrike.ts +++ b/apps/sim/blocks/blocks/crowdstrike.ts @@ -13,7 +13,7 @@ export const CrowdStrikeBlock: BlockConfig = { docsLink: 'https://docs.sim.ai/tools/crowdstrike', category: 'tools', integrationType: IntegrationType.Security, - tags: ['monitoring', 'security'], + tags: ['identity', 'monitoring'], bgColor: '#E01F3D', icon: CrowdStrikeIcon, authMode: AuthMode.ApiKey, diff --git a/apps/sim/blocks/blocks/shopify.ts b/apps/sim/blocks/blocks/shopify.ts index 6489abde8ef..d1c32bf8af7 100644 --- a/apps/sim/blocks/blocks/shopify.ts +++ b/apps/sim/blocks/blocks/shopify.ts @@ -2,7 +2,7 @@ import { ShopifyIcon } from '@/components/icons' import { getScopesForService } from '@/lib/oauth/utils' import type { BlockConfig } from '@/blocks/types' import { AuthMode, IntegrationType } from '@/blocks/types' -import { parseOptionalNumberInput } from '@/blocks/utils' +import { parseOptionalBooleanInput, parseOptionalNumberInput } from '@/blocks/utils' interface ShopifyResponse { success: boolean @@ -19,19 +19,6 @@ const LIST_OPERATIONS = [ 'shopify_list_collections', ] as const -function parseBooleanInput(value: unknown): boolean | undefined { - if (typeof value === 'boolean') { - return value - } - - if (typeof value === 'string') { - if (value === 'true') return true - if (value === 'false') return false - } - - return undefined -} - export const ShopifyBlock: BlockConfig = { type: 'shopify', name: 'Shopify', @@ -763,10 +750,10 @@ export const ShopifyBlock: BlockConfig = { ...baseParams, orderId: params.orderId.trim(), reason: params.cancelReason, - restock: parseBooleanInput(params.restock) ?? false, - notifyCustomer: parseBooleanInput(params.cancelNotifyCustomer), + restock: parseOptionalBooleanInput(params.restock) ?? false, + notifyCustomer: parseOptionalBooleanInput(params.cancelNotifyCustomer), refundMethod: - parseBooleanInput(params.refundOriginalPayment) === true + parseOptionalBooleanInput(params.refundOriginalPayment) === true ? { originalPaymentMethodsRefund: true } : undefined, staffNote: params.staffNote?.trim(), @@ -871,7 +858,7 @@ export const ShopifyBlock: BlockConfig = { return { ...baseParams, first, - includeInactive: parseBooleanInput(params.includeInactive), + includeInactive: parseOptionalBooleanInput(params.includeInactive), } // Fulfillment Operations @@ -885,7 +872,7 @@ export const ShopifyBlock: BlockConfig = { trackingNumber: params.trackingNumber?.trim(), trackingCompany: params.trackingCompany?.trim(), trackingUrl: params.trackingUrl?.trim(), - notifyCustomer: parseBooleanInput(params.notifyCustomer), + notifyCustomer: parseOptionalBooleanInput(params.notifyCustomer), } // Collection Operations diff --git a/apps/sim/blocks/blocks/trello.ts b/apps/sim/blocks/blocks/trello.ts index 84dbc38f49e..d06251d89a5 100644 --- a/apps/sim/blocks/blocks/trello.ts +++ b/apps/sim/blocks/blocks/trello.ts @@ -2,7 +2,7 @@ import { TrelloIcon } from '@/components/icons' import { getScopesForService } from '@/lib/oauth/utils' import type { BlockConfig } from '@/blocks/types' import { AuthMode, IntegrationType } from '@/blocks/types' -import { parseOptionalNumberInput } from '@/blocks/utils' +import { parseOptionalBooleanInput, parseOptionalNumberInput } from '@/blocks/utils' import type { TrelloResponse } from '@/tools/trello' function getTrimmedString(value: unknown): string | undefined { @@ -14,35 +14,6 @@ function getTrimmedString(value: unknown): string | undefined { return trimmed.length > 0 ? trimmed : undefined } -function parseOptionalBoolean(value: unknown): boolean | undefined { - if (typeof value === 'boolean') { - return value - } - - if (typeof value === 'number') { - return value !== 0 - } - - if (typeof value !== 'string') { - return undefined - } - - const normalized = value.trim().toLowerCase() - if (normalized.length === 0) { - return undefined - } - - if (normalized === 'true' || normalized === '1') { - return true - } - - if (normalized === 'false' || normalized === '0') { - return false - } - - return undefined -} - function parseStringArray(value: unknown): string[] | undefined { if (Array.isArray(value)) { const items = value @@ -427,7 +398,7 @@ Return ONLY the date/timestamp string - no explanations, no extra text.`, desc: getTrimmedString(params.desc), pos: getTrimmedString(params.pos), due: getTrimmedString(params.due), - dueComplete: parseOptionalBoolean(params.dueComplete), + dueComplete: parseOptionalBooleanInput(params.dueComplete), labelIds: parseStringArray(params.labelIds), } } @@ -444,10 +415,10 @@ Return ONLY the date/timestamp string - no explanations, no extra text.`, cardId, name: getTrimmedString(params.name), desc: getTrimmedString(params.desc), - closed: parseOptionalBoolean(params.closed), + closed: parseOptionalBooleanInput(params.closed), idList: getTrimmedString(params.idList), due: getTrimmedString(params.due), - dueComplete: parseOptionalBoolean(params.dueComplete), + dueComplete: parseOptionalBooleanInput(params.dueComplete), } } diff --git a/apps/sim/blocks/utils.test.ts b/apps/sim/blocks/utils.test.ts index e9ed32609f9..b59e0ebd17f 100644 --- a/apps/sim/blocks/utils.test.ts +++ b/apps/sim/blocks/utils.test.ts @@ -68,6 +68,7 @@ vi.mock('@/lib/oauth/utils', () => ({ import { getApiKeyCondition, + parseOptionalBooleanInput, parseOptionalJsonInput, parseOptionalNumberInput, } from '@/blocks/utils' @@ -325,3 +326,36 @@ describe('parseOptionalNumberInput', () => { expect(() => parseOptionalNumberInput('abc', 'limit')).toThrow(/Invalid number for limit/i) }) }) + +describe('parseOptionalBooleanInput', () => { + it('returns undefined for empty values', () => { + expect(parseOptionalBooleanInput('')).toBeUndefined() + expect(parseOptionalBooleanInput(' ')).toBeUndefined() + expect(parseOptionalBooleanInput(undefined)).toBeUndefined() + }) + + it('passes through boolean values', () => { + expect(parseOptionalBooleanInput(true)).toBe(true) + expect(parseOptionalBooleanInput(false)).toBe(false) + }) + + it('supports numeric boolean values', () => { + expect(parseOptionalBooleanInput(1)).toBe(true) + expect(parseOptionalBooleanInput(0)).toBe(false) + expect(parseOptionalBooleanInput(5)).toBe(true) + }) + + it('supports trimmed and case-insensitive string values', () => { + expect(parseOptionalBooleanInput('true')).toBe(true) + expect(parseOptionalBooleanInput(' TRUE ')).toBe(true) + expect(parseOptionalBooleanInput('1')).toBe(true) + expect(parseOptionalBooleanInput('false')).toBe(false) + expect(parseOptionalBooleanInput(' False ')).toBe(false) + expect(parseOptionalBooleanInput('0')).toBe(false) + }) + + it('returns undefined for unrecognized string values', () => { + expect(parseOptionalBooleanInput('yes')).toBeUndefined() + expect(parseOptionalBooleanInput('no')).toBeUndefined() + }) +}) diff --git a/apps/sim/blocks/utils.ts b/apps/sim/blocks/utils.ts index c6cc746b56a..64ce0eb7a7f 100644 --- a/apps/sim/blocks/utils.ts +++ b/apps/sim/blocks/utils.ts @@ -441,6 +441,43 @@ export function parseOptionalNumberInput( return parsed } +/** + * Parses an optional boolean block input value. + * Returns `undefined` for empty or unrecognized values. + */ +export function parseOptionalBooleanInput(value: unknown): boolean | undefined { + if (value === undefined || value === null || value === '') { + return undefined + } + + if (typeof value === 'boolean') { + return value + } + + if (typeof value === 'number') { + return value !== 0 + } + + if (typeof value !== 'string') { + return undefined + } + + const normalized = value.trim().toLowerCase() + if (normalized.length === 0) { + return undefined + } + + if (normalized === 'true' || normalized === '1') { + return true + } + + if (normalized === 'false' || normalized === '0') { + return false + } + + return undefined +} + const DEFAULT_MULTIPLE_FILES_ERROR = 'File reference must be a single file, not an array. Use to select one file.'