From b094718d8dfee5b4e998ef88871796ab46b84eff Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Thu, 27 Aug 2026 17:37:57 -0700 Subject: [PATCH 01/13] feat(selectors): execute dynamic selectors server-side --- .agents/skills/add-block/SKILL.md | 18 +- .agents/skills/add-connector/SKILL.md | 37 +- .agents/skills/add-integration/SKILL.md | 16 +- .agents/skills/add-selector/SKILL.md | 118 ++++ .../skills/add-selector/agents/openai.yaml | 5 + .agents/skills/add-trigger/SKILL.md | 13 +- .../migrate-application-operation/SKILL.md | 16 + .agents/skills/validate-connector/SKILL.md | 27 +- .agents/skills/validate-integration/SKILL.md | 25 + .agents/skills/validate-selector/SKILL.md | 94 ++++ .../validate-selector/agents/openai.yaml | 5 + .agents/skills/validate-trigger/SKILL.md | 13 + .claude/skills/add-selector | 1 + .claude/skills/validate-selector | 1 + .../api/auth/oauth/microsoft/file/route.ts | 123 ----- .../api/auth/oauth/microsoft/files/route.ts | 219 -------- .../api/auth/oauth/wealthbox/item/route.ts | 150 ----- .../api/auth/oauth/wealthbox/items/route.ts | 162 ------ .../app/api/selectors/execute/route.test.ts | 36 ++ apps/sim/app/api/selectors/execute/route.ts | 52 ++ .../sim/app/api/tools/airtable/bases/route.ts | 143 ----- .../app/api/tools/airtable/tables/route.ts | 89 --- .../app/api/tools/asana/workspaces/route.ts | 150 ----- apps/sim/app/api/tools/attio/lists/route.ts | 92 ---- apps/sim/app/api/tools/attio/objects/route.ts | 92 ---- .../bitbucket/repositories/route.test.ts | 326 ----------- .../api/tools/bitbucket/repositories/route.ts | 173 ------ .../tools/bitbucket/workspaces/route.test.ts | 284 ---------- .../api/tools/bitbucket/workspaces/route.ts | 165 ------ .../app/api/tools/calcom/event-types/route.ts | 86 --- .../app/api/tools/calcom/schedules/route.ts | 84 --- .../app/api/tools/clickup/folders/route.ts | 93 ---- apps/sim/app/api/tools/clickup/lists/route.ts | 105 ---- .../sim/app/api/tools/clickup/spaces/route.ts | 93 ---- .../app/api/tools/clickup/workspaces/route.ts | 81 --- .../cloudwatch/describe-log-groups/route.ts | 81 +-- .../cloudwatch/describe-log-streams/route.ts | 33 +- .../app/api/tools/confluence/page/route.ts | 13 +- .../app/api/tools/confluence/pages/route.ts | 103 ---- .../tools/confluence/selector-spaces/route.ts | 215 -------- apps/sim/app/api/tools/drive/file/route.ts | 186 ------- apps/sim/app/api/tools/drive/files/route.ts | 232 -------- apps/sim/app/api/tools/gmail/label/route.ts | 103 ---- apps/sim/app/api/tools/gmail/labels/route.ts | 127 ----- .../tools/google_bigquery/datasets/route.ts | 140 ----- .../api/tools/google_bigquery/tables/route.ts | 137 ----- .../tools/google_calendar/calendars/route.ts | 150 ----- .../api/tools/google_sheets/sheets/route.ts | 141 ----- .../tools/google_tasks/task-lists/route.ts | 123 ----- .../harmonic/saved-searches/route.test.ts | 403 -------------- .../tools/harmonic/saved-searches/route.ts | 253 --------- apps/sim/app/api/tools/hubspot/lists/route.ts | 106 ---- .../sim/app/api/tools/hubspot/owners/route.ts | 103 ---- .../app/api/tools/hubspot/pipelines/route.ts | 90 --- .../app/api/tools/hubspot/properties/route.ts | 106 ---- .../sim/app/api/tools/imap/mailboxes/route.ts | 110 ---- apps/sim/app/api/tools/jira/issues/route.ts | 237 -------- apps/sim/app/api/tools/jira/projects/route.ts | 251 --------- .../tools/jsm/selector-requesttypes/route.ts | 177 ------ .../tools/jsm/selector-servicedesks/route.ts | 167 ------ .../app/api/tools/linear/projects/route.ts | 140 ----- apps/sim/app/api/tools/linear/teams/route.ts | 108 ---- .../tools/microsoft-teams/channels/route.ts | 158 ------ .../api/tools/microsoft-teams/chats/route.ts | 270 --------- .../api/tools/microsoft-teams/teams/route.ts | 153 ------ .../api/tools/microsoft_excel/drives/route.ts | 145 ----- .../api/tools/microsoft_excel/sheets/route.ts | 106 ---- .../tools/microsoft_planner/plans/route.ts | 94 ---- .../tools/microsoft_planner/tasks/route.ts | 118 ---- apps/sim/app/api/tools/monday/boards/route.ts | 142 ----- apps/sim/app/api/tools/monday/groups/route.ts | 106 ---- .../api/tools/netsuite/objects/route.test.ts | 462 ---------------- .../app/api/tools/netsuite/objects/route.ts | 355 ------------ .../app/api/tools/notion/databases/route.ts | 118 ---- apps/sim/app/api/tools/notion/pages/route.ts | 118 ---- .../sim/app/api/tools/onedrive/files/route.ts | 169 ------ .../app/api/tools/onedrive/folder/route.ts | 88 --- .../app/api/tools/onedrive/folders/route.ts | 142 ----- .../app/api/tools/outlook/calendars/route.ts | 163 ------ .../app/api/tools/outlook/folders/route.ts | 169 ------ .../api/tools/pipedrive/pipelines/route.ts | 155 ------ .../app/api/tools/sharepoint/lists/route.ts | 119 ---- .../app/api/tools/sharepoint/site/route.ts | 96 ---- .../app/api/tools/sharepoint/sites/route.ts | 106 ---- .../sim/app/api/tools/slack/channels/route.ts | 319 ----------- apps/sim/app/api/tools/slack/users/route.ts | 197 ------- .../app/api/tools/snowflake/objects/route.ts | 193 ------- apps/sim/app/api/tools/trello/boards/route.ts | 105 ---- .../sim/app/api/tools/wealthbox/item/route.ts | 128 ----- .../app/api/tools/wealthbox/items/route.ts | 203 ------- .../api/tools/webflow/collections/route.ts | 97 ---- apps/sim/app/api/tools/webflow/items/route.ts | 177 ------ apps/sim/app/api/tools/webflow/sites/route.ts | 109 ---- .../app/api/tools/zoho_desk/agents/route.ts | 147 ----- .../api/tools/zoho_desk/departments/route.ts | 136 ----- .../tools/zoho_desk/organizations/route.ts | 109 ---- .../tools/zoho_desk/selector-credential.ts | 95 ---- apps/sim/app/api/tools/zoom/meetings/route.ts | 126 ----- .../connector-config-fields.tsx | 2 +- .../connector-selector-field.tsx | 42 +- .../components/combobox/combobox.tsx | 3 +- .../components/dropdown/dropdown.tsx | 3 +- .../selector-combobox/selector-combobox.tsx | 12 +- .../selector-input/selector-input.tsx | 7 +- .../workflow-selector-input.tsx | 4 +- .../sub-block/hooks/use-depends-on-gate.ts | 3 + .../sub-block/hooks/use-fetched-options.ts | 352 +++--------- .../sub-block/hooks/use-selector-setup.ts | 105 ++-- .../blocks/blocks/credential-group.test.ts | 155 ------ apps/sim/blocks/blocks/embeddings.test.ts | 31 +- apps/sim/blocks/blocks/microsoft_excel.ts | 2 +- .../blocks/selector-backed-subblocks.test.ts | 63 ++- apps/sim/blocks/types.ts | 2 +- apps/sim/connectors/types.ts | 2 +- .../fork-sync/dependent-field-selector.tsx | 10 +- .../components/fork-sync/fork-sync-view.tsx | 5 +- apps/sim/hooks/queries/credential-groups.ts | 18 +- apps/sim/hooks/queries/credentials.ts | 6 + .../queries/dynamic-subblock-options.test.tsx | 73 ++- .../hooks/queries/dynamic-subblock-options.ts | 135 ++--- apps/sim/hooks/queries/environment.ts | 20 +- apps/sim/hooks/queries/selectors.test.tsx | 327 +++++++++++ apps/sim/hooks/queries/selectors.ts | 306 +++++++++++ .../utils/invalidate-workflow-lists.ts | 6 +- apps/sim/hooks/queries/utils/selector-keys.ts | 28 + .../queries/workflow-search-replace.test.ts | 10 +- .../hooks/queries/workflow-search-replace.ts | 204 +++++-- apps/sim/hooks/selectors/helpers.ts | 30 - .../selectors/providers/airtable/selectors.ts | 90 --- .../selectors/providers/asana/selectors.ts | 37 -- .../selectors/providers/attio/selectors.ts | 73 --- .../selectors/providers/bigquery/selectors.ts | 111 ---- .../providers/bitbucket/selectors.test.ts | 213 -------- .../providers/bitbucket/selectors.ts | 95 ---- .../selectors/providers/calcom/selectors.ts | 76 --- .../selectors/providers/clickup/selectors.ts | 133 ----- .../providers/cloudwatch/selectors.test.ts | 30 - .../providers/cloudwatch/selectors.ts | 96 ---- .../providers/confluence/selectors.ts | 146 ----- .../selectors/providers/google/selectors.ts | 192 ------- .../providers/harmonic/selectors.test.ts | 151 ----- .../selectors/providers/harmonic/selectors.ts | 69 --- .../selectors/providers/hubspot/selectors.ts | 148 ----- .../selectors/providers/imap/selectors.ts | 51 -- .../selectors/providers/jira/selectors.ts | 139 ----- .../selectors/providers/jsm/selectors.ts | 104 ---- .../providers/knowledge/selectors.ts | 61 --- .../selectors/providers/linear/selectors.ts | 59 -- .../providers/managed-agent/selectors.ts | 77 --- .../providers/microsoft/selectors.ts | 404 -------------- .../selectors/providers/monday/selectors.ts | 86 --- .../selectors/providers/netsuite/selectors.ts | 91 ---- .../selectors/providers/notion/selectors.ts | 73 --- .../providers/pipedrive/selectors.ts | 40 -- apps/sim/hooks/selectors/providers/shared.ts | 35 -- .../providers/sharepoint/selectors.ts | 89 --- .../selectors/providers/sim/selectors.ts | 147 ----- .../selectors/providers/slack/selectors.ts | 57 -- .../providers/snowflake/selectors.ts | 104 ---- .../selectors/providers/trello/selectors.ts | 39 -- .../providers/wealthbox/selectors.ts | 30 - .../selectors/providers/webflow/selectors.ts | 98 ---- .../providers/workspace/selectors.ts | 258 --------- .../providers/zoho-desk/selectors.test.ts | 188 ------- .../providers/zoho-desk/selectors.ts | 96 ---- .../selectors/providers/zoom/selectors.ts | 40 -- apps/sim/hooks/selectors/query-keys.ts | 7 - apps/sim/hooks/selectors/registry.test.ts | 239 -------- apps/sim/hooks/selectors/registry.ts | 127 ----- apps/sim/hooks/selectors/resolution.ts | 23 - .../selectors/trigger-types-live.test.ts | 36 -- apps/sim/hooks/selectors/types.ts | 221 -------- .../sim/hooks/selectors/use-selector-query.ts | 265 --------- apps/sim/hooks/use-selector-display-name.ts | 14 +- .../lib/api/contracts/selectors/airtable.ts | 33 -- apps/sim/lib/api/contracts/selectors/asana.ts | 19 - apps/sim/lib/api/contracts/selectors/attio.ts | 25 - .../lib/api/contracts/selectors/bigquery.ts | 57 -- .../lib/api/contracts/selectors/bitbucket.ts | 199 ------- .../sim/lib/api/contracts/selectors/calcom.ts | 30 - .../lib/api/contracts/selectors/clickup.ts | 73 --- .../lib/api/contracts/selectors/confluence.ts | 60 +- .../lib/api/contracts/selectors/execute.ts | 144 +++++ .../sim/lib/api/contracts/selectors/google.ts | 136 ----- .../lib/api/contracts/selectors/harmonic.ts | 49 -- .../lib/api/contracts/selectors/hubspot.ts | 110 ---- apps/sim/lib/api/contracts/selectors/index.ts | 239 +------- apps/sim/lib/api/contracts/selectors/jira.ts | 121 +---- apps/sim/lib/api/contracts/selectors/jsm.ts | 30 +- .../lib/api/contracts/selectors/knowledge.ts | 69 --- .../sim/lib/api/contracts/selectors/linear.ts | 28 - .../lib/api/contracts/selectors/microsoft.ts | 253 --------- .../sim/lib/api/contracts/selectors/monday.ts | 32 -- .../lib/api/contracts/selectors/netsuite.ts | 54 -- .../sim/lib/api/contracts/selectors/notion.ts | 24 - apps/sim/lib/api/contracts/selectors/oauth.ts | 23 - .../lib/api/contracts/selectors/pipedrive.ts | 17 - .../lib/api/contracts/selectors/sharepoint.ts | 60 -- apps/sim/lib/api/contracts/selectors/slack.ts | 49 -- .../lib/api/contracts/selectors/snowflake.ts | 59 -- .../sim/lib/api/contracts/selectors/trello.ts | 18 - .../lib/api/contracts/selectors/wealthbox.ts | 77 --- .../lib/api/contracts/selectors/webflow.ts | 49 -- .../lib/api/contracts/selectors/zoho-desk.ts | 42 -- apps/sim/lib/api/contracts/selectors/zoom.ts | 15 - apps/sim/lib/api/contracts/tools/imap.ts | 41 -- apps/sim/lib/api/contracts/tools/index.ts | 1 - .../core/security/input-validation.server.ts | 25 +- .../minters/zoho-desk.ts | 4 +- apps/sim/lib/environment/utils.ts | 7 +- apps/sim/lib/imap/connection.server.test.ts | 176 ++++++ apps/sim/lib/imap/connection.server.ts | 169 ++++++ apps/sim/lib/imap/selector-policy.test.ts | 58 ++ apps/sim/lib/managed-agents/session-client.ts | 1 + apps/sim/lib/oauth/credential-service.test.ts | 198 +++++++ apps/sim/lib/oauth/credential-service.ts | 147 +++-- .../application/execute-selector.test.ts | 172 ++++++ .../selectors/application/execute-selector.ts | 147 +++++ .../lib/selectors/application/operations.ts | 10 + .../selectors/application/resolve-scope.ts | 41 ++ .../lib/selectors/client/execute-selector.ts | 73 +++ apps/sim/lib/selectors/client/local.ts | 25 + apps/sim/lib/selectors/context.ts | 186 +++++++ apps/sim/lib/selectors/manifest.test.ts | 46 ++ apps/sim/lib/selectors/manifest.ts | 372 +++++++++++++ .../lib/selectors/server/credentials.test.ts | 84 +++ apps/sim/lib/selectors/server/credentials.ts | 136 +++++ apps/sim/lib/selectors/server/errors.ts | 20 + apps/sim/lib/selectors/server/internal.ts | 310 +++++++++++ .../lib/selectors/server/protected-values.ts | 16 + .../selectors/server/providers/airtable.ts | 121 +++++ .../lib/selectors/server/providers/asana.ts | 85 +++ .../selectors/server/providers/atlassian.ts | 64 +++ .../lib/selectors/server/providers/attio.ts | 90 +++ .../selectors/server/providers/bigquery.ts | 158 ++++++ .../server/providers/bitbucket.test.ts | 119 ++++ .../selectors/server/providers/bitbucket.ts | 266 +++++++++ .../lib/selectors/server/providers/calcom.ts | 107 ++++ .../lib/selectors/server/providers/clickup.ts | 135 +++++ .../selectors/server/providers/cloudwatch.ts | 70 +++ .../selectors/server/providers/confluence.ts | 208 +++++++ .../server/providers/credential-bundle.ts | 52 ++ .../server/providers/flat-results.ts | 18 + .../lib/selectors/server/providers/google.ts | 376 +++++++++++++ .../selectors/server/providers/harmonic.ts | 126 +++++ .../lib/selectors/server/providers/hubspot.ts | 194 +++++++ .../lib/selectors/server/providers/imap.ts | 28 + .../lib/selectors/server/providers/jira.ts | 200 +++++++ .../sim/lib/selectors/server/providers/jsm.ts | 149 +++++ .../lib/selectors/server/providers/linear.ts | 117 ++++ .../server/providers/managed-agent.ts | 119 ++++ .../selectors/server/providers/microsoft.ts | 443 +++++++++++++++ .../lib/selectors/server/providers/monday.ts | 104 ++++ .../selectors/server/providers/netsuite.ts | 242 +++++++++ .../lib/selectors/server/providers/notion.ts | 85 +++ .../selectors/server/providers/pipedrive.ts | 59 ++ .../server/providers/provider-http.ts | 58 ++ .../selectors/server/providers/sharepoint.ts | 88 +++ .../lib/selectors/server/providers/slack.ts | 224 ++++++++ .../selectors/server/providers/snowflake.ts | 178 ++++++ .../lib/selectors/server/providers/trello.ts | 60 ++ .../selectors/server/providers/wealthbox.ts | 76 +++ .../lib/selectors/server/providers/webflow.ts | 131 +++++ .../selectors/server/providers/zoho-desk.ts | 185 +++++++ .../lib/selectors/server/providers/zoom.ts | 56 ++ .../lib/selectors/server/references.test.ts | 137 +++++ apps/sim/lib/selectors/server/references.ts | 100 ++++ apps/sim/lib/selectors/server/registry.ts | 71 +++ .../sim/lib/selectors/server/sanitize.test.ts | 69 +++ apps/sim/lib/selectors/server/sanitize.ts | 91 ++++ apps/sim/lib/selectors/server/types.ts | 98 ++++ apps/sim/lib/selectors/types.ts | 126 +++++ apps/sim/lib/webhooks/deploy.ts | 12 +- apps/sim/lib/webhooks/polling/imap.test.ts | 83 +++ apps/sim/lib/webhooks/polling/imap.ts | 87 +-- apps/sim/lib/webhooks/providers/imap.test.ts | 134 +++++ apps/sim/lib/webhooks/providers/imap.ts | 41 +- apps/sim/lib/webhooks/providers/types.ts | 4 + apps/sim/lib/webhooks/registration-service.ts | 4 + .../comparison/format-description.test.ts | 13 +- .../workflows/comparison/resolve-values.ts | 117 ++-- .../lib/workflows/search-replace/indexer.ts | 49 +- .../search-replace/resources/references.ts | 4 +- .../search-replace/resources/registry.ts | 8 +- .../sim/lib/workflows/search-replace/types.ts | 14 +- .../lib/workflows/subblocks/context.test.ts | 110 +++- apps/sim/lib/workflows/subblocks/context.ts | 178 +++--- apps/sim/tools/cloudwatch/listing.ts | 122 +++++ apps/sim/tools/confluence/client.ts | 17 + scripts/check-fork-dependent-coverage.ts | 2 +- ...check-tool-registry-boundary.baseline.json | 514 +++++++++--------- 291 files changed, 10636 insertions(+), 20858 deletions(-) create mode 100644 .agents/skills/add-selector/SKILL.md create mode 100644 .agents/skills/add-selector/agents/openai.yaml create mode 100644 .agents/skills/validate-selector/SKILL.md create mode 100644 .agents/skills/validate-selector/agents/openai.yaml create mode 120000 .claude/skills/add-selector create mode 120000 .claude/skills/validate-selector delete mode 100644 apps/sim/app/api/auth/oauth/microsoft/file/route.ts delete mode 100644 apps/sim/app/api/auth/oauth/microsoft/files/route.ts delete mode 100644 apps/sim/app/api/auth/oauth/wealthbox/item/route.ts delete mode 100644 apps/sim/app/api/auth/oauth/wealthbox/items/route.ts create mode 100644 apps/sim/app/api/selectors/execute/route.test.ts create mode 100644 apps/sim/app/api/selectors/execute/route.ts delete mode 100644 apps/sim/app/api/tools/airtable/bases/route.ts delete mode 100644 apps/sim/app/api/tools/airtable/tables/route.ts delete mode 100644 apps/sim/app/api/tools/asana/workspaces/route.ts delete mode 100644 apps/sim/app/api/tools/attio/lists/route.ts delete mode 100644 apps/sim/app/api/tools/attio/objects/route.ts delete mode 100644 apps/sim/app/api/tools/bitbucket/repositories/route.test.ts delete mode 100644 apps/sim/app/api/tools/bitbucket/repositories/route.ts delete mode 100644 apps/sim/app/api/tools/bitbucket/workspaces/route.test.ts delete mode 100644 apps/sim/app/api/tools/bitbucket/workspaces/route.ts delete mode 100644 apps/sim/app/api/tools/calcom/event-types/route.ts delete mode 100644 apps/sim/app/api/tools/calcom/schedules/route.ts delete mode 100644 apps/sim/app/api/tools/clickup/folders/route.ts delete mode 100644 apps/sim/app/api/tools/clickup/lists/route.ts delete mode 100644 apps/sim/app/api/tools/clickup/spaces/route.ts delete mode 100644 apps/sim/app/api/tools/clickup/workspaces/route.ts delete mode 100644 apps/sim/app/api/tools/confluence/pages/route.ts delete mode 100644 apps/sim/app/api/tools/confluence/selector-spaces/route.ts delete mode 100644 apps/sim/app/api/tools/drive/file/route.ts delete mode 100644 apps/sim/app/api/tools/drive/files/route.ts delete mode 100644 apps/sim/app/api/tools/gmail/label/route.ts delete mode 100644 apps/sim/app/api/tools/gmail/labels/route.ts delete mode 100644 apps/sim/app/api/tools/google_bigquery/datasets/route.ts delete mode 100644 apps/sim/app/api/tools/google_bigquery/tables/route.ts delete mode 100644 apps/sim/app/api/tools/google_calendar/calendars/route.ts delete mode 100644 apps/sim/app/api/tools/google_sheets/sheets/route.ts delete mode 100644 apps/sim/app/api/tools/google_tasks/task-lists/route.ts delete mode 100644 apps/sim/app/api/tools/harmonic/saved-searches/route.test.ts delete mode 100644 apps/sim/app/api/tools/harmonic/saved-searches/route.ts delete mode 100644 apps/sim/app/api/tools/hubspot/lists/route.ts delete mode 100644 apps/sim/app/api/tools/hubspot/owners/route.ts delete mode 100644 apps/sim/app/api/tools/hubspot/pipelines/route.ts delete mode 100644 apps/sim/app/api/tools/hubspot/properties/route.ts delete mode 100644 apps/sim/app/api/tools/imap/mailboxes/route.ts delete mode 100644 apps/sim/app/api/tools/jira/issues/route.ts delete mode 100644 apps/sim/app/api/tools/jira/projects/route.ts delete mode 100644 apps/sim/app/api/tools/jsm/selector-requesttypes/route.ts delete mode 100644 apps/sim/app/api/tools/jsm/selector-servicedesks/route.ts delete mode 100644 apps/sim/app/api/tools/linear/projects/route.ts delete mode 100644 apps/sim/app/api/tools/linear/teams/route.ts delete mode 100644 apps/sim/app/api/tools/microsoft-teams/channels/route.ts delete mode 100644 apps/sim/app/api/tools/microsoft-teams/chats/route.ts delete mode 100644 apps/sim/app/api/tools/microsoft-teams/teams/route.ts delete mode 100644 apps/sim/app/api/tools/microsoft_excel/drives/route.ts delete mode 100644 apps/sim/app/api/tools/microsoft_excel/sheets/route.ts delete mode 100644 apps/sim/app/api/tools/microsoft_planner/plans/route.ts delete mode 100644 apps/sim/app/api/tools/microsoft_planner/tasks/route.ts delete mode 100644 apps/sim/app/api/tools/monday/boards/route.ts delete mode 100644 apps/sim/app/api/tools/monday/groups/route.ts delete mode 100644 apps/sim/app/api/tools/netsuite/objects/route.test.ts delete mode 100644 apps/sim/app/api/tools/netsuite/objects/route.ts delete mode 100644 apps/sim/app/api/tools/notion/databases/route.ts delete mode 100644 apps/sim/app/api/tools/notion/pages/route.ts delete mode 100644 apps/sim/app/api/tools/onedrive/files/route.ts delete mode 100644 apps/sim/app/api/tools/onedrive/folder/route.ts delete mode 100644 apps/sim/app/api/tools/onedrive/folders/route.ts delete mode 100644 apps/sim/app/api/tools/outlook/calendars/route.ts delete mode 100644 apps/sim/app/api/tools/outlook/folders/route.ts delete mode 100644 apps/sim/app/api/tools/pipedrive/pipelines/route.ts delete mode 100644 apps/sim/app/api/tools/sharepoint/lists/route.ts delete mode 100644 apps/sim/app/api/tools/sharepoint/site/route.ts delete mode 100644 apps/sim/app/api/tools/sharepoint/sites/route.ts delete mode 100644 apps/sim/app/api/tools/slack/channels/route.ts delete mode 100644 apps/sim/app/api/tools/slack/users/route.ts delete mode 100644 apps/sim/app/api/tools/snowflake/objects/route.ts delete mode 100644 apps/sim/app/api/tools/trello/boards/route.ts delete mode 100644 apps/sim/app/api/tools/wealthbox/item/route.ts delete mode 100644 apps/sim/app/api/tools/wealthbox/items/route.ts delete mode 100644 apps/sim/app/api/tools/webflow/collections/route.ts delete mode 100644 apps/sim/app/api/tools/webflow/items/route.ts delete mode 100644 apps/sim/app/api/tools/webflow/sites/route.ts delete mode 100644 apps/sim/app/api/tools/zoho_desk/agents/route.ts delete mode 100644 apps/sim/app/api/tools/zoho_desk/departments/route.ts delete mode 100644 apps/sim/app/api/tools/zoho_desk/organizations/route.ts delete mode 100644 apps/sim/app/api/tools/zoho_desk/selector-credential.ts delete mode 100644 apps/sim/app/api/tools/zoom/meetings/route.ts delete mode 100644 apps/sim/blocks/blocks/credential-group.test.ts create mode 100644 apps/sim/hooks/queries/selectors.test.tsx create mode 100644 apps/sim/hooks/queries/selectors.ts create mode 100644 apps/sim/hooks/queries/utils/selector-keys.ts delete mode 100644 apps/sim/hooks/selectors/helpers.ts delete mode 100644 apps/sim/hooks/selectors/providers/airtable/selectors.ts delete mode 100644 apps/sim/hooks/selectors/providers/asana/selectors.ts delete mode 100644 apps/sim/hooks/selectors/providers/attio/selectors.ts delete mode 100644 apps/sim/hooks/selectors/providers/bigquery/selectors.ts delete mode 100644 apps/sim/hooks/selectors/providers/bitbucket/selectors.test.ts delete mode 100644 apps/sim/hooks/selectors/providers/bitbucket/selectors.ts delete mode 100644 apps/sim/hooks/selectors/providers/calcom/selectors.ts delete mode 100644 apps/sim/hooks/selectors/providers/clickup/selectors.ts delete mode 100644 apps/sim/hooks/selectors/providers/cloudwatch/selectors.test.ts delete mode 100644 apps/sim/hooks/selectors/providers/cloudwatch/selectors.ts delete mode 100644 apps/sim/hooks/selectors/providers/confluence/selectors.ts delete mode 100644 apps/sim/hooks/selectors/providers/google/selectors.ts delete mode 100644 apps/sim/hooks/selectors/providers/harmonic/selectors.test.ts delete mode 100644 apps/sim/hooks/selectors/providers/harmonic/selectors.ts delete mode 100644 apps/sim/hooks/selectors/providers/hubspot/selectors.ts delete mode 100644 apps/sim/hooks/selectors/providers/imap/selectors.ts delete mode 100644 apps/sim/hooks/selectors/providers/jira/selectors.ts delete mode 100644 apps/sim/hooks/selectors/providers/jsm/selectors.ts delete mode 100644 apps/sim/hooks/selectors/providers/knowledge/selectors.ts delete mode 100644 apps/sim/hooks/selectors/providers/linear/selectors.ts delete mode 100644 apps/sim/hooks/selectors/providers/managed-agent/selectors.ts delete mode 100644 apps/sim/hooks/selectors/providers/microsoft/selectors.ts delete mode 100644 apps/sim/hooks/selectors/providers/monday/selectors.ts delete mode 100644 apps/sim/hooks/selectors/providers/netsuite/selectors.ts delete mode 100644 apps/sim/hooks/selectors/providers/notion/selectors.ts delete mode 100644 apps/sim/hooks/selectors/providers/pipedrive/selectors.ts delete mode 100644 apps/sim/hooks/selectors/providers/shared.ts delete mode 100644 apps/sim/hooks/selectors/providers/sharepoint/selectors.ts delete mode 100644 apps/sim/hooks/selectors/providers/sim/selectors.ts delete mode 100644 apps/sim/hooks/selectors/providers/slack/selectors.ts delete mode 100644 apps/sim/hooks/selectors/providers/snowflake/selectors.ts delete mode 100644 apps/sim/hooks/selectors/providers/trello/selectors.ts delete mode 100644 apps/sim/hooks/selectors/providers/wealthbox/selectors.ts delete mode 100644 apps/sim/hooks/selectors/providers/webflow/selectors.ts delete mode 100644 apps/sim/hooks/selectors/providers/workspace/selectors.ts delete mode 100644 apps/sim/hooks/selectors/providers/zoho-desk/selectors.test.ts delete mode 100644 apps/sim/hooks/selectors/providers/zoho-desk/selectors.ts delete mode 100644 apps/sim/hooks/selectors/providers/zoom/selectors.ts delete mode 100644 apps/sim/hooks/selectors/query-keys.ts delete mode 100644 apps/sim/hooks/selectors/registry.test.ts delete mode 100644 apps/sim/hooks/selectors/registry.ts delete mode 100644 apps/sim/hooks/selectors/resolution.ts delete mode 100644 apps/sim/hooks/selectors/trigger-types-live.test.ts delete mode 100644 apps/sim/hooks/selectors/types.ts delete mode 100644 apps/sim/hooks/selectors/use-selector-query.ts delete mode 100644 apps/sim/lib/api/contracts/selectors/airtable.ts delete mode 100644 apps/sim/lib/api/contracts/selectors/asana.ts delete mode 100644 apps/sim/lib/api/contracts/selectors/attio.ts delete mode 100644 apps/sim/lib/api/contracts/selectors/bigquery.ts delete mode 100644 apps/sim/lib/api/contracts/selectors/bitbucket.ts delete mode 100644 apps/sim/lib/api/contracts/selectors/calcom.ts delete mode 100644 apps/sim/lib/api/contracts/selectors/clickup.ts create mode 100644 apps/sim/lib/api/contracts/selectors/execute.ts delete mode 100644 apps/sim/lib/api/contracts/selectors/google.ts delete mode 100644 apps/sim/lib/api/contracts/selectors/hubspot.ts delete mode 100644 apps/sim/lib/api/contracts/selectors/knowledge.ts delete mode 100644 apps/sim/lib/api/contracts/selectors/linear.ts delete mode 100644 apps/sim/lib/api/contracts/selectors/microsoft.ts delete mode 100644 apps/sim/lib/api/contracts/selectors/monday.ts delete mode 100644 apps/sim/lib/api/contracts/selectors/netsuite.ts delete mode 100644 apps/sim/lib/api/contracts/selectors/notion.ts delete mode 100644 apps/sim/lib/api/contracts/selectors/oauth.ts delete mode 100644 apps/sim/lib/api/contracts/selectors/pipedrive.ts delete mode 100644 apps/sim/lib/api/contracts/selectors/sharepoint.ts delete mode 100644 apps/sim/lib/api/contracts/selectors/slack.ts delete mode 100644 apps/sim/lib/api/contracts/selectors/snowflake.ts delete mode 100644 apps/sim/lib/api/contracts/selectors/trello.ts delete mode 100644 apps/sim/lib/api/contracts/selectors/wealthbox.ts delete mode 100644 apps/sim/lib/api/contracts/selectors/webflow.ts delete mode 100644 apps/sim/lib/api/contracts/selectors/zoho-desk.ts delete mode 100644 apps/sim/lib/api/contracts/selectors/zoom.ts delete mode 100644 apps/sim/lib/api/contracts/tools/imap.ts create mode 100644 apps/sim/lib/imap/connection.server.test.ts create mode 100644 apps/sim/lib/imap/connection.server.ts create mode 100644 apps/sim/lib/imap/selector-policy.test.ts create mode 100644 apps/sim/lib/oauth/credential-service.test.ts create mode 100644 apps/sim/lib/selectors/application/execute-selector.test.ts create mode 100644 apps/sim/lib/selectors/application/execute-selector.ts create mode 100644 apps/sim/lib/selectors/application/operations.ts create mode 100644 apps/sim/lib/selectors/application/resolve-scope.ts create mode 100644 apps/sim/lib/selectors/client/execute-selector.ts create mode 100644 apps/sim/lib/selectors/client/local.ts create mode 100644 apps/sim/lib/selectors/context.ts create mode 100644 apps/sim/lib/selectors/manifest.test.ts create mode 100644 apps/sim/lib/selectors/manifest.ts create mode 100644 apps/sim/lib/selectors/server/credentials.test.ts create mode 100644 apps/sim/lib/selectors/server/credentials.ts create mode 100644 apps/sim/lib/selectors/server/errors.ts create mode 100644 apps/sim/lib/selectors/server/internal.ts create mode 100644 apps/sim/lib/selectors/server/protected-values.ts create mode 100644 apps/sim/lib/selectors/server/providers/airtable.ts create mode 100644 apps/sim/lib/selectors/server/providers/asana.ts create mode 100644 apps/sim/lib/selectors/server/providers/atlassian.ts create mode 100644 apps/sim/lib/selectors/server/providers/attio.ts create mode 100644 apps/sim/lib/selectors/server/providers/bigquery.ts create mode 100644 apps/sim/lib/selectors/server/providers/bitbucket.test.ts create mode 100644 apps/sim/lib/selectors/server/providers/bitbucket.ts create mode 100644 apps/sim/lib/selectors/server/providers/calcom.ts create mode 100644 apps/sim/lib/selectors/server/providers/clickup.ts create mode 100644 apps/sim/lib/selectors/server/providers/cloudwatch.ts create mode 100644 apps/sim/lib/selectors/server/providers/confluence.ts create mode 100644 apps/sim/lib/selectors/server/providers/credential-bundle.ts create mode 100644 apps/sim/lib/selectors/server/providers/flat-results.ts create mode 100644 apps/sim/lib/selectors/server/providers/google.ts create mode 100644 apps/sim/lib/selectors/server/providers/harmonic.ts create mode 100644 apps/sim/lib/selectors/server/providers/hubspot.ts create mode 100644 apps/sim/lib/selectors/server/providers/imap.ts create mode 100644 apps/sim/lib/selectors/server/providers/jira.ts create mode 100644 apps/sim/lib/selectors/server/providers/jsm.ts create mode 100644 apps/sim/lib/selectors/server/providers/linear.ts create mode 100644 apps/sim/lib/selectors/server/providers/managed-agent.ts create mode 100644 apps/sim/lib/selectors/server/providers/microsoft.ts create mode 100644 apps/sim/lib/selectors/server/providers/monday.ts create mode 100644 apps/sim/lib/selectors/server/providers/netsuite.ts create mode 100644 apps/sim/lib/selectors/server/providers/notion.ts create mode 100644 apps/sim/lib/selectors/server/providers/pipedrive.ts create mode 100644 apps/sim/lib/selectors/server/providers/provider-http.ts create mode 100644 apps/sim/lib/selectors/server/providers/sharepoint.ts create mode 100644 apps/sim/lib/selectors/server/providers/slack.ts create mode 100644 apps/sim/lib/selectors/server/providers/snowflake.ts create mode 100644 apps/sim/lib/selectors/server/providers/trello.ts create mode 100644 apps/sim/lib/selectors/server/providers/wealthbox.ts create mode 100644 apps/sim/lib/selectors/server/providers/webflow.ts create mode 100644 apps/sim/lib/selectors/server/providers/zoho-desk.ts create mode 100644 apps/sim/lib/selectors/server/providers/zoom.ts create mode 100644 apps/sim/lib/selectors/server/references.test.ts create mode 100644 apps/sim/lib/selectors/server/references.ts create mode 100644 apps/sim/lib/selectors/server/registry.ts create mode 100644 apps/sim/lib/selectors/server/sanitize.test.ts create mode 100644 apps/sim/lib/selectors/server/sanitize.ts create mode 100644 apps/sim/lib/selectors/server/types.ts create mode 100644 apps/sim/lib/selectors/types.ts create mode 100644 apps/sim/lib/webhooks/polling/imap.test.ts create mode 100644 apps/sim/lib/webhooks/providers/imap.test.ts create mode 100644 apps/sim/tools/cloudwatch/listing.ts create mode 100644 apps/sim/tools/confluence/client.ts diff --git a/.agents/skills/add-block/SKILL.md b/.agents/skills/add-block/SKILL.md index fb60d3c0649..a0192f99f89 100644 --- a/.agents/skills/add-block/SKILL.md +++ b/.agents/skills/add-block/SKILL.md @@ -203,6 +203,7 @@ silently available. id: 'channel', title: 'Channel', type: 'channel-selector', + selectorKey: '{service}.channels', serviceId: '{service}', placeholder: 'Select channel', dependsOn: ['credential'], @@ -213,6 +214,7 @@ silently available. id: 'project', title: 'Project', type: 'project-selector', + selectorKey: '{service}.projects', serviceId: '{service}', dependsOn: ['credential'], } @@ -222,6 +224,7 @@ silently available. id: 'file', title: 'File', type: 'file-selector', + selectorKey: '{service}.files', serviceId: '{service}', mimeType: 'application/pdf', dependsOn: ['credential'], @@ -232,6 +235,7 @@ silently available. id: 'user', title: 'User', type: 'user-selector', + selectorKey: '{service}.users', serviceId: '{service}', dependsOn: ['credential'], } @@ -1057,7 +1061,10 @@ After creating the block, you MUST validate it against every tool it references: A sub-block gets its choices from exactly one of two places. There is no third. -**`selectorKey` — every remote list.** Register the list in `hooks/selectors/providers//selectors.ts`, add its key to `SelectorKey`, and point the sub-block at it. A selector is parameterized by an explicit `SelectorContext`, so the same definition serves the canvas, the workspace-fork sync modal, and anything added later. +**`selectorKey` — every remote list.** Use the `add-selector` skill to add browser-safe metadata in +`apps/sim/lib/selectors/manifest.ts` and a server attachment under +`apps/sim/lib/selectors/server/providers/`. Point the sub-block at that key. All remote selectors +execute through `selectors.execute`; never add a client provider module or selector-only fetch route. ```ts { id: 'triggerCredentials', type: 'oauth-input', canonicalParamId: 'oauthCredential', mode: 'trigger' }, @@ -1066,7 +1073,11 @@ A sub-block gets its choices from exactly one of two places. There is no third. { id: 'manualLabelIds', type: 'short-input', mode: 'trigger-advanced' }, ``` -`canonicalParamId: 'oauthCredential'` on the credential sub-block is the line people forget. `buildSelectorContextFromBlock` keys the context on a sub-block's CANONICAL id, so without it `context.oauthCredential` is never set and the picker looks unfixable without reading the store. (A credential field is also recognised by its `oauth-input` TYPE as a fallback, so a block whose shipped param is already named something else does not have to rename it.) +`canonicalParamId: 'oauthCredential'` on the credential sub-block is the line people forget. The +shared context builder projects only active `dependsOn` values and keys canonical pairs by their +canonical id. Exact environment references such as `{{GMAIL_CREDENTIAL_ID}}` stay unresolved in the +browser and are resolved only by the authorized server executor. A credential field is also +recognized by its `oauth-input` type as a compatibility fallback. **`options` — everything else.** A static array, or a pure function of the block's own values for a list that narrows to a sibling's selection. No I/O. @@ -1081,5 +1092,6 @@ options: (params) => { Two rules the checks enforce: -- **A secret never enters a selector's `getQueryKey`.** A query key identifies a resource; a credential authorizes access to it. A credential *id* is fine; a typed password is not (see `imap.mailboxes`). +- **Selector query keys contain no context values.** This includes credential IDs, raw secrets, + unresolved references, and hashes of those values; the shared facade uses an opaque local revision. - **A sub-block that `dependsOn` a credential / knowledge-base / table selector must be reconfigurable at fork-sync time** — a `selectorKey`, a canonical pair whose basic member is a selector, or a `short-input`/`long-input`. `bun run check:fork-dependent-coverage` fails otherwise, because a fork sync clears those fields on every push and an unofferable one can never be set anywhere that sticks. diff --git a/.agents/skills/add-connector/SKILL.md b/.agents/skills/add-connector/SKILL.md index ce2e29066ca..c14c5a11ade 100644 --- a/.agents/skills/add-connector/SKILL.md +++ b/.agents/skills/add-connector/SKILL.md @@ -197,7 +197,14 @@ Three field types are supported: `short-input`, `dropdown`, and `selector`. ## Dynamic Selectors (Canonical Pairs) -Use `type: 'selector'` to fetch options dynamically from the existing selector registry (`hooks/selectors/registry.ts`). Selectors are always paired with a manual fallback input using the **canonical pair** pattern — a `selector` field (basic mode) and a `short-input` field (advanced mode) linked by `canonicalParamId`. +Use `type: 'selector'` for a key declared in the browser-safe selector manifest at +`apps/sim/lib/selectors/manifest.ts`. Remote selectors execute through the authorized +`selectors.execute` server operation and a server attachment; connectors never call providers or +resolve credentials in the browser. Apply the `add-selector` skill when the key does not exist. + +Selectors are paired with a manual fallback input using the **canonical pair** pattern — a +`selector` field (basic mode) and a `short-input` field (advanced mode) linked by +`canonicalParamId`. The user sees a toggle button (ArrowLeftRight) to switch between the selector dropdown and manual text input. On submit, the modal resolves each canonical pair to the active mode's value, keyed by `canonicalParamId`. @@ -217,7 +224,7 @@ configFields: [ id: 'baseSelector', title: 'Base', type: 'selector', - selectorKey: 'airtable.bases', // Must exist in hooks/selectors/registry.ts + selectorKey: 'airtable.bases', // Must exist in lib/selectors/manifest.ts canonicalParamId: 'baseId', mode: 'basic', placeholder: 'Select a base', @@ -260,7 +267,9 @@ configFields: [ ### Selector with domain dependency (Jira/Confluence pattern) -When a selector depends on a plain `short-input` field (no canonical pair), `dependsOn` references that field's `id` directly. The `domain` field's value maps to `SelectorContext.domain` automatically via `SELECTOR_CONTEXT_FIELDS`. +When a selector depends on a plain `short-input` field (no canonical pair), `dependsOn` references +that field's `id` directly. Exact references such as `{{JIRA_DOMAIN}}` remain unresolved in the +browser and are resolved only after workspace authorization on the server. ```typescript configFields: [ @@ -296,16 +305,16 @@ configFields: [ ### How `dependsOn` maps to `SelectorContext` -The connector selector field builds a `SelectorContext` from dependency values. For the mapping to work, each dependency's `canonicalParamId` (or field `id` for non-canonical fields) must exist in `SELECTOR_CONTEXT_FIELDS` (`lib/workflows/subblocks/context.ts`): - -``` -oauthCredential, domain, teamId, projectId, knowledgeBaseId, planId, -siteId, collectionId, spreadsheetId, fileId, baseId, datasetId, serviceDeskId -``` +The shared connector context builder projects only active dependencies. A canonical dependency uses +its active basic or advanced value under `canonicalParamId`; a non-canonical dependency uses its +field `id`. The resulting key must be a `SelectorContextKey` in +`apps/sim/lib/selectors/types.ts` and must be explicitly allowed by that selector's manifest entry. +The browser sends the connector's workspace scope, not the complete connector configuration. ### Available selector keys -Check `hooks/selectors/types.ts` for the full `SelectorKey` union. Common ones for connectors: +Check `apps/sim/lib/selectors/manifest.ts` for the exhaustive selector keys. Common ones for +connectors: | SelectorKey | Context Deps | Returns | |-------------|-------------|---------| @@ -607,9 +616,13 @@ export const CONNECTOR_META_REGISTRY: ConnectorMetaRegistry = { - [ ] **Selector fields configured correctly (if applicable):** - Every `type: 'selector'` field has a canonical pair (`short-input` or `dropdown` with same `canonicalParamId` and `mode: 'advanced'`) - `required` is identical on both fields in each canonical pair - - `selectorKey` exists in `hooks/selectors/registry.ts` + - `selectorKey` exists in `apps/sim/lib/selectors/manifest.ts` - `dependsOn` references selector field IDs (not `canonicalParamId`) - - Dependency `canonicalParamId` values exist in `SELECTOR_CONTEXT_FIELDS` + - Each projected dependency key is a `SelectorContextKey` allowed by the selector manifest + - Every remote key has one server attachment with credential provider binding and a reviewed + `fixed`, `credential-bound`, or `user-controlled` destination policy + - No connector selector adds a client provider module, browser token request, or selector-only + API route - [ ] `listDocuments` handles pagination with metadata-based content hashes - [ ] `syncContext.listingCapped = true` set whenever the listing is truncated (max-items cap or transient per-item error) — required to prevent the engine's deletion reconciliation from removing unseen documents - [ ] `contentDeferred: true` used if content requires per-doc API calls (file download, export, blocks fetch) diff --git a/.agents/skills/add-integration/SKILL.md b/.agents/skills/add-integration/SKILL.md index 3f3bbaccf9a..846f39a46dc 100644 --- a/.agents/skills/add-integration/SKILL.md +++ b/.agents/skills/add-integration/SKILL.md @@ -262,15 +262,24 @@ export const {Service}Block: BlockConfig = { { id: 'project', type: 'project-selector', + selectorKey: '{service}.projects', dependsOn: ['credential'], }, { id: 'issue', type: 'file-selector', + selectorKey: '{service}.issues', dependsOn: ['credential', 'project'], } ``` +Every remote `selectorKey` must use the unified server selector path. Apply the `add-selector` skill: +add browser-safe metadata to `apps/sim/lib/selectors/manifest.ts`, reuse or extract a server-only +provider listing primitive, and add a credential- and destination-bound server attachment. Do not +add code under `hooks/selectors/providers`, a provider-specific query key, browser token acquisition, +or a selector-only API route. The shared context builder sends only active `dependsOn` values and +preserves exact `{{KEY}}` environment references for server-side resolution. + **Basic/Advanced mode for dual UX:** ```typescript // Basic: Visual selector @@ -616,6 +625,10 @@ If creating V2 versions (API-aligned outputs): - [ ] Added credential field with `requiredScopes: getScopesForService('{service}')` - [ ] Added conditional fields per operation - [ ] Set up dependsOn for cascading selectors +- [ ] Every remote `selectorKey` exists in the shared manifest and has one server attachment with + trusted credential provider binding and a fixed, credential-bound, or explicitly reviewed + user-controlled destination policy +- [ ] No selector provider logic, credential resolution, or provider route call runs in the browser - [ ] Configured tools.access with all tool IDs - [ ] Configured tools.config.tool selector - [ ] Defined outputs matching tool outputs @@ -1001,7 +1014,8 @@ requiredScopes: getScopesForService('{service}'), 3. **Block type is snake_case** - `type: 'stripe'`, not `type: 'Stripe'` 4. **Alphabetical ordering** - Keep imports and registry entries alphabetically sorted 5. **Required can be conditional** - Use `required: { field: 'op', value: 'create' }` instead of always true -6. **DependsOn clears options** - When a dependency changes, selector options are refetched +6. **DependsOn clears options** - When an active dependency changes, the shared selector facade + refetches with an opaque query revision; dependency values and references never enter query keys 7. **Never pass Buffer directly to fetch** - Convert to `new Uint8Array(buffer)` for TypeScript compatibility 8. **Always handle legacy file params** - Keep hidden `fileContent` params for backwards compatibility 9. **Optional fields use advanced mode** - Set `mode: 'advanced'` on rarely-used optional fields diff --git a/.agents/skills/add-selector/SKILL.md b/.agents/skills/add-selector/SKILL.md new file mode 100644 index 00000000000..876ea4a446f --- /dev/null +++ b/.agents/skills/add-selector/SKILL.md @@ -0,0 +1,118 @@ +--- +name: add-selector +description: Add or update a Sim dynamic selector using the shared manifest, server attachment, and selectors.execute path. Use for provider-backed, internal, or local option lists referenced by block, trigger, or connector selectorKey fields. +argument-hint: +--- + +# Add Selector + +Dynamic selectors expose option metadata while a workflow or connector is being configured. Every +remote selector executes through the authorized `selectors.execute` application operation; the +browser never resolves credentials or calls a provider directly. + +## Read the shared boundary + +Before editing, read: + +- `apps/sim/lib/selectors/types.ts` +- `apps/sim/lib/selectors/manifest.ts` +- `apps/sim/lib/selectors/context.ts` +- `apps/sim/lib/selectors/server/types.ts` +- `apps/sim/lib/selectors/server/registry.ts` +- `apps/sim/hooks/queries/selectors.ts` + +Then read the nearest existing selector attachment and the block, trigger, or connector declaration +that will consume the key. + +## Classify the selector + +- `provider-server`: contacts an external provider or uses provider credentials. +- `internal-server`: reads protected Sim data through an existing authorized application use case. +- `local`: pure browser-safe data with no protected data, credentials, references, or network I/O. + +Add every key to the browser-safe manifest in `lib/selectors/manifest.ts`. `SelectorKey` derives from +that manifest; do not maintain a second union. Manifest entries contain data only: allowed context, +readiness, scope kinds, list/search/detail capabilities, and stale time. Do not import provider SDKs, +credentials, server helpers, or attachment functions into the manifest. + +## Build context from active values + +Declare `dependsOn` on the consuming sub-block or connector field. The shared context builder sends +only declared, active dependencies: + +- Canonical basic/advanced pairs contribute the active value under their canonical key. +- Action and trigger modes contribute only fields active on that surface. +- Exact environment references such as `{{GMAIL_CREDENTIAL_ID}}` remain unresolved in the browser. +- Runtime block-output references are not selector context. +- Embedded environment interpolation such as `https://{{HOST}}/path` is unsupported. + +Add a new `SelectorContextKey` only when the value is a real, reusable selector dependency. Allow it +explicitly on each relevant manifest entry. Never send a full block or connector configuration. + +## Add the server attachment + +Add the service's attachment map under `apps/sim/lib/selectors/server/providers/` and include it in +the exhaustive server registry. A provider attachment declares: + +- Credential policy, including the exact context field and trusted `serviceIds`. +- Destination policy: `fixed`, `credential-bound`, or `user-controlled`. +- A list/detail adapter that explicitly projects `id`, `label`, and allowlisted scalar `meta`. + +Stored credentials must pass actor-use, workspace, and provider/service binding checks. Do not trust +a provider, service, operation kind, origin, or module name supplied by the browser. + +Choose the destination policy deliberately: + +- `fixed`: provider origin is code-defined. +- `credential-bound`: origin/account/site comes from, or is verified against, the authorized + credential. +- `user-controlled`: the user selects the destination. Hidden use-only authentication requires an + explicit security policy; do not combine it with an arbitrary destination by default. + +Reuse or extract a server-only provider listing primitive. If an existing provider route has +non-selector callers, keep the route as a thin caller of that primitive. If it is selector-only, +move the logic and remove the obsolete route and contract. Never import a route handler or make an +internal HTTP request from an attachment. + +The attachment must return normalized selector results only. It must not return provider payloads, +resolved context, credential IDs, tokens, or secrets. Let the shared executor own scope +authorization, exact-reference resolution, credential authorization, error projection, output +sanitization, and abort propagation. + +## Wire the UI declaration + +Point the block, trigger, or connector field at `selectorKey` and declare its `dependsOn` fields. +Keep connector selector/manual canonical pairs and fork reconfiguration behavior intact. Static +`options` stay local and need no selector. + +Do not add: + +- A module under `hooks/selectors/providers` or any client provider fetcher. +- A provider-specific React Query key. +- A selector-specific OAuth-token request. +- A selector-only API route when the provider primitive can be called directly. + +All server selectors use the shared POST contract and React Query facade. Query identities must stay +opaque and must not include context values, references, credential IDs, secrets, or their hashes. + +## Focused validation + +Follow nearby Vitest and route-test style. Do not add an authorization matrix for every ordinary +provider attachment; the shared executor tests own shared security behavior. + +Add a focused adapter test when behavior is special, such as pagination, nontrivial destination +binding, provider-specific projection, or a raw-connection policy. For an ordinary fixed-origin OAuth +list, manifest/registry exhaustiveness plus an existing provider primitive test is usually enough. + +Run the smallest relevant set, then: + +```bash +bunx vitest run +bun run --cwd apps/sim type-check +bun run check:fork-dependent-coverage +bun run check:client-boundary +git diff --check +``` + +Confirm there is no browser-side provider call, every server key has one attachment, and every +returned option is explicitly projected. diff --git a/.agents/skills/add-selector/agents/openai.yaml b/.agents/skills/add-selector/agents/openai.yaml new file mode 100644 index 00000000000..c11b8a3bd73 --- /dev/null +++ b/.agents/skills/add-selector/agents/openai.yaml @@ -0,0 +1,5 @@ +interface: + display_name: "Add Selector" + short_description: "Build a secure dynamic selector" + brand_color: "#2563EB" + default_prompt: "Use $add-selector to add or update a Sim dynamic selector through the unified server execution path." diff --git a/.agents/skills/add-trigger/SKILL.md b/.agents/skills/add-trigger/SKILL.md index bfec917d60b..2a20db41d35 100644 --- a/.agents/skills/add-trigger/SKILL.md +++ b/.agents/skills/add-trigger/SKILL.md @@ -476,7 +476,9 @@ Add to `helm/sim/values.yaml` under the existing polling cron jobs: A sub-block gets its choices from exactly one of two places. There is no third. -**`selectorKey` — every remote list.** Register the list in `hooks/selectors/providers//selectors.ts`, add its key to `SelectorKey`, and point the sub-block at it. A selector is parameterized by an explicit `SelectorContext`, so the same definition serves the canvas, the workspace-fork sync modal, and anything added later. +**`selectorKey` — every remote list.** Use the `add-selector` skill to add the key to the +browser-safe manifest and attach its provider behavior on the server. All remote selectors execute +through `selectors.execute`; never add a client provider module or selector-only fetch route. ```ts { id: 'triggerCredentials', type: 'oauth-input', canonicalParamId: 'oauthCredential', mode: 'trigger' }, @@ -485,7 +487,11 @@ A sub-block gets its choices from exactly one of two places. There is no third. { id: 'manualLabelIds', type: 'short-input', mode: 'trigger-advanced' }, ``` -`canonicalParamId: 'oauthCredential'` on the credential sub-block is the line people forget. `buildSelectorContextFromBlock` keys the context on a sub-block's CANONICAL id, so without it `context.oauthCredential` is never set and the picker looks unfixable without reading the store. (A credential field is also recognised by its `oauth-input` TYPE as a fallback, so a block whose shipped param is already named something else does not have to rename it.) +`canonicalParamId: 'oauthCredential'` on the credential sub-block is the line people forget. The +shared context builder uses trigger mode and projects only active `dependsOn` values under their +canonical ids. Exact `{{KEY}}` environment references remain unresolved until the authorized server +executor. A credential field is also recognized by its `oauth-input` type as a compatibility +fallback. **`options` — everything else.** A static array, or a pure function of the block's own values for a list that narrows to a sibling's selection. No I/O. @@ -500,7 +506,8 @@ options: (params) => { Two rules the checks enforce: -- **A secret never enters a selector's `getQueryKey`.** A query key identifies a resource; a credential authorizes access to it. A credential *id* is fine; a typed password is not (see `imap.mailboxes`). +- **Selector query keys contain no dependency values.** Credential IDs, secrets, unresolved + references, and their hashes stay out of browser cache identities. - **A sub-block that `dependsOn` a credential / knowledge-base / table selector must be reconfigurable at fork-sync time** — a `selectorKey`, a canonical pair whose basic member is a selector, or a `short-input`/`long-input`. `bun run check:fork-dependent-coverage` fails otherwise, because a fork sync clears those fields on every push and an unofferable one can never be set anywhere that sticks. ## Checklist diff --git a/.agents/skills/migrate-application-operation/SKILL.md b/.agents/skills/migrate-application-operation/SKILL.md index 7b51087bb60..2786721895c 100644 --- a/.agents/skills/migrate-application-operation/SKILL.md +++ b/.agents/skills/migrate-application-operation/SKILL.md @@ -167,6 +167,22 @@ Choose principal kinds from actual behavior. Do not accept every principal merel Route declarations, tool adapters, and use cases must use the same literal operation. Runtime operation selection is permitted only from a trusted, code-defined registry. Never accept an operation ID or permission tag from an HTTP body, model argument, or other untrusted input. +### Unified selector execution is one operation + +Dynamic selector dispatch is the deliberate instance of trusted runtime selection. Define and +authorize `selectors.execute` once: it means "enumerate options while configuring a workflow or +workspace resource." The browser supplies only a selector key from the exhaustive browser-safe +manifest, scope, allowlisted context, and list/detail request. After canonical scope authorization, +the application use case selects the matching attachment from the exhaustive server-only registry. + +Provider and internal attachments are trusted implementation adapters under that semantic operation, +not separate application operations. Do not create one operation per selector, provider, or listing +endpoint. Attachments may choose only code-defined credential/service binding, destination policy, +provider primitive, and projection behavior; they must not accept a module, provider, service, +operation kind, origin, or permission tag from the request. The `selectors.execute` use case owns +reference resolution, credential authorization, provider invocation, sanitization, and safe result +projection end to end. + ## Implement the application use case Use `defineAuthorizedWorkspaceUseCase` directly or a thin domain binding that supplies domain-specific authorization options: diff --git a/.agents/skills/validate-connector/SKILL.md b/.agents/skills/validate-connector/SKILL.md index cea6e14ad73..81070a8e2a3 100644 --- a/.agents/skills/validate-connector/SKILL.md +++ b/.agents/skills/validate-connector/SKILL.md @@ -37,11 +37,16 @@ apps/sim/components/icons.tsx # Icon definition for the service If the connector uses selectors, also read: ``` -apps/sim/hooks/selectors/registry.ts # Selector key definitions -apps/sim/hooks/selectors/types.ts # SelectorKey union type -apps/sim/lib/workflows/subblocks/context.ts # SELECTOR_CONTEXT_FIELDS +apps/sim/lib/selectors/manifest.ts # Browser-safe exhaustive metadata +apps/sim/lib/selectors/types.ts # Selector context and option types +apps/sim/lib/selectors/context.ts # Active canonical context projection +apps/sim/lib/selectors/server/registry.ts # Exhaustive server attachments +apps/sim/lib/selectors/server/providers/* # Matching provider attachment ``` +Apply the `validate-selector` skill to the matching key and provider primitive. There is no client +provider selector registry. + ## Step 2: Pull API Documentation Fetch the official API docs for the service. This is the **source of truth** for: @@ -219,8 +224,17 @@ Connectors where the list API already returns content inline (e.g., Slack messag - A `type: 'selector'` field with `selectorKey`, `canonicalParamId`, `mode: 'basic'` - A `type: 'short-input'` field with the same `canonicalParamId`, `mode: 'advanced'` - `required` is identical on both fields in the pair -- [ ] `selectorKey` values exist in the selector registry +- [ ] `selectorKey` values exist in the browser-safe manifest and remote keys have exactly one + server attachment - [ ] `dependsOn` references selector field `id` values, not `canonicalParamId` +- [ ] The shared builder projects only the active canonical dependency into an allowlisted + `SelectorContextKey`; exact `{{KEY}}` references remain unresolved in the browser +- [ ] The attachment binds stored credentials to the actor, workspace, and trusted provider/service + and declares a reviewed `fixed`, `credential-bound`, or `user-controlled` destination policy +- [ ] The connector sends workspace scope through the shared selector transport and does not send + the full connector configuration +- [ ] No client provider selector module, browser token request, provider-specific selector request, + or selector-only route remains ### validateConfig - [ ] Validates all required fields are present before making API calls @@ -305,6 +319,9 @@ Group findings by severity: - `contentHash` mismatch between `listDocuments` stub and `getDocument` return — causes unnecessary re-processing every sync - Server/runtime import in `meta.ts` (e.g. `@/lib/knowledge/...`, `input-validation.server`, `fetchWithRetry`) — pulls server-only code into the client bundle and breaks the build - Connector missing from `connectors/registry.ts` (the client-safe meta registry) — or its entry there imports the runtime module instead of `meta.ts` — the knowledge UI can't render it +- A connector selector resolves shared secrets in the browser, lacks scope or credential provider + binding, combines hidden authentication with an unsafe user-controlled destination, or forwards + protected/provider payload data to the client **Warning** (incorrect behavior, data quality issues, or convention violations): - HTML content not stripped via `htmlToPlainText` @@ -358,6 +375,8 @@ After fixing, confirm: - [ ] Validated data transformation: plain text extraction, HTML stripping, content hashing - [ ] Validated tag definitions match mapTags output, correct fieldTypes - [ ] Validated config fields: canonical pairs, selector keys, required flags +- [ ] Validated each dynamic selector through the shared manifest, server attachment, and + `selectors.execute` boundary - [ ] Validated validateConfig: lightweight check, error messages, retry options - [ ] Validated getDocument: null on 404, all content types handled, no redundant re-fetches, syncContext forwarding - [ ] Validated fetchWithRetry used for all external calls (no raw fetch), VALIDATE_RETRY_OPTIONS threaded through helpers diff --git a/.agents/skills/validate-integration/SKILL.md b/.agents/skills/validate-integration/SKILL.md index abf1740647d..62bea8c88e4 100644 --- a/.agents/skills/validate-integration/SKILL.md +++ b/.agents/skills/validate-integration/SKILL.md @@ -38,6 +38,10 @@ packages/deployment-config/src/service-account-providers.generated.ts # Generate packages/deployment-config/src/service-account-metadata.ts # Handwritten deployment policy ``` +If the block or its triggers use a `selectorKey`, also apply the `validate-selector` skill and read +the key's entry in `apps/sim/lib/selectors/manifest.ts`, its server attachment and provider listing +primitive, and the shared context builder. There is no client provider selector registry. + ## Step 2: Pull API Documentation Fetch the official API docs for the service. This is the **source of truth** for: @@ -278,6 +282,22 @@ For **each tool** in `tools.access`: - [ ] Input types match the subBlock types - [ ] When using `canonicalParamId`, inputs list the canonical ID (not the raw subBlock IDs) +### Dynamic Selectors + +- [ ] Every remote `selectorKey` is classified in the browser-safe manifest and has exactly one + server attachment +- [ ] The manifest allowlists the minimal active `dependsOn` context and matches list/search/detail, + pagination, scope, and stale-time behavior +- [ ] Canonical basic/advanced and trigger/action modes project only their active values; exact + `{{KEY}}` references remain unresolved in the browser +- [ ] Stored credentials are bound to the actor, workspace, and trusted provider/service +- [ ] Each attachment declares and enforces a `fixed`, `credential-bound`, or explicitly reviewed + `user-controlled` destination policy +- [ ] Provider results are explicitly projected to safe option fields; secrets, tokens, credential + IDs, context values, and raw upstream errors do not enter responses, logs, or query keys +- [ ] No selector provider module, provider fetch, or OAuth-token request runs in the browser, and no + selector-only provider route remains + ## Step 5: Validate OAuth Scopes (if OAuth service) Scopes are centralized — the single source of truth is `OAUTH_PROVIDERS` in `lib/oauth/oauth.ts`. @@ -358,6 +378,9 @@ Group findings by severity: legacy headerless/`NULL` data - A tool substitutes secret plaintext into source, leaks private metadata, or generically sanitizes unrelated third-party results +- A selector resolves shared secret plaintext in the browser, lacks credential provider binding or + destination enforcement, or returns provider payloads or protected values across the selector + boundary **Warning** (follows conventions incorrectly or has usability issues): - Optional field not set to `mode: 'advanced'` @@ -452,6 +475,8 @@ After fixing, confirm: - [ ] Confirmed legacy persisted data keeps working and tracked invalid provenance fails closed - [ ] Confirmed ordinary third-party results remain unchanged absent activated Sim provenance - [ ] Validated `{Service}BlockMeta` exported with at least 7 templates +- [ ] Validated every dynamic selector through the shared manifest, server attachment, and + `selectors.execute` boundary - [ ] Reported all issues grouped by severity - [ ] Fixed all critical and warning issues - [ ] Ran `bun run tool-metadata:generate` if any tool outputs/params changed, and confirmed `bun run tool-metadata:check` passes diff --git a/.agents/skills/validate-selector/SKILL.md b/.agents/skills/validate-selector/SKILL.md new file mode 100644 index 00000000000..ef340efe3b3 --- /dev/null +++ b/.agents/skills/validate-selector/SKILL.md @@ -0,0 +1,94 @@ +--- +name: validate-selector +description: Audit a Sim dynamic selector across its declaration, browser-safe manifest, server attachment, provider primitive, and selectors.execute security boundary. Use when reviewing selector correctness, secret handling, scope authorization, or migration completeness. +argument-hint: +--- + +# Validate Selector + +Validate the complete path, not only the provider adapter. + +## Gather the path + +Read: + +- Every block, trigger, and connector field using the selector key. +- `apps/sim/lib/selectors/manifest.ts` and `types.ts`. +- `apps/sim/lib/selectors/context.ts`. +- The matching server attachment and any shared provider listing primitive. +- `apps/sim/lib/selectors/server/registry.ts`. +- The shared application executor, route contract, client transport, and focused tests when the + finding concerns shared behavior. + +Search literal API paths as well as TypeScript imports before deciding whether an old provider route +or contract is unused. + +## Validate the declaration and manifest + +- The key has exactly one classification and one attachment (`local` keys use the local registry). +- Allowed context is minimal and readiness matches the provider request. +- List, pagination, search, detail, unknown-detail, scope, and stale-time metadata match actual UX. +- `dependsOn` names the required source fields; canonical pairs contribute only their active member. +- Action/trigger and connector surfaces build the same canonical context. +- Exact `{{KEY}}` references remain unresolved in the browser; runtime references and embedded + interpolation are not sent. +- Query keys contain no context value, reference, credential ID, secret, or hash of one. + +## Validate the server boundary + +For provider and internal selectors, confirm the shared executor owns this order: + +1. Session authentication before request parsing. +2. Canonical workflow/workspace loading and read authorization. +3. Manifest capability and exact-context allowlisting. +4. Exact environment-reference resolution on the server. +5. Credential use, workspace, and trusted provider/service binding. +6. Destination-policy enforcement before provider network access. +7. Provider/internal execution followed by explicit option projection and sanitization. + +The attachment must not duplicate these shared checks. It must not accept a browser-provided module, +provider, service, operation kind, origin, or scope list. + +Review its destination classification: + +- `fixed` origins are code-defined. +- `credential-bound` origins are derived from or checked against the authorized credential. +- `user-controlled` destinations have an explicit policy for hidden use-only authentication and + network safety. + +Missing and inaccessible references, and missing, unauthorized, or provider-mismatched credentials, +must not become existence oracles. Resolved plaintext, credentials, tokens, and raw upstream errors +must not enter responses, query/cache/rate keys, logs, audit metadata, redirects, or error messages. +Only normalized `{ id, label, meta? }` options and bounded cursors may cross the boundary. + +## Validate provider reuse and browser boundaries + +- The attachment calls a server-only provider primitive, not a route handler or internal HTTP URL. +- A surviving provider route has a proven non-selector caller and delegates to the same primitive. +- There is no client provider selector module, selector-specific token request, or browser request to + a provider-specific selector route. +- Internal selectors delegate to existing authorized use cases rather than querying protected data + in the route or adapter. + +## Tests and report + +Use existing Vitest/route/React Query patterns. Preserve valuable provider tests, but do not demand a +per-selector authorization matrix. Shared executor tests should cover ordering, references, +credential binding, sanitization, and safe errors; adapter tests should cover only special provider +behavior. + +Report critical, warning, and suggestion findings. Treat browser secret resolution, missing scope or +credential authorization, unsafe destination binding, provider payload passthrough, and plaintext +egress as critical. + +Run the smallest relevant focused suites plus: + +```bash +bun run --cwd apps/sim type-check +bun run check:api-validation:strict +bun run check:fork-dependent-coverage +bun run check:client-boundary +git diff --check +``` + +State which live-provider checks remain pending when disposable credentials are unavailable. diff --git a/.agents/skills/validate-selector/agents/openai.yaml b/.agents/skills/validate-selector/agents/openai.yaml new file mode 100644 index 00000000000..62c3fd9a5ff --- /dev/null +++ b/.agents/skills/validate-selector/agents/openai.yaml @@ -0,0 +1,5 @@ +interface: + display_name: "Validate Selector" + short_description: "Audit a dynamic selector boundary" + brand_color: "#B45309" + default_prompt: "Use $validate-selector to audit a Sim dynamic selector for correctness and secure server-side execution." diff --git a/.agents/skills/validate-trigger/SKILL.md b/.agents/skills/validate-trigger/SKILL.md index 7e4bccb84f8..715a6642d28 100644 --- a/.agents/skills/validate-trigger/SKILL.md +++ b/.agents/skills/validate-trigger/SKILL.md @@ -36,6 +36,10 @@ apps/sim/lib/webhooks/provider-subscription-utils.ts # Subscription helpers apps/sim/lib/webhooks/processor.ts # Central webhook processor ``` +If trigger sub-blocks use a `selectorKey`, also apply the `validate-selector` skill and read the +key's browser-safe manifest entry, shared active-value context builder, server attachment, and +provider listing primitive. + ## Step 2: Pull API Documentation Fetch the service's official webhook documentation. This is the **source of truth** for: @@ -76,6 +80,12 @@ If a payload schema is unknown, validation must explicitly recommend: - [ ] Every trigger's `id` matches the convention `{service}_{event_name}` - [ ] Every trigger's `provider` matches the service name used in the handler registry - [ ] `index.ts` barrel exports all triggers +- [ ] Every remote `selectorKey` is present in `apps/sim/lib/selectors/manifest.ts` and has exactly + one server attachment +- [ ] Trigger-mode `dependsOn` fields project only active canonical values; exact `{{KEY}}` + references stay unresolved in the browser +- [ ] Trigger selectors use the shared `selectors.execute` transport, with no client provider + module, browser token request, or selector-only provider route ### Trigger ↔ Provider Alignment (CRITICAL) - [ ] Every trigger ID referenced in `matchEvent` logic exists in `{service}TriggerOptions` @@ -185,6 +195,8 @@ Group findings by severity: - Trigger IDs mismatch between trigger files, registry, and block - `createSubscription` calling wrong API endpoint - Auth comparison using `===` instead of `safeCompare` +- Trigger selector credential/reference resolution occurring in the browser, or a selector missing + scope authorization, credential provider binding, destination enforcement, or safe projection **Warning** (convention violations or usability issues): - Missing `extractIdempotencyId` when the service provides delivery IDs @@ -218,6 +230,7 @@ After fixing, confirm: - [ ] Read all trigger files, provider handler, types, registries, and block - [ ] Pulled and read official webhook/API documentation - [ ] Validated trigger definitions: options, instructions, extra fields, outputs +- [ ] Validated dynamic selector declarations through the shared manifest and server attachment - [ ] Validated primary/secondary trigger distinction (`includeDropdown`) - [ ] Validated provider handler: auth, matchEvent, formatInput, idempotency - [ ] Validated output alignment: every `outputs` key ↔ every `formatInput` key diff --git a/.claude/skills/add-selector b/.claude/skills/add-selector new file mode 120000 index 00000000000..00e430ae4ce --- /dev/null +++ b/.claude/skills/add-selector @@ -0,0 +1 @@ +../../.agents/skills/add-selector \ No newline at end of file diff --git a/.claude/skills/validate-selector b/.claude/skills/validate-selector new file mode 120000 index 00000000000..b3612d7d2a3 --- /dev/null +++ b/.claude/skills/validate-selector @@ -0,0 +1 @@ +../../.agents/skills/validate-selector \ No newline at end of file diff --git a/apps/sim/app/api/auth/oauth/microsoft/file/route.ts b/apps/sim/app/api/auth/oauth/microsoft/file/route.ts deleted file mode 100644 index d0bb8a8af7d..00000000000 --- a/apps/sim/app/api/auth/oauth/microsoft/file/route.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' -import { microsoftFileQuerySchema } from '@/lib/api/contracts/selectors/microsoft' -import { getValidationErrorMessage } from '@/lib/api/server' -import { authorizeCredentialUse } from '@/lib/auth/credential-access' -import { validateMicrosoftGraphId } from '@/lib/core/security/input-validation' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { getCredential, refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service' - -export const dynamic = 'force-dynamic' - -const logger = createLogger('MicrosoftFileAPI') - -export const GET = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - try { - const { searchParams } = new URL(request.url) - const parsedQuery = microsoftFileQuerySchema.safeParse({ - credentialId: searchParams.get('credentialId') ?? undefined, - fileId: searchParams.get('fileId') ?? undefined, - workflowId: searchParams.get('workflowId') ?? undefined, - }) - - if (!parsedQuery.success) { - return NextResponse.json( - { error: getValidationErrorMessage(parsedQuery.error) }, - { status: 400 } - ) - } - - const { credentialId, fileId, workflowId } = parsedQuery.data - - const fileIdValidation = validateMicrosoftGraphId(fileId, 'fileId') - if (!fileIdValidation.isValid) { - logger.warn(`[${requestId}] Invalid file ID: ${fileIdValidation.error}`) - return NextResponse.json({ error: fileIdValidation.error }, { status: 400 }) - } - - const authz = await authorizeCredentialUse(request, { - credentialId, - workflowId, - requireWorkflowIdForInternal: false, - }) - - if (!authz.ok || !authz.credentialOwnerUserId) { - const status = authz.error === 'Credential not found' ? 404 : 403 - return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status }) - } - - const resolvedCredentialId = authz.resolvedCredentialId || credentialId - const credential = await getCredential( - requestId, - resolvedCredentialId, - authz.credentialOwnerUserId - ) - if (!credential) { - return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) - } - - const accessToken = await refreshAccessTokenIfNeeded( - resolvedCredentialId, - authz.credentialOwnerUserId, - requestId - ) - - if (!accessToken) { - return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 }) - } - - const response = await fetch( - `https://graph.microsoft.com/v1.0/me/drive/items/${fileId}?$select=id,name,mimeType,webUrl,thumbnails,createdDateTime,lastModifiedDateTime,size,createdBy`, - { - headers: { - Authorization: `Bearer ${accessToken}`, - }, - } - ) - - if (!response.ok) { - const errorData = await response.json().catch(() => ({ error: { message: 'Unknown error' } })) - logger.error(`[${requestId}] Microsoft Graph API error`, { - status: response.status, - error: errorData.error?.message || 'Failed to fetch file from Microsoft OneDrive', - }) - return NextResponse.json( - { - error: errorData.error?.message || 'Failed to fetch file from Microsoft OneDrive', - }, - { status: response.status } - ) - } - - const file = await response.json() - - const transformedFile = { - id: file.id, - name: file.name, - mimeType: - file.mimeType || 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - iconLink: file.thumbnails?.[0]?.small?.url, - webViewLink: file.webUrl, - thumbnailLink: file.thumbnails?.[0]?.medium?.url, - createdTime: file.createdDateTime, - modifiedTime: file.lastModifiedDateTime, - size: file.size?.toString(), - owners: file.createdBy - ? [ - { - displayName: file.createdBy.user?.displayName || 'Unknown', - emailAddress: file.createdBy.user?.email || '', - }, - ] - : [], - downloadUrl: `https://graph.microsoft.com/v1.0/me/drive/items/${file.id}/content`, - } - - return NextResponse.json({ file: transformedFile }, { status: 200 }) - } catch (error) { - logger.error(`[${requestId}] Error fetching file from Microsoft OneDrive`, error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } -}) diff --git a/apps/sim/app/api/auth/oauth/microsoft/files/route.ts b/apps/sim/app/api/auth/oauth/microsoft/files/route.ts deleted file mode 100644 index 8b658cd453e..00000000000 --- a/apps/sim/app/api/auth/oauth/microsoft/files/route.ts +++ /dev/null @@ -1,219 +0,0 @@ -import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' -import { microsoftFilesQuerySchema } from '@/lib/api/contracts/selectors/microsoft' -import { getValidationErrorMessage } from '@/lib/api/server' -import { authorizeCredentialUse } from '@/lib/auth/credential-access' -import { validatePathSegment } from '@/lib/core/security/input-validation' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { getCredential, refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service' -import { GRAPH_ID_PATTERN } from '@/tools/microsoft_excel/utils' -import { assertGraphNextPageUrl, getGraphNextPageUrl } from '@/tools/sharepoint/utils' - -export const dynamic = 'force-dynamic' - -const logger = createLogger('MicrosoftFilesAPI') - -/** - * Microsoft Graph paginates `search()` results via the `@odata.nextLink` - * absolute URL in the response body. Request the largest page (`$top` caps at - * 999) and drain following nextLink, bounded by a page cap. - * See https://learn.microsoft.com/en-us/graph/paging - */ -const MICROSOFT_FILES_PAGE_SIZE = 999 -const MAX_MICROSOFT_FILES_PAGES = 20 - -interface MicrosoftGraphFile { - id: string - name?: string - mimeType?: string - webUrl?: string - size?: number - createdDateTime?: string - lastModifiedDateTime?: string - thumbnails?: Array<{ small?: { url?: string }; medium?: { url?: string } }> - createdBy?: { user?: { displayName?: string; email?: string } } -} - -/** - * The shared `/api/auth/oauth/microsoft/files` route serves both the - * `microsoft.excel` and `microsoft.word` selectors. The two are distinguished - * by the `fileType` query parameter the selector forwards (defaulting to - * `excel` for backward compatibility), which drives both the search-query - * extension hint and the server-side result filter. - */ -const FILE_TYPE_CONFIG = { - excel: { - extension: '.xlsx', - mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - }, - word: { - extension: '.docx', - mimeType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - }, -} as const - -type MicrosoftFileType = keyof typeof FILE_TYPE_CONFIG - -/** - * Get Excel or Word files from Microsoft OneDrive / SharePoint. The - * `fileType` query parameter selects which Office document type to return - * (defaults to `excel`). - */ -export const GET = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - - try { - // Get the credential ID from the query params - const { searchParams } = new URL(request.url) - const parsedQuery = microsoftFilesQuerySchema.safeParse({ - credentialId: searchParams.get('credentialId') ?? undefined, - query: searchParams.get('query') ?? undefined, - driveId: searchParams.get('driveId') ?? undefined, - workflowId: searchParams.get('workflowId') ?? undefined, - fileType: searchParams.get('fileType') ?? undefined, - }) - - if (!parsedQuery.success) { - logger.warn(`[${requestId}] Invalid query parameters`) - return NextResponse.json( - { error: getValidationErrorMessage(parsedQuery.error) }, - { status: 400 } - ) - } - - const { credentialId, driveId, workflowId } = parsedQuery.data - const query = parsedQuery.data.query ?? '' - - const fileType: MicrosoftFileType = parsedQuery.data.fileType ?? 'excel' - const { extension, mimeType: targetMimeType } = FILE_TYPE_CONFIG[fileType] - - const authz = await authorizeCredentialUse(request, { - credentialId, - workflowId, - requireWorkflowIdForInternal: false, - }) - - if (!authz.ok || !authz.credentialOwnerUserId) { - const status = authz.error === 'Credential not found' ? 404 : 403 - return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status }) - } - - const resolvedCredentialId = authz.resolvedCredentialId || credentialId - const credential = await getCredential( - requestId, - resolvedCredentialId, - authz.credentialOwnerUserId - ) - if (!credential) { - return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) - } - - // Refresh access token if needed using the utility function - const accessToken = await refreshAccessTokenIfNeeded( - resolvedCredentialId, - authz.credentialOwnerUserId, - requestId - ) - - if (!accessToken) { - return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 }) - } - - // Build search query for the requested Office document type - const searchQuery = query ? `${query} ${extension}` : extension - - // Build the query parameters for Microsoft Graph API - const searchParams_new = new URLSearchParams() - searchParams_new.append( - '$select', - 'id,name,mimeType,webUrl,thumbnails,createdDateTime,lastModifiedDateTime,size,createdBy' - ) - searchParams_new.append('$top', String(MICROSOFT_FILES_PAGE_SIZE)) - - // When driveId is provided (SharePoint), search within that specific drive. - // Otherwise, search the user's personal OneDrive. - if (driveId) { - const driveIdValidation = validatePathSegment(driveId, { - paramName: 'driveId', - customPattern: GRAPH_ID_PATTERN, - }) - if (!driveIdValidation.isValid) { - return NextResponse.json({ error: driveIdValidation.error }, { status: 400 }) - } - } - const drivePath = driveId ? `drives/${driveId}` : 'me/drive' - - const rawFiles: MicrosoftGraphFile[] = [] - let nextUrl: string | undefined = - `https://graph.microsoft.com/v1.0/${drivePath}/root/search(q='${encodeURIComponent(searchQuery)}')?${searchParams_new.toString()}` - - for (let page = 0; page < MAX_MICROSOFT_FILES_PAGES && nextUrl; page++) { - const response = await fetch(nextUrl, { - headers: { - Authorization: `Bearer ${accessToken}`, - }, - }) - - if (!response.ok) { - const errorData = await response - .json() - .catch(() => ({ error: { message: 'Unknown error' } })) - logger.error(`[${requestId}] Microsoft Graph API error`, { - status: response.status, - error: errorData.error?.message || 'Failed to fetch files from Microsoft OneDrive', - }) - return NextResponse.json( - { - error: errorData.error?.message || 'Failed to fetch files from Microsoft OneDrive', - }, - { status: response.status } - ) - } - - const data = await response.json() - rawFiles.push(...((data.value as MicrosoftGraphFile[]) || [])) - - const nextLink = getGraphNextPageUrl(data) - nextUrl = nextLink ? assertGraphNextPageUrl(nextLink) : undefined - - if (nextUrl && page === MAX_MICROSOFT_FILES_PAGES - 1) { - logger.warn( - `[${requestId}] Microsoft files search hit pagination cap; list may be incomplete`, - { fileType, pages: MAX_MICROSOFT_FILES_PAGES, collected: rawFiles.length } - ) - } - } - - // Transform Microsoft Graph response and filter to the requested file type - const files = rawFiles - .filter( - (file: MicrosoftGraphFile) => - file.name?.toLowerCase().endsWith(extension) || file.mimeType === targetMimeType - ) - .map((file: MicrosoftGraphFile) => ({ - id: file.id, - name: file.name, - mimeType: file.mimeType || targetMimeType, - iconLink: file.thumbnails?.[0]?.small?.url, - webViewLink: file.webUrl, - thumbnailLink: file.thumbnails?.[0]?.medium?.url, - createdTime: file.createdDateTime, - modifiedTime: file.lastModifiedDateTime, - size: file.size?.toString(), - owners: file.createdBy - ? [ - { - displayName: file.createdBy.user?.displayName || 'Unknown', - emailAddress: file.createdBy.user?.email || '', - }, - ] - : [], - })) - - return NextResponse.json({ files }, { status: 200 }) - } catch (error) { - logger.error(`[${requestId}] Error fetching files from Microsoft OneDrive`, error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } -}) diff --git a/apps/sim/app/api/auth/oauth/wealthbox/item/route.ts b/apps/sim/app/api/auth/oauth/wealthbox/item/route.ts deleted file mode 100644 index fa871722f36..00000000000 --- a/apps/sim/app/api/auth/oauth/wealthbox/item/route.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' -import { wealthboxOAuthItemContract } from '@/lib/api/contracts/selectors/wealthbox' -import { parseRequest } from '@/lib/api/server' -import { authorizeCredentialUse } from '@/lib/auth/credential-access' -import { validateEnum, validatePathSegment } from '@/lib/core/security/input-validation' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service' - -export const dynamic = 'force-dynamic' - -const logger = createLogger('WealthboxItemAPI') - -interface WealthboxItem { - id: string - name: string - type: string - content: string - createdAt: string - updatedAt: string -} - -/** - * Get a single item (note, contact, task) from Wealthbox - */ -export const GET = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - - try { - const parsed = await parseRequest(wealthboxOAuthItemContract, request, {}) - if (!parsed.success) return parsed.response - const { credentialId, itemId, type } = parsed.data.query - - const typeValidation = validateEnum(type, ['contact'] as const, 'type') - if (!typeValidation.isValid) { - logger.warn(`[${requestId}] Invalid item type: ${typeValidation.error}`) - return NextResponse.json({ error: typeValidation.error }, { status: 400 }) - } - - const itemIdValidation = validatePathSegment(itemId, { - paramName: 'itemId', - maxLength: 100, - allowHyphens: true, - allowUnderscores: true, - allowDots: false, - }) - if (!itemIdValidation.isValid) { - logger.warn(`[${requestId}] Invalid item ID: ${itemIdValidation.error}`) - return NextResponse.json({ error: itemIdValidation.error }, { status: 400 }) - } - - const credAccess = await authorizeCredentialUse(request, { - credentialId, - requireWorkflowIdForInternal: false, - }) - if (!credAccess.ok || !credAccess.credentialOwnerUserId) { - logger.warn(`[${requestId}] Credential access denied`, { error: credAccess.error }) - return NextResponse.json({ error: credAccess.error || 'Unauthorized' }, { status: 401 }) - } - - const accessToken = await refreshAccessTokenIfNeeded( - credentialId, - credAccess.credentialOwnerUserId, - requestId - ) - - if (!accessToken) { - logger.error(`[${requestId}] Failed to obtain valid access token`) - return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 }) - } - - const endpoints = { - contact: 'contacts', - } - const endpoint = endpoints[type as keyof typeof endpoints] - - logger.info(`[${requestId}] Fetching ${type} ${itemId} from Wealthbox`) - - const response = await fetch(`https://api.crmworkspace.com/v1/${endpoint}/${itemId}`, { - headers: { - Authorization: `Bearer ${accessToken}`, - 'Content-Type': 'application/json', - }, - }) - - if (!response.ok) { - const errorText = await response.text() - logger.error( - `[${requestId}] Wealthbox API error: ${response.status} ${response.statusText}`, - { - error: errorText, - endpoint, - itemId, - } - ) - - if (response.status === 404) { - return NextResponse.json({ error: 'Item not found' }, { status: 404 }) - } - - return NextResponse.json( - { error: `Failed to fetch ${type} from Wealthbox` }, - { status: response.status } - ) - } - - const data = (await response.json()) as Record - const meta = - data.meta && typeof data.meta === 'object' ? (data.meta as Record) : null - const totalCount = meta?.total_count ?? 'unknown' - - logger.info(`[${requestId}] Wealthbox API response structure`, { - type, - dataKeys: Object.keys(data || {}), - hasContacts: !!data.contacts, - totalCount, - }) - - let items: WealthboxItem[] = [] - - if (type === 'contact') { - if (data?.id) { - const firstName = typeof data.first_name === 'string' ? data.first_name : '' - const lastName = typeof data.last_name === 'string' ? data.last_name : '' - const item = { - id: data.id?.toString() || '', - name: `${firstName} ${lastName}`.trim() || `Contact ${data.id}`, - type: 'contact', - content: typeof data.background_info === 'string' ? data.background_info : '', - createdAt: typeof data.created_at === 'string' ? data.created_at : '', - updatedAt: typeof data.updated_at === 'string' ? data.updated_at : '', - } - items = [item] - } else { - logger.warn(`[${requestId}] Unexpected contact response format`, { data }) - items = [] - } - } - - logger.info( - `[${requestId}] Successfully fetched ${items.length} ${type}s from Wealthbox (total: ${totalCount})` - ) - - return NextResponse.json({ item: items[0] }, { status: 200 }) - } catch (error) { - logger.error(`[${requestId}] Error fetching Wealthbox item`, error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } -}) diff --git a/apps/sim/app/api/auth/oauth/wealthbox/items/route.ts b/apps/sim/app/api/auth/oauth/wealthbox/items/route.ts deleted file mode 100644 index f8781057cfe..00000000000 --- a/apps/sim/app/api/auth/oauth/wealthbox/items/route.ts +++ /dev/null @@ -1,162 +0,0 @@ -import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' -import { wealthboxOAuthItemsContract } from '@/lib/api/contracts/selectors/wealthbox' -import { parseRequest } from '@/lib/api/server' -import { authorizeCredentialUse } from '@/lib/auth/credential-access' -import { validatePathSegment } from '@/lib/core/security/input-validation' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service' - -export const dynamic = 'force-dynamic' - -const logger = createLogger('WealthboxItemsAPI') - -interface WealthboxItem { - id: string - name: string - type: string - content: string - createdAt: string - updatedAt: string -} - -/** - * Get items (notes, contacts, tasks) from Wealthbox - */ -export const GET = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - - try { - const parsed = await parseRequest(wealthboxOAuthItemsContract, request, {}) - if (!parsed.success) return parsed.response - const { credentialId, type } = parsed.data.query - const query = parsed.data.query.query ?? '' - - const credentialIdValidation = validatePathSegment(credentialId, { - paramName: 'credentialId', - maxLength: 100, - allowHyphens: true, - allowUnderscores: true, - allowDots: false, - }) - if (!credentialIdValidation.isValid) { - logger.warn(`[${requestId}] Invalid credentialId format: ${credentialId}`) - return NextResponse.json({ error: credentialIdValidation.error }, { status: 400 }) - } - - const authz = await authorizeCredentialUse(request, { - credentialId, - requireWorkflowIdForInternal: false, - }) - if (!authz.ok || !authz.credentialOwnerUserId) { - return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) - } - - const accessToken = await refreshAccessTokenIfNeeded( - credentialId, - authz.credentialOwnerUserId, - requestId - ) - - if (!accessToken) { - logger.error(`[${requestId}] Failed to obtain valid access token`) - return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 }) - } - - const endpoints = { - contact: 'contacts', - } - const endpoint = endpoints[type as keyof typeof endpoints] - - const url = new URL(`https://api.crmworkspace.com/v1/${endpoint}`) - - logger.info(`[${requestId}] Fetching ${type}s from Wealthbox`, { - endpoint, - url: url.toString(), - hasQuery: !!query.trim(), - }) - - const response = await fetch(url.toString(), { - headers: { - Authorization: `Bearer ${accessToken}`, - 'Content-Type': 'application/json', - }, - }) - - if (!response.ok) { - const errorText = await response.text() - logger.error( - `[${requestId}] Wealthbox API error: ${response.status} ${response.statusText}`, - { - error: errorText, - endpoint, - url: url.toString(), - } - ) - return NextResponse.json( - { error: `Failed to fetch ${type}s from Wealthbox` }, - { status: response.status } - ) - } - - const data = (await response.json()) as { contacts?: Array> } & Record< - string, - unknown - > - - logger.info(`[${requestId}] Wealthbox API response structure`, { - type, - status: response.status, - dataKeys: Object.keys(data || {}), - hasContacts: !!data.contacts, - dataStructure: typeof data === 'object' ? Object.keys(data) : 'not an object', - }) - - let items: WealthboxItem[] = [] - - if (type === 'contact') { - const contacts = data.contacts || [] - if (!Array.isArray(contacts)) { - logger.warn(`[${requestId}] Contacts is not an array`, { - contacts, - dataType: typeof contacts, - }) - return NextResponse.json({ items: [] }, { status: 200 }) - } - - items = contacts.map((item) => { - const firstName = typeof item.first_name === 'string' ? item.first_name : '' - const lastName = typeof item.last_name === 'string' ? item.last_name : '' - return { - id: item.id?.toString() || '', - name: `${firstName} ${lastName}`.trim() || `Contact ${item.id ?? ''}`, - type: 'contact', - content: - typeof item.background_information === 'string' ? item.background_information : '', - createdAt: typeof item.created_at === 'string' ? item.created_at : '', - updatedAt: typeof item.updated_at === 'string' ? item.updated_at : '', - } - }) - } - - if (query.trim()) { - const searchTerm = query.trim().toLowerCase() - items = items.filter( - (item) => - item.name.toLowerCase().includes(searchTerm) || - item.content.toLowerCase().includes(searchTerm) - ) - } - - logger.info(`[${requestId}] Successfully fetched ${items.length} ${type}s from Wealthbox`, { - totalItems: items.length, - hasSearchQuery: !!query.trim(), - }) - - return NextResponse.json({ items }, { status: 200 }) - } catch (error) { - logger.error(`[${requestId}] Error fetching Wealthbox items`, error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } -}) diff --git a/apps/sim/app/api/selectors/execute/route.test.ts b/apps/sim/app/api/selectors/execute/route.test.ts new file mode 100644 index 00000000000..fc2045b8464 --- /dev/null +++ b/apps/sim/app/api/selectors/execute/route.test.ts @@ -0,0 +1,36 @@ +/** + * @vitest-environment node + */ +import { createMockRequest } from '@sim/testing' +import { describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ status: 200 })) + +vi.mock('@/lib/api/server/routes', () => ({ + defineInternalJsonRoute: vi.fn( + () => async () => + new Response(JSON.stringify({ ok: mocks.status < 400 }), { + status: mocks.status, + headers: { 'Content-Type': 'application/json' }, + }) + ), + extendInternalErrorPolicy: vi.fn(() => ({})), + internalErrorResponse: vi.fn(), + internalOrchestrationErrorPolicy: {}, + internalRateLimits: { none: vi.fn(() => ({ kind: 'none' })) }, + internalSessionAuth: {}, +})) + +import { POST } from '@/app/api/selectors/execute/route' + +describe('POST /api/selectors/execute', () => { + it('marks success, authentication, parse, and unhandled responses private and non-cacheable', async () => { + for (const status of [200, 400, 401, 500]) { + mocks.status = status + const response = await POST(createMockRequest('POST', {})) + + expect(response.status).toBe(status) + expect(response.headers.get('Cache-Control')).toBe('private, no-store') + } + }) +}) diff --git a/apps/sim/app/api/selectors/execute/route.ts b/apps/sim/app/api/selectors/execute/route.ts new file mode 100644 index 00000000000..b521e8a16d8 --- /dev/null +++ b/apps/sim/app/api/selectors/execute/route.ts @@ -0,0 +1,52 @@ +import { executeSelectorContract } from '@/lib/api/contracts/selectors/execute' +import { + defineInternalJsonRoute, + extendInternalErrorPolicy, + internalErrorResponse, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { executeSelector } from '@/lib/selectors/application/execute-selector' +import { selectorOperations } from '@/lib/selectors/application/operations' +import { + SelectorConnectionUnavailableError, + SelectorContextUnavailableError, + SelectorOptionsUnavailableError, +} from '@/lib/selectors/server/errors' + +const PRIVATE_NO_STORE = { 'Cache-Control': 'private, no-store' } as const + +const selectorErrorPolicy = extendInternalErrorPolicy(internalOrchestrationErrorPolicy, (error) => { + if (error instanceof SelectorContextUnavailableError) { + return internalErrorResponse(400, { error: 'Context unavailable' }, PRIVATE_NO_STORE) + } + if (error instanceof SelectorConnectionUnavailableError) { + return internalErrorResponse(403, { error: 'Connection unavailable' }, PRIVATE_NO_STORE) + } + if (error instanceof SelectorOptionsUnavailableError) { + return internalErrorResponse(502, { error: 'Options unavailable' }, PRIVATE_NO_STORE) + } + return null +}) + +const executeSelectorRoute = defineInternalJsonRoute({ + contract: executeSelectorContract, + auth: internalSessionAuth, + operation: selectorOperations.execute, + rateLimit: internalRateLimits.none({ + reason: 'Preserve existing dynamic selector request behavior during the server migration', + }), + errorPolicy: selectorErrorPolicy, + parseOptions: { maxBodyBytes: 256 * 1024 }, + mapInput: ({ body }, { request }) => ({ ...body, signal: request.signal }), + useCase: executeSelector, + responseHeaders: () => PRIVATE_NO_STORE, +}) + +/** Applies the privacy header to authentication, parse, and unhandled failures too. */ +export async function POST(...args: Parameters): Promise { + const response = await executeSelectorRoute(...args) + response.headers.set('Cache-Control', PRIVATE_NO_STORE['Cache-Control']) + return response +} diff --git a/apps/sim/app/api/tools/airtable/bases/route.ts b/apps/sim/app/api/tools/airtable/bases/route.ts deleted file mode 100644 index a309daa1f08..00000000000 --- a/apps/sim/app/api/tools/airtable/bases/route.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' -import { airtableBasesSelectorContract } from '@/lib/api/contracts/selectors' -import { parseRequest } from '@/lib/api/server' -import { authorizeCredentialUse } from '@/lib/auth/credential-access' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service' - -const logger = createLogger('AirtableBasesAPI') - -export const dynamic = 'force-dynamic' - -const AIRTABLE_MAX_BASES_PAGES = 50 - -interface AirtableBase { - id: string - name: string -} - -/** - * Lists all Airtable bases, following the `offset` continuation token the Meta - * API returns (an opaque string, passed back verbatim as `?offset=`) so the - * full set is returned. Bounded by `AIRTABLE_MAX_BASES_PAGES`; logs a warning - * rather than silently dropping bases when the cap is hit. - */ -async function fetchAllBases(accessToken: string): Promise { - const bases: AirtableBase[] = [] - let offset: string | undefined - - for (let page = 0; page < AIRTABLE_MAX_BASES_PAGES; page++) { - const url = new URL('https://api.airtable.com/v0/meta/bases') - if (offset) { - url.searchParams.set('offset', offset) - } - - const response = await fetch(url.toString(), { - headers: { - Authorization: `Bearer ${accessToken}`, - 'Content-Type': 'application/json', - }, - }) - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})) - throw new AirtableFetchError(response.status, errorData) - } - - const data = (await response.json()) as { bases?: AirtableBase[]; offset?: string } - if (Array.isArray(data.bases)) { - bases.push(...data.bases) - } - - offset = data.offset || undefined - if (!offset) { - return bases - } - - if (page === AIRTABLE_MAX_BASES_PAGES - 1) { - logger.warn('Airtable bases listing hit pagination cap; base list may be incomplete', { - pages: AIRTABLE_MAX_BASES_PAGES, - }) - } - } - - return bases -} - -class AirtableFetchError extends Error { - constructor( - readonly status: number, - readonly details: unknown - ) { - super('Failed to fetch Airtable bases') - this.name = 'AirtableFetchError' - } -} - -export const POST = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - try { - const parsed = await parseRequest(airtableBasesSelectorContract, request, {}) - if (!parsed.success) { - logger.error('Missing credential in request') - return parsed.response - } - const { credential, workflowId } = parsed.data.body - - const authz = await authorizeCredentialUse(request, { - credentialId: credential, - workflowId, - }) - if (!authz.ok || !authz.credentialOwnerUserId) { - return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) - } - - const accessToken = await refreshAccessTokenIfNeeded( - credential, - authz.credentialOwnerUserId, - requestId - ) - if (!accessToken) { - logger.error('Failed to get access token', { - credentialId: credential, - userId: authz.credentialOwnerUserId, - }) - return NextResponse.json( - { error: 'Could not retrieve access token', authRequired: true }, - { status: 401 } - ) - } - - let allBases: AirtableBase[] - try { - allBases = await fetchAllBases(accessToken) - } catch (error) { - if (error instanceof AirtableFetchError) { - logger.error('Failed to fetch Airtable bases', { - status: error.status, - error: error.details, - }) - return NextResponse.json( - { error: 'Failed to fetch Airtable bases', details: error.details }, - { status: error.status } - ) - } - throw error - } - - const bases = allBases.map((base) => ({ - id: base.id, - name: base.name, - })) - - return NextResponse.json({ bases }) - } catch (error) { - logger.error('Error processing Airtable bases request:', error) - return NextResponse.json( - { error: 'Failed to retrieve Airtable bases', details: (error as Error).message }, - { status: 500 } - ) - } -}) diff --git a/apps/sim/app/api/tools/airtable/tables/route.ts b/apps/sim/app/api/tools/airtable/tables/route.ts deleted file mode 100644 index 3f4ba3c9739..00000000000 --- a/apps/sim/app/api/tools/airtable/tables/route.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' -import { airtableTablesSelectorContract } from '@/lib/api/contracts/selectors' -import { parseRequest } from '@/lib/api/server' -import { authorizeCredentialUse } from '@/lib/auth/credential-access' -import { validateAirtableId } from '@/lib/core/security/input-validation' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service' - -const logger = createLogger('AirtableTablesAPI') - -export const dynamic = 'force-dynamic' - -export const POST = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - try { - const parsed = await parseRequest(airtableTablesSelectorContract, request, {}) - if (!parsed.success) return parsed.response - const { credential, workflowId, baseId } = parsed.data.body - - const baseIdValidation = validateAirtableId(baseId, 'app', 'baseId') - if (!baseIdValidation.isValid) { - logger.error('Invalid baseId', { error: baseIdValidation.error }) - return NextResponse.json({ error: baseIdValidation.error }, { status: 400 }) - } - - const authz = await authorizeCredentialUse(request, { - credentialId: credential, - workflowId, - }) - if (!authz.ok || !authz.credentialOwnerUserId) { - return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) - } - - const accessToken = await refreshAccessTokenIfNeeded( - credential, - authz.credentialOwnerUserId, - requestId - ) - if (!accessToken) { - logger.error('Failed to get access token', { - credentialId: credential, - userId: authz.credentialOwnerUserId, - }) - return NextResponse.json( - { error: 'Could not retrieve access token', authRequired: true }, - { status: 401 } - ) - } - - const response = await fetch( - `https://api.airtable.com/v0/meta/bases/${baseIdValidation.sanitized}/tables`, - { - headers: { - Authorization: `Bearer ${accessToken}`, - 'Content-Type': 'application/json', - }, - } - ) - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})) - logger.error('Failed to fetch Airtable tables', { - status: response.status, - error: errorData, - baseId, - }) - return NextResponse.json( - { error: 'Failed to fetch Airtable tables', details: errorData }, - { status: response.status } - ) - } - - const data = await response.json() - const tables = (data.tables || []).map((table: { id: string; name: string }) => ({ - id: table.id, - name: table.name, - })) - - return NextResponse.json({ tables }) - } catch (error) { - logger.error('Error processing Airtable tables request:', error) - return NextResponse.json( - { error: 'Failed to retrieve Airtable tables', details: (error as Error).message }, - { status: 500 } - ) - } -}) diff --git a/apps/sim/app/api/tools/asana/workspaces/route.ts b/apps/sim/app/api/tools/asana/workspaces/route.ts deleted file mode 100644 index 0aaec253c95..00000000000 --- a/apps/sim/app/api/tools/asana/workspaces/route.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' -import { asanaWorkspacesSelectorContract } from '@/lib/api/contracts/selectors' -import { parseRequest } from '@/lib/api/server' -import { authorizeCredentialUse } from '@/lib/auth/credential-access' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service' - -const logger = createLogger('AsanaWorkspacesAPI') - -export const dynamic = 'force-dynamic' - -const ASANA_PAGE_LIMIT = 100 -const ASANA_MAX_WORKSPACES_PAGES = 50 - -interface AsanaWorkspace { - gid: string - name: string -} - -interface AsanaWorkspacesPage { - data?: AsanaWorkspace[] - next_page?: { - offset?: string - } | null -} - -/** - * Lists all Asana workspaces using `limit`/`offset` pagination, following - * `next_page.offset` (an opaque token, passed back verbatim as `?offset=`) - * until `next_page` is null so the full set is returned. Bounded by - * `ASANA_MAX_WORKSPACES_PAGES`; logs a warning rather than silently dropping - * workspaces when the cap is hit. - */ -async function fetchAllWorkspaces(accessToken: string): Promise { - const workspaces: AsanaWorkspace[] = [] - let offset: string | undefined - - for (let page = 0; page < ASANA_MAX_WORKSPACES_PAGES; page++) { - const url = new URL('https://app.asana.com/api/1.0/workspaces') - url.searchParams.set('limit', String(ASANA_PAGE_LIMIT)) - if (offset) { - url.searchParams.set('offset', offset) - } - - const response = await fetch(url.toString(), { - headers: { - Authorization: `Bearer ${accessToken}`, - Accept: 'application/json', - }, - }) - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})) - throw new AsanaFetchError(response.status, errorData) - } - - const data = (await response.json()) as AsanaWorkspacesPage - if (Array.isArray(data.data)) { - workspaces.push(...data.data) - } - - offset = data.next_page?.offset || undefined - if (!offset) { - return workspaces - } - - if (page === ASANA_MAX_WORKSPACES_PAGES - 1) { - logger.warn('Asana workspaces listing hit pagination cap; workspace list may be incomplete', { - pages: ASANA_MAX_WORKSPACES_PAGES, - }) - } - } - - return workspaces -} - -class AsanaFetchError extends Error { - constructor( - readonly status: number, - readonly details: unknown - ) { - super('Failed to fetch Asana workspaces') - this.name = 'AsanaFetchError' - } -} - -export const POST = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - try { - const parsed = await parseRequest(asanaWorkspacesSelectorContract, request, {}) - if (!parsed.success) return parsed.response - const { credential, workflowId } = parsed.data.body - - const authz = await authorizeCredentialUse(request, { - credentialId: credential, - workflowId, - }) - if (!authz.ok || !authz.credentialOwnerUserId) { - return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) - } - - const accessToken = await refreshAccessTokenIfNeeded( - credential, - authz.credentialOwnerUserId, - requestId - ) - if (!accessToken) { - logger.error('Failed to get access token', { - credentialId: credential, - userId: authz.credentialOwnerUserId, - }) - return NextResponse.json( - { error: 'Could not retrieve access token', authRequired: true }, - { status: 401 } - ) - } - - let allWorkspaces: AsanaWorkspace[] - try { - allWorkspaces = await fetchAllWorkspaces(accessToken) - } catch (error) { - if (error instanceof AsanaFetchError) { - logger.error('Failed to fetch Asana workspaces', { - status: error.status, - error: error.details, - }) - return NextResponse.json( - { error: 'Failed to fetch Asana workspaces', details: error.details }, - { status: error.status } - ) - } - throw error - } - - const workspaces = allWorkspaces.map((workspace) => ({ - id: workspace.gid, - name: workspace.name, - })) - - return NextResponse.json({ workspaces }) - } catch (error) { - logger.error('Error processing Asana workspaces request:', error) - return NextResponse.json( - { error: 'Failed to retrieve Asana workspaces', details: (error as Error).message }, - { status: 500 } - ) - } -}) diff --git a/apps/sim/app/api/tools/attio/lists/route.ts b/apps/sim/app/api/tools/attio/lists/route.ts deleted file mode 100644 index 310784e6fb4..00000000000 --- a/apps/sim/app/api/tools/attio/lists/route.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' -import { attioListsSelectorContract } from '@/lib/api/contracts/selectors/attio' -import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' -import { authorizeCredentialUse } from '@/lib/auth/credential-access' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service' - -const logger = createLogger('AttioListsAPI') - -export const dynamic = 'force-dynamic' - -export const POST = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - try { - const parsed = await parseRequest( - attioListsSelectorContract, - request, - {}, - { - validationErrorResponse: (error) => { - logger.error('Missing credential in request') - return NextResponse.json( - { error: getValidationErrorMessage(error, 'Invalid request') }, - { status: 400 } - ) - }, - } - ) - if (!parsed.success) return parsed.response - - const { credential, workflowId } = parsed.data.body - - const authz = await authorizeCredentialUse(request as any, { - credentialId: credential, - workflowId, - }) - if (!authz.ok || !authz.credentialOwnerUserId) { - return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) - } - - const accessToken = await refreshAccessTokenIfNeeded( - credential, - authz.credentialOwnerUserId, - requestId - ) - if (!accessToken) { - logger.error('Failed to get access token', { - credentialId: credential, - userId: authz.credentialOwnerUserId, - }) - return NextResponse.json( - { error: 'Could not retrieve access token', authRequired: true }, - { status: 401 } - ) - } - - const response = await fetch('https://api.attio.com/v2/lists', { - headers: { - Authorization: `Bearer ${accessToken}`, - Accept: 'application/json', - }, - }) - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})) - logger.error('Failed to fetch Attio lists', { - status: response.status, - error: errorData, - }) - return NextResponse.json( - { error: 'Failed to fetch Attio lists', details: errorData }, - { status: response.status } - ) - } - - const data = await response.json() - const lists = (data.data || []).map((list: { api_slug: string; name: string }) => ({ - id: list.api_slug, - name: list.name, - })) - - return NextResponse.json({ lists }) - } catch (error) { - logger.error('Error processing Attio lists request:', error) - return NextResponse.json( - { error: 'Failed to retrieve Attio lists', details: (error as Error).message }, - { status: 500 } - ) - } -}) diff --git a/apps/sim/app/api/tools/attio/objects/route.ts b/apps/sim/app/api/tools/attio/objects/route.ts deleted file mode 100644 index de0e7820f91..00000000000 --- a/apps/sim/app/api/tools/attio/objects/route.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' -import { attioObjectsSelectorContract } from '@/lib/api/contracts/selectors/attio' -import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' -import { authorizeCredentialUse } from '@/lib/auth/credential-access' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service' - -const logger = createLogger('AttioObjectsAPI') - -export const dynamic = 'force-dynamic' - -export const POST = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - try { - const parsed = await parseRequest( - attioObjectsSelectorContract, - request, - {}, - { - validationErrorResponse: (error) => { - logger.error('Missing credential in request') - return NextResponse.json( - { error: getValidationErrorMessage(error, 'Invalid request') }, - { status: 400 } - ) - }, - } - ) - if (!parsed.success) return parsed.response - - const { credential, workflowId } = parsed.data.body - - const authz = await authorizeCredentialUse(request as any, { - credentialId: credential, - workflowId, - }) - if (!authz.ok || !authz.credentialOwnerUserId) { - return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) - } - - const accessToken = await refreshAccessTokenIfNeeded( - credential, - authz.credentialOwnerUserId, - requestId - ) - if (!accessToken) { - logger.error('Failed to get access token', { - credentialId: credential, - userId: authz.credentialOwnerUserId, - }) - return NextResponse.json( - { error: 'Could not retrieve access token', authRequired: true }, - { status: 401 } - ) - } - - const response = await fetch('https://api.attio.com/v2/objects', { - headers: { - Authorization: `Bearer ${accessToken}`, - Accept: 'application/json', - }, - }) - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})) - logger.error('Failed to fetch Attio objects', { - status: response.status, - error: errorData, - }) - return NextResponse.json( - { error: 'Failed to fetch Attio objects', details: errorData }, - { status: response.status } - ) - } - - const data = await response.json() - const objects = (data.data || []).map((obj: { api_slug: string; singular_noun: string }) => ({ - id: obj.api_slug, - name: obj.singular_noun, - })) - - return NextResponse.json({ objects }) - } catch (error) { - logger.error('Error processing Attio objects request:', error) - return NextResponse.json( - { error: 'Failed to retrieve Attio objects', details: (error as Error).message }, - { status: 500 } - ) - } -}) diff --git a/apps/sim/app/api/tools/bitbucket/repositories/route.test.ts b/apps/sim/app/api/tools/bitbucket/repositories/route.test.ts deleted file mode 100644 index 5838cc9f59c..00000000000 --- a/apps/sim/app/api/tools/bitbucket/repositories/route.test.ts +++ /dev/null @@ -1,326 +0,0 @@ -/** - * @vitest-environment node - */ -import { NextRequest } from 'next/server' -import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' - -const { - mockAuthorizeCredentialUse, - mockCheckSessionOrInternalAuth, - mockFetch, - mockGetCredential, - mockRefreshAccessTokenIfNeeded, -} = vi.hoisted(() => ({ - mockAuthorizeCredentialUse: vi.fn(), - mockCheckSessionOrInternalAuth: vi.fn(), - mockFetch: vi.fn(), - mockGetCredential: vi.fn(), - mockRefreshAccessTokenIfNeeded: vi.fn(), -})) - -vi.mock('@/lib/auth/credential-access', () => ({ - authorizeCredentialUse: mockAuthorizeCredentialUse, -})) -vi.mock('@/lib/auth/hybrid', () => ({ - checkSessionOrInternalAuth: mockCheckSessionOrInternalAuth, -})) -vi.mock('@/lib/oauth/credential-service', () => ({ - getCredential: mockGetCredential, - refreshAccessTokenIfNeeded: mockRefreshAccessTokenIfNeeded, -})) - -import { POST } from '@/app/api/tools/bitbucket/repositories/route' - -const URL = 'http://localhost:3000/api/tools/bitbucket/repositories' -const FIRST_PAGE_URL = - 'https://api.bitbucket.org/2.0/repositories/acme-platform?pagelen=100&fields=values.slug%2Cvalues.uuid%2Cvalues.name%2Cvalues.full_name%2Cnext' -const SECOND_PAGE_URL = - 'https://api.bitbucket.org/2.0/repositories/acme-platform?page=2&pagelen=100' -const REQUEST_BODY = { - credential: 'credential-1', - workflowId: 'workflow-1', - workspaceSlug: 'acme-platform', -} as const - -function request(body: unknown): NextRequest { - return new NextRequest(URL, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: typeof body === 'string' ? body : JSON.stringify(body), - }) -} - -function providerResponse(body: unknown, status = 200): Response { - return new Response(JSON.stringify(body), { - status, - headers: { 'content-type': 'application/json' }, - }) -} - -async function json(response: Response): Promise> { - return (await response.json()) as Record -} - -describe('POST /api/tools/bitbucket/repositories', () => { - beforeEach(() => { - vi.clearAllMocks() - vi.stubGlobal('fetch', mockFetch) - mockCheckSessionOrInternalAuth.mockResolvedValue({ success: true, userId: 'caller-1' }) - mockAuthorizeCredentialUse.mockResolvedValue({ - ok: true, - credentialOwnerUserId: 'owner-1', - resolvedCredentialId: 'account-1', - credentialType: 'oauth', - }) - mockGetCredential.mockResolvedValue({ providerId: 'bitbucket' }) - mockRefreshAccessTokenIfNeeded.mockResolvedValue('server-only-token') - mockFetch.mockResolvedValue(providerResponse({ values: [] })) - }) - - afterAll(() => vi.unstubAllGlobals()) - - it('authenticates before attempting to parse an invalid body', async () => { - mockCheckSessionOrInternalAuth.mockResolvedValueOnce({ - success: false, - error: 'Authentication required', - }) - - const response = await POST(request('{not-json'), {}) - - expect(response.status).toBe(401) - expect(mockAuthorizeCredentialUse).not.toHaveBeenCalled() - expect(mockFetch).not.toHaveBeenCalled() - }) - - it('requires the workspace dependency before credential authorization', async () => { - const response = await POST( - request({ credential: 'credential-1', workflowId: 'workflow-1' }), - {} - ) - - expect(response.status).toBe(400) - expect(mockAuthorizeCredentialUse).not.toHaveBeenCalled() - expect(mockRefreshAccessTokenIfNeeded).not.toHaveBeenCalled() - expect(mockFetch).not.toHaveBeenCalled() - }) - - it.each(['{0f6f9d4d-70cf-4831-aaa2-d196fc5eebcb}', '0f6f9d4d-70cf-4831-aaa2-d196fc5eebcb'])( - 'rejects workspace UUID %s because repository discovery is slug-scoped', - async (uuid) => { - const response = await POST(request({ ...REQUEST_BODY, workspaceSlug: uuid }), {}) - - expect(response.status).toBe(400) - expect(mockAuthorizeCredentialUse).not.toHaveBeenCalled() - expect(mockRefreshAccessTokenIfNeeded).not.toHaveBeenCalled() - expect(mockFetch).not.toHaveBeenCalled() - } - ) - - it('authorizes the exact credential before resolving or refreshing it', async () => { - const response = await POST(request(REQUEST_BODY), {}) - - expect(response.status).toBe(200) - expect(mockAuthorizeCredentialUse).toHaveBeenCalledWith(expect.any(NextRequest), { - credentialId: 'credential-1', - workflowId: 'workflow-1', - callerUserId: 'caller-1', - }) - expect(mockGetCredential).toHaveBeenCalledWith(expect.any(String), 'account-1', 'owner-1') - expect(mockRefreshAccessTokenIfNeeded).toHaveBeenCalledWith( - 'account-1', - 'owner-1', - expect.any(String) - ) - }) - - it('fails closed when credential authorization is denied', async () => { - mockAuthorizeCredentialUse.mockResolvedValueOnce({ ok: false, error: 'Forbidden' }) - - const response = await POST(request(REQUEST_BODY), {}) - - expect(response.status).toBe(403) - expect(mockGetCredential).not.toHaveBeenCalled() - expect(mockRefreshAccessTokenIfNeeded).not.toHaveBeenCalled() - expect(mockFetch).not.toHaveBeenCalled() - }) - - it('does not send a credential for another provider to Bitbucket', async () => { - mockGetCredential.mockResolvedValueOnce({ providerId: 'gitlab' }) - - const response = await POST(request(REQUEST_BODY), {}) - - expect(response.status).toBe(400) - expect(mockRefreshAccessTokenIfNeeded).not.toHaveBeenCalled() - expect(mockFetch).not.toHaveBeenCalled() - }) - - it('returns an auth-required response when token refresh cannot resolve a token', async () => { - mockRefreshAccessTokenIfNeeded.mockResolvedValueOnce(null) - - const response = await POST(request(REQUEST_BODY), {}) - - expect(response.status).toBe(401) - expect(await json(response)).toMatchObject({ authRequired: true }) - expect(mockFetch).not.toHaveBeenCalled() - }) - - it.each([ - ['plain HTTP', 'http://api.bitbucket.org/2.0/repositories/acme-platform?page=2'], - [ - 'lookalike host', - 'https://api.bitbucket.org.evil.example/2.0/repositories/acme-platform?page=2', - ], - ['different workspace', 'https://api.bitbucket.org/2.0/repositories/other-team?page=2'], - ['wrong endpoint', 'https://api.bitbucket.org/2.0/user/workspaces?page=2'], - [ - 'embedded credentials', - 'https://attacker:secret@api.bitbucket.org/2.0/repositories/acme-platform?page=2', - ], - ])('rejects a %s cursor before resolving a bearer token', async (_label, cursor) => { - const response = await POST(request({ ...REQUEST_BODY, cursor }), {}) - - expect(response.status).toBe(400) - expect(mockAuthorizeCredentialUse).not.toHaveBeenCalled() - expect(mockRefreshAccessTokenIfNeeded).not.toHaveBeenCalled() - expect(mockFetch).not.toHaveBeenCalled() - }) - - it('normalizes a page and returns slug ids with UUID/full-name data, never the token', async () => { - mockFetch.mockResolvedValueOnce( - providerResponse({ - values: [ - { - uuid: '{repository-uuid}', - full_name: 'acme-platform/payments-api', - links: { html: { href: 'https://bitbucket.org/acme-platform/payments-api' } }, - }, - ], - next: SECOND_PAGE_URL, - }) - ) - - const response = await POST(request(REQUEST_BODY), {}) - const body = await json(response) - - expect(response.status).toBe(200) - expect(mockFetch).toHaveBeenCalledWith( - FIRST_PAGE_URL, - expect.objectContaining({ method: 'GET', redirect: 'error' }) - ) - const init = mockFetch.mock.calls[0]?.[1] as RequestInit - expect(new Headers(init.headers).get('Authorization')).toBe('Bearer server-only-token') - expect(body).toEqual({ - repositories: [ - { - slug: 'payments-api', - uuid: '{repository-uuid}', - name: 'payments-api', - fullName: 'acme-platform/payments-api', - }, - ], - nextCursor: SECOND_PAGE_URL, - }) - expect(JSON.stringify(body)).not.toContain('server-only-token') - }) - - it('uses a validated cursor for one page without dropping the workspace scope', async () => { - const response = await POST(request({ ...REQUEST_BODY, cursor: SECOND_PAGE_URL }), {}) - - expect(response.status).toBe(200) - expect(mockFetch).toHaveBeenCalledTimes(1) - expect(mockFetch).toHaveBeenCalledWith(SECOND_PAGE_URL, expect.any(Object)) - }) - - it.each([ - ['missing values', {}], - ['non-array values', { values: { slug: 'payments-api' } }], - [ - 'malformed repository', - { - values: [ - { - slug: 'payments-api', - uuid: '{repository-uuid}', - name: 'Payments API', - }, - ], - }, - ], - [ - 'oversized page', - { - values: Array.from({ length: 101 }, (_, index) => ({ - slug: `repository-${index}`, - uuid: `{repository-${index}}`, - name: `Repository ${index}`, - full_name: `acme-platform/repository-${index}`, - })), - }, - ], - ])('fails closed on a %s provider response', async (_label, providerBody) => { - mockFetch.mockResolvedValueOnce(providerResponse(providerBody)) - - const response = await POST(request(REQUEST_BODY), {}) - - expect(response.status).toBe(502) - expect(await json(response)).toEqual({ - error: 'Bitbucket returned an invalid repository response.', - }) - }) - - it('fails closed on invalid provider JSON', async () => { - mockFetch.mockResolvedValueOnce(new Response('{not-json', { status: 200 })) - - const response = await POST(request(REQUEST_BODY), {}) - - expect(response.status).toBe(502) - }) - - it('accepts a case-mismatched workspace slug the way Bitbucket itself resolves it', async () => { - mockFetch.mockResolvedValueOnce( - providerResponse({ - values: [ - { - slug: 'sdk-core', - uuid: '{repo-1}', - name: 'SDK Core', - full_name: 'acme-platform/sdk-core', - }, - ], - next: 'https://api.bitbucket.org/2.0/repositories/acme-platform?page=2', - }) - ) - - const response = await POST(request({ ...REQUEST_BODY, workspaceSlug: 'ACME-Platform' }), {}) - - expect(response.status).toBe(200) - expect(await json(response)).toMatchObject({ - repositories: [{ slug: 'sdk-core', fullName: 'acme-platform/sdk-core' }], - }) - }) - - it('rejects a workspace slug containing dot segments before it reaches Bitbucket', async () => { - for (const workspaceSlug of ['.', '..', 'acme.platform']) { - const response = await POST(request({ ...REQUEST_BODY, workspaceSlug }), {}) - - expect(response.status, workspaceSlug).toBe(400) - expect(mockFetch).not.toHaveBeenCalled() - } - }) - - it('rejects a provider next link that crosses the selected workspace', async () => { - mockFetch.mockResolvedValueOnce( - providerResponse({ - values: [], - next: 'https://api.bitbucket.org/2.0/repositories/other-team?page=2', - }) - ) - - const response = await POST(request(REQUEST_BODY), {}) - - expect(response.status).toBe(502) - expect(await json(response)).toEqual({ - error: 'Bitbucket returned an invalid repository response.', - }) - }) -}) diff --git a/apps/sim/app/api/tools/bitbucket/repositories/route.ts b/apps/sim/app/api/tools/bitbucket/repositories/route.ts deleted file mode 100644 index 636b786a56b..00000000000 --- a/apps/sim/app/api/tools/bitbucket/repositories/route.ts +++ /dev/null @@ -1,173 +0,0 @@ -import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' -import { - BITBUCKET_SELECTOR_PAGE_SIZE, - bitbucketRepositoriesSelectorContract, - bitbucketRepositoryProviderPageSchema, - isBitbucketRepositoriesCursor, -} from '@/lib/api/contracts/selectors/bitbucket' -import { parseRequest } from '@/lib/api/server' -import { authorizeCredentialUse } from '@/lib/auth/credential-access' -import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' -import { generateRequestId } from '@/lib/core/utils/request' -import { readResponseJsonWithLimit } from '@/lib/core/utils/stream-limits' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { getCredential, refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service' - -export const dynamic = 'force-dynamic' - -const logger = createLogger('BitbucketRepositoriesAPI') -const BITBUCKET_PROVIDER_ID = 'bitbucket' -const BITBUCKET_REPOSITORIES_URL = 'https://api.bitbucket.org/2.0/repositories' -const BITBUCKET_REPOSITORY_FIELDS = 'values.slug,values.uuid,values.name,values.full_name,next' -const SELECTOR_REQUEST_MAX_BYTES = 8 * 1024 -const PROVIDER_RESPONSE_MAX_BYTES = 1024 * 1024 - -function bitbucketFailureResponse(status: number): NextResponse { - if (status === 401) { - return NextResponse.json( - { - error: 'Bitbucket rejected this credential. Reconnect it and try again.', - authRequired: true, - }, - { status: 401 } - ) - } - if (status === 403) { - return NextResponse.json( - { error: 'Bitbucket denied access to repository discovery.' }, - { status: 403 } - ) - } - if (status === 429) { - return NextResponse.json( - { error: 'Bitbucket rate-limited repository discovery. Try again shortly.' }, - { status: 429 } - ) - } - return NextResponse.json({ error: 'Bitbucket repository discovery failed.' }, { status: 502 }) -} - -/** Lists one workspace-scoped page for the `bitbucket.repositories` selector. */ -export const POST = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - - const caller = await checkSessionOrInternalAuth(request, { requireWorkflowId: true }) - if (!caller.success || !caller.userId) { - return NextResponse.json({ error: caller.error || 'Authentication required' }, { status: 401 }) - } - - const parsed = await parseRequest( - bitbucketRepositoriesSelectorContract, - request, - {}, - { - maxBodyBytes: SELECTOR_REQUEST_MAX_BYTES, - } - ) - if (!parsed.success) return parsed.response - const { credential, workflowId, workspaceSlug, cursor } = parsed.data.body - - const authorization = await authorizeCredentialUse(request, { - credentialId: credential, - workflowId, - callerUserId: caller.userId, - }) - if (!authorization.ok || !authorization.credentialOwnerUserId) { - return NextResponse.json({ error: authorization.error || 'Unauthorized' }, { status: 403 }) - } - - const resolvedCredentialId = authorization.resolvedCredentialId ?? credential - const storedCredential = await getCredential( - requestId, - resolvedCredentialId, - authorization.credentialOwnerUserId - ) - if (!storedCredential || storedCredential.providerId !== BITBUCKET_PROVIDER_ID) { - return NextResponse.json({ error: 'Select a Bitbucket OAuth credential.' }, { status: 400 }) - } - - const accessToken = await refreshAccessTokenIfNeeded( - resolvedCredentialId, - authorization.credentialOwnerUserId, - requestId - ) - if (!accessToken) { - return NextResponse.json( - { error: 'Could not retrieve a Bitbucket access token.', authRequired: true }, - { status: 401 } - ) - } - - const firstPage = new URL(`${BITBUCKET_REPOSITORIES_URL}/${encodeURIComponent(workspaceSlug)}`) - firstPage.searchParams.set('pagelen', String(BITBUCKET_SELECTOR_PAGE_SIZE)) - firstPage.searchParams.set('fields', BITBUCKET_REPOSITORY_FIELDS) - const providerUrl = cursor ?? firstPage.toString() - - let response: Response - try { - response = await fetch(providerUrl, { - method: 'GET', - headers: { - Accept: 'application/json', - Authorization: `Bearer ${accessToken}`, - }, - redirect: 'error', - signal: request.signal, - }) - } catch (error) { - if (request.signal.aborted) throw error - logger.warn('Bitbucket repository request failed', { - workspaceSlug, - errorType: error instanceof Error ? error.name : 'unknown', - }) - return NextResponse.json({ error: 'Bitbucket repository discovery failed.' }, { status: 502 }) - } - - if (!response.ok) return bitbucketFailureResponse(response.status) - - let providerBody: unknown - try { - providerBody = await readResponseJsonWithLimit(response, { - label: 'Bitbucket repository response', - maxBytes: PROVIDER_RESPONSE_MAX_BYTES, - signal: request.signal, - }) - } catch (error) { - if (request.signal.aborted) throw error - logger.warn('Bitbucket repository response was not bounded JSON', { - workspaceSlug, - errorType: error instanceof Error ? error.name : 'unknown', - }) - return NextResponse.json( - { error: 'Bitbucket returned an invalid repository response.' }, - { status: 502 } - ) - } - - const expectedFullNamePrefix = workspaceSlug.toLowerCase() - const page = bitbucketRepositoryProviderPageSchema.safeParse(providerBody) - if ( - !page.success || - (page.data.next && !isBitbucketRepositoriesCursor(page.data.next, workspaceSlug)) || - page.data.values.some( - (repository) => !repository.full_name.toLowerCase().startsWith(`${expectedFullNamePrefix}/`) - ) - ) { - logger.warn('Bitbucket returned a malformed repository page', { workspaceSlug }) - return NextResponse.json( - { error: 'Bitbucket returned an invalid repository response.' }, - { status: 502 } - ) - } - - return NextResponse.json({ - repositories: page.data.values.map((repository) => ({ - slug: repository.slug, - uuid: repository.uuid, - name: repository.name ?? repository.slug, - fullName: repository.full_name, - })), - ...(page.data.next ? { nextCursor: page.data.next } : {}), - }) -}) diff --git a/apps/sim/app/api/tools/bitbucket/workspaces/route.test.ts b/apps/sim/app/api/tools/bitbucket/workspaces/route.test.ts deleted file mode 100644 index 4ad1f1daedf..00000000000 --- a/apps/sim/app/api/tools/bitbucket/workspaces/route.test.ts +++ /dev/null @@ -1,284 +0,0 @@ -/** - * @vitest-environment node - */ -import { NextRequest } from 'next/server' -import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' - -const { - mockAuthorizeCredentialUse, - mockCheckSessionOrInternalAuth, - mockFetch, - mockGetCredential, - mockRefreshAccessTokenIfNeeded, -} = vi.hoisted(() => ({ - mockAuthorizeCredentialUse: vi.fn(), - mockCheckSessionOrInternalAuth: vi.fn(), - mockFetch: vi.fn(), - mockGetCredential: vi.fn(), - mockRefreshAccessTokenIfNeeded: vi.fn(), -})) - -vi.mock('@/lib/auth/credential-access', () => ({ - authorizeCredentialUse: mockAuthorizeCredentialUse, -})) -vi.mock('@/lib/auth/hybrid', () => ({ - checkSessionOrInternalAuth: mockCheckSessionOrInternalAuth, -})) -vi.mock('@/lib/oauth/credential-service', () => ({ - getCredential: mockGetCredential, - refreshAccessTokenIfNeeded: mockRefreshAccessTokenIfNeeded, -})) - -import { POST } from '@/app/api/tools/bitbucket/workspaces/route' - -const URL = 'http://localhost:3000/api/tools/bitbucket/workspaces' -const FIRST_PAGE_URL = - 'https://api.bitbucket.org/2.0/user/workspaces?pagelen=100&fields=values.administrator%2Cvalues.workspace.slug%2Cvalues.workspace.uuid%2Cvalues.workspace.name%2Cnext' -const SECOND_PAGE_URL = 'https://api.bitbucket.org/2.0/user/workspaces?page=2&pagelen=100' -const REQUEST_BODY = { credential: 'credential-1', workflowId: 'workflow-1' } as const - -function request(body: unknown): NextRequest { - return new NextRequest(URL, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: typeof body === 'string' ? body : JSON.stringify(body), - }) -} - -function providerResponse(body: unknown, status = 200): Response { - return new Response(JSON.stringify(body), { - status, - headers: { 'content-type': 'application/json' }, - }) -} - -async function json(response: Response): Promise> { - return (await response.json()) as Record -} - -describe('POST /api/tools/bitbucket/workspaces', () => { - beforeEach(() => { - vi.clearAllMocks() - vi.stubGlobal('fetch', mockFetch) - mockCheckSessionOrInternalAuth.mockResolvedValue({ success: true, userId: 'caller-1' }) - mockAuthorizeCredentialUse.mockResolvedValue({ - ok: true, - credentialOwnerUserId: 'owner-1', - resolvedCredentialId: 'account-1', - credentialType: 'oauth', - }) - mockGetCredential.mockResolvedValue({ providerId: 'bitbucket' }) - mockRefreshAccessTokenIfNeeded.mockResolvedValue('server-only-token') - mockFetch.mockResolvedValue(providerResponse({ values: [] })) - }) - - afterAll(() => vi.unstubAllGlobals()) - - it('authenticates before attempting to parse an invalid body', async () => { - mockCheckSessionOrInternalAuth.mockResolvedValueOnce({ - success: false, - error: 'Authentication required', - }) - - const response = await POST(request('{not-json'), {}) - - expect(response.status).toBe(401) - expect(mockCheckSessionOrInternalAuth).toHaveBeenCalledWith(expect.any(NextRequest), { - requireWorkflowId: true, - }) - expect(mockAuthorizeCredentialUse).not.toHaveBeenCalled() - expect(mockFetch).not.toHaveBeenCalled() - }) - - it('authorizes the exact credential before resolving or refreshing it', async () => { - const response = await POST(request(REQUEST_BODY), {}) - - expect(response.status).toBe(200) - expect(mockAuthorizeCredentialUse).toHaveBeenCalledWith(expect.any(NextRequest), { - credentialId: 'credential-1', - workflowId: 'workflow-1', - callerUserId: 'caller-1', - }) - expect(mockGetCredential).toHaveBeenCalledWith(expect.any(String), 'account-1', 'owner-1') - expect(mockRefreshAccessTokenIfNeeded).toHaveBeenCalledWith( - 'account-1', - 'owner-1', - expect.any(String) - ) - }) - - it('fails closed when credential authorization is denied', async () => { - mockAuthorizeCredentialUse.mockResolvedValueOnce({ ok: false, error: 'Forbidden' }) - - const response = await POST(request(REQUEST_BODY), {}) - - expect(response.status).toBe(403) - expect(await json(response)).toMatchObject({ error: 'Forbidden' }) - expect(mockGetCredential).not.toHaveBeenCalled() - expect(mockRefreshAccessTokenIfNeeded).not.toHaveBeenCalled() - expect(mockFetch).not.toHaveBeenCalled() - }) - - it('does not send a credential for another provider to Bitbucket', async () => { - mockGetCredential.mockResolvedValueOnce({ providerId: 'github' }) - - const response = await POST(request(REQUEST_BODY), {}) - - expect(response.status).toBe(400) - expect(mockRefreshAccessTokenIfNeeded).not.toHaveBeenCalled() - expect(mockFetch).not.toHaveBeenCalled() - }) - - it('returns an auth-required response when token refresh cannot resolve a token', async () => { - mockRefreshAccessTokenIfNeeded.mockResolvedValueOnce(null) - - const response = await POST(request(REQUEST_BODY), {}) - - expect(response.status).toBe(401) - expect(await json(response)).toMatchObject({ authRequired: true }) - expect(mockFetch).not.toHaveBeenCalled() - }) - - it.each([ - ['plain HTTP', 'http://api.bitbucket.org/2.0/user/workspaces?page=2'], - ['lookalike host', 'https://api.bitbucket.org.evil.example/2.0/user/workspaces?page=2'], - ['non-default port', 'https://api.bitbucket.org:444/2.0/user/workspaces?page=2'], - ['wrong v2 endpoint', 'https://api.bitbucket.org/2.0/repositories/acme?page=2'], - ['embedded credentials', 'https://attacker:secret@api.bitbucket.org/2.0/user/workspaces'], - ])('rejects a %s cursor before resolving a bearer token', async (_label, cursor) => { - const response = await POST(request({ ...REQUEST_BODY, cursor }), {}) - - expect(response.status).toBe(400) - expect(mockAuthorizeCredentialUse).not.toHaveBeenCalled() - expect(mockRefreshAccessTokenIfNeeded).not.toHaveBeenCalled() - expect(mockFetch).not.toHaveBeenCalled() - }) - - it('normalizes a page and returns only the provider cursor, never the bearer token', async () => { - mockFetch.mockResolvedValueOnce( - providerResponse({ - values: [ - { - administrator: true, - workspace: { - slug: 'acme-platform', - uuid: '{workspace-uuid}', - name: 'Acme Platform', - links: { html: { href: 'https://bitbucket.org/acme-platform' } }, - }, - }, - ], - next: SECOND_PAGE_URL, - }) - ) - - const response = await POST(request(REQUEST_BODY), {}) - const body = await json(response) - - expect(response.status).toBe(200) - expect(mockFetch).toHaveBeenCalledWith( - FIRST_PAGE_URL, - expect.objectContaining({ method: 'GET', redirect: 'error' }) - ) - const init = mockFetch.mock.calls[0]?.[1] as RequestInit - expect(new Headers(init.headers).get('Authorization')).toBe('Bearer server-only-token') - expect(body).toEqual({ - workspaces: [ - { - slug: 'acme-platform', - uuid: '{workspace-uuid}', - name: 'Acme Platform', - administrator: true, - }, - ], - nextCursor: SECOND_PAGE_URL, - }) - expect(JSON.stringify(body)).not.toContain('server-only-token') - }) - - it('uses a validated provider cursor for exactly one progressive page', async () => { - const response = await POST(request({ ...REQUEST_BODY, cursor: SECOND_PAGE_URL }), {}) - - expect(response.status).toBe(200) - expect(mockFetch).toHaveBeenCalledTimes(1) - expect(mockFetch).toHaveBeenCalledWith(SECOND_PAGE_URL, expect.any(Object)) - }) - - it('uses the slug when the current workspace-access shape omits a display name', async () => { - mockFetch.mockResolvedValueOnce( - providerResponse({ - values: [ - { - administrator: false, - workspace: { slug: 'acme-platform', uuid: '{workspace-uuid}' }, - }, - ], - }) - ) - - const response = await POST(request(REQUEST_BODY), {}) - - expect(response.status).toBe(200) - expect(await json(response)).toEqual({ - workspaces: [ - { - slug: 'acme-platform', - uuid: '{workspace-uuid}', - name: 'acme-platform', - administrator: false, - }, - ], - }) - }) - - it.each([ - ['missing values', {}], - ['non-array values', { values: 'not-an-array' }], - ['malformed workspace', { values: [{ workspace: { slug: 'acme', uuid: 42, name: 'Acme' } }] }], - [ - 'oversized page', - { - values: Array.from({ length: 101 }, (_, index) => ({ - workspace: { - slug: `workspace-${index}`, - uuid: `{workspace-${index}}`, - name: `Workspace ${index}`, - }, - })), - }, - ], - ])('fails closed on a %s provider response', async (_label, providerBody) => { - mockFetch.mockResolvedValueOnce(providerResponse(providerBody)) - - const response = await POST(request(REQUEST_BODY), {}) - - expect(response.status).toBe(502) - expect(await json(response)).toEqual({ - error: 'Bitbucket returned an invalid workspace response.', - }) - }) - - it('fails closed on invalid provider JSON', async () => { - mockFetch.mockResolvedValueOnce(new Response('{not-json', { status: 200 })) - - const response = await POST(request(REQUEST_BODY), {}) - - expect(response.status).toBe(502) - }) - - it('rejects a provider next link that could redirect the bearer token elsewhere', async () => { - mockFetch.mockResolvedValueOnce( - providerResponse({ - values: [], - next: 'https://evil.example/2.0/user/workspaces?page=2', - }) - ) - - const response = await POST(request(REQUEST_BODY), {}) - - expect(response.status).toBe(502) - expect(await json(response)).toEqual({ - error: 'Bitbucket returned an invalid workspace response.', - }) - }) -}) diff --git a/apps/sim/app/api/tools/bitbucket/workspaces/route.ts b/apps/sim/app/api/tools/bitbucket/workspaces/route.ts deleted file mode 100644 index 1d8639bccb7..00000000000 --- a/apps/sim/app/api/tools/bitbucket/workspaces/route.ts +++ /dev/null @@ -1,165 +0,0 @@ -import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' -import { - BITBUCKET_SELECTOR_PAGE_SIZE, - bitbucketWorkspaceProviderPageSchema, - bitbucketWorkspacesSelectorContract, - isBitbucketWorkspacesCursor, -} from '@/lib/api/contracts/selectors/bitbucket' -import { parseRequest } from '@/lib/api/server' -import { authorizeCredentialUse } from '@/lib/auth/credential-access' -import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' -import { generateRequestId } from '@/lib/core/utils/request' -import { readResponseJsonWithLimit } from '@/lib/core/utils/stream-limits' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { getCredential, refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service' - -export const dynamic = 'force-dynamic' - -const logger = createLogger('BitbucketWorkspacesAPI') -const BITBUCKET_PROVIDER_ID = 'bitbucket' -const BITBUCKET_WORKSPACES_URL = 'https://api.bitbucket.org/2.0/user/workspaces' -const BITBUCKET_WORKSPACE_FIELDS = - 'values.administrator,values.workspace.slug,values.workspace.uuid,values.workspace.name,next' -const SELECTOR_REQUEST_MAX_BYTES = 8 * 1024 -const PROVIDER_RESPONSE_MAX_BYTES = 1024 * 1024 - -function bitbucketFailureResponse(status: number): NextResponse { - if (status === 401) { - return NextResponse.json( - { - error: 'Bitbucket rejected this credential. Reconnect it and try again.', - authRequired: true, - }, - { status: 401 } - ) - } - if (status === 403) { - return NextResponse.json( - { error: 'Bitbucket denied access to workspace discovery.' }, - { status: 403 } - ) - } - if (status === 429) { - return NextResponse.json( - { error: 'Bitbucket rate-limited workspace discovery. Try again shortly.' }, - { status: 429 } - ) - } - return NextResponse.json({ error: 'Bitbucket workspace discovery failed.' }, { status: 502 }) -} - -/** Lists one normalized page for the `bitbucket.workspaces` selector. */ -export const POST = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - - const caller = await checkSessionOrInternalAuth(request, { requireWorkflowId: true }) - if (!caller.success || !caller.userId) { - return NextResponse.json({ error: caller.error || 'Authentication required' }, { status: 401 }) - } - - const parsed = await parseRequest( - bitbucketWorkspacesSelectorContract, - request, - {}, - { - maxBodyBytes: SELECTOR_REQUEST_MAX_BYTES, - } - ) - if (!parsed.success) return parsed.response - const { credential, workflowId, cursor } = parsed.data.body - - const authorization = await authorizeCredentialUse(request, { - credentialId: credential, - workflowId, - callerUserId: caller.userId, - }) - if (!authorization.ok || !authorization.credentialOwnerUserId) { - return NextResponse.json({ error: authorization.error || 'Unauthorized' }, { status: 403 }) - } - - const resolvedCredentialId = authorization.resolvedCredentialId ?? credential - const storedCredential = await getCredential( - requestId, - resolvedCredentialId, - authorization.credentialOwnerUserId - ) - if (!storedCredential || storedCredential.providerId !== BITBUCKET_PROVIDER_ID) { - return NextResponse.json({ error: 'Select a Bitbucket OAuth credential.' }, { status: 400 }) - } - - const accessToken = await refreshAccessTokenIfNeeded( - resolvedCredentialId, - authorization.credentialOwnerUserId, - requestId - ) - if (!accessToken) { - return NextResponse.json( - { error: 'Could not retrieve a Bitbucket access token.', authRequired: true }, - { status: 401 } - ) - } - - const firstPage = new URL(BITBUCKET_WORKSPACES_URL) - firstPage.searchParams.set('pagelen', String(BITBUCKET_SELECTOR_PAGE_SIZE)) - firstPage.searchParams.set('fields', BITBUCKET_WORKSPACE_FIELDS) - const providerUrl = cursor ?? firstPage.toString() - - let response: Response - try { - response = await fetch(providerUrl, { - method: 'GET', - headers: { - Accept: 'application/json', - Authorization: `Bearer ${accessToken}`, - }, - redirect: 'error', - signal: request.signal, - }) - } catch (error) { - if (request.signal.aborted) throw error - logger.warn('Bitbucket workspace request failed', { - errorType: error instanceof Error ? error.name : 'unknown', - }) - return NextResponse.json({ error: 'Bitbucket workspace discovery failed.' }, { status: 502 }) - } - - if (!response.ok) return bitbucketFailureResponse(response.status) - - let providerBody: unknown - try { - providerBody = await readResponseJsonWithLimit(response, { - label: 'Bitbucket workspace response', - maxBytes: PROVIDER_RESPONSE_MAX_BYTES, - signal: request.signal, - }) - } catch (error) { - if (request.signal.aborted) throw error - logger.warn('Bitbucket workspace response was not bounded JSON', { - errorType: error instanceof Error ? error.name : 'unknown', - }) - return NextResponse.json( - { error: 'Bitbucket returned an invalid workspace response.' }, - { status: 502 } - ) - } - - const page = bitbucketWorkspaceProviderPageSchema.safeParse(providerBody) - if (!page.success || (page.data.next && !isBitbucketWorkspacesCursor(page.data.next))) { - logger.warn('Bitbucket returned a malformed workspace page') - return NextResponse.json( - { error: 'Bitbucket returned an invalid workspace response.' }, - { status: 502 } - ) - } - - return NextResponse.json({ - workspaces: page.data.values.map(({ administrator, workspace }) => ({ - slug: workspace.slug, - uuid: workspace.uuid, - name: workspace.name ?? workspace.slug, - administrator, - })), - ...(page.data.next ? { nextCursor: page.data.next } : {}), - }) -}) diff --git a/apps/sim/app/api/tools/calcom/event-types/route.ts b/apps/sim/app/api/tools/calcom/event-types/route.ts deleted file mode 100644 index 0bb4a9cd8d0..00000000000 --- a/apps/sim/app/api/tools/calcom/event-types/route.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' -import { calcomEventTypesSelectorContract } from '@/lib/api/contracts/selectors' -import { parseRequest } from '@/lib/api/server' -import { authorizeCredentialUse } from '@/lib/auth/credential-access' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service' - -const logger = createLogger('CalcomEventTypesAPI') - -export const dynamic = 'force-dynamic' - -interface CalcomEventType { - id: number - title: string - slug: string -} - -export const POST = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - try { - const parsed = await parseRequest(calcomEventTypesSelectorContract, request, {}) - if (!parsed.success) return parsed.response - const { credential, workflowId } = parsed.data.body - - const authz = await authorizeCredentialUse(request, { - credentialId: credential, - workflowId, - }) - if (!authz.ok || !authz.credentialOwnerUserId) { - return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) - } - - const accessToken = await refreshAccessTokenIfNeeded( - credential, - authz.credentialOwnerUserId, - requestId - ) - if (!accessToken) { - logger.error('Failed to get access token', { - credentialId: credential, - userId: authz.credentialOwnerUserId, - }) - return NextResponse.json( - { error: 'Could not retrieve access token', authRequired: true }, - { status: 401 } - ) - } - - const response = await fetch('https://api.cal.com/v2/event-types', { - headers: { - Authorization: `Bearer ${accessToken}`, - 'Content-Type': 'application/json', - 'cal-api-version': '2024-06-14', - }, - }) - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})) - logger.error('Failed to fetch Cal.com event types', { - status: response.status, - error: errorData, - }) - return NextResponse.json( - { error: 'Failed to fetch Cal.com event types', details: errorData }, - { status: response.status } - ) - } - - const data = (await response.json()) as { data?: CalcomEventType[] } - const eventTypes = (data.data || []).map((eventType) => ({ - id: String(eventType.id), - title: eventType.title, - slug: eventType.slug, - })) - - return NextResponse.json({ eventTypes }) - } catch (error) { - logger.error('Error processing Cal.com event types request:', error) - return NextResponse.json( - { error: 'Failed to retrieve Cal.com event types', details: (error as Error).message }, - { status: 500 } - ) - } -}) diff --git a/apps/sim/app/api/tools/calcom/schedules/route.ts b/apps/sim/app/api/tools/calcom/schedules/route.ts deleted file mode 100644 index 8ccb0700017..00000000000 --- a/apps/sim/app/api/tools/calcom/schedules/route.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' -import { calcomSchedulesSelectorContract } from '@/lib/api/contracts/selectors' -import { parseRequest } from '@/lib/api/server' -import { authorizeCredentialUse } from '@/lib/auth/credential-access' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service' - -const logger = createLogger('CalcomSchedulesAPI') - -export const dynamic = 'force-dynamic' - -interface CalcomSchedule { - id: number - name: string -} - -export const POST = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - try { - const parsed = await parseRequest(calcomSchedulesSelectorContract, request, {}) - if (!parsed.success) return parsed.response - const { credential, workflowId } = parsed.data.body - - const authz = await authorizeCredentialUse(request, { - credentialId: credential, - workflowId, - }) - if (!authz.ok || !authz.credentialOwnerUserId) { - return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) - } - - const accessToken = await refreshAccessTokenIfNeeded( - credential, - authz.credentialOwnerUserId, - requestId - ) - if (!accessToken) { - logger.error('Failed to get access token', { - credentialId: credential, - userId: authz.credentialOwnerUserId, - }) - return NextResponse.json( - { error: 'Could not retrieve access token', authRequired: true }, - { status: 401 } - ) - } - - const response = await fetch('https://api.cal.com/v2/schedules', { - headers: { - Authorization: `Bearer ${accessToken}`, - 'Content-Type': 'application/json', - 'cal-api-version': '2024-06-11', - }, - }) - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})) - logger.error('Failed to fetch Cal.com schedules', { - status: response.status, - error: errorData, - }) - return NextResponse.json( - { error: 'Failed to fetch Cal.com schedules', details: errorData }, - { status: response.status } - ) - } - - const data = (await response.json()) as { data?: CalcomSchedule[] } - const schedules = (data.data || []).map((schedule) => ({ - id: String(schedule.id), - name: schedule.name, - })) - - return NextResponse.json({ schedules }) - } catch (error) { - logger.error('Error processing Cal.com schedules request:', error) - return NextResponse.json( - { error: 'Failed to retrieve Cal.com schedules', details: (error as Error).message }, - { status: 500 } - ) - } -}) diff --git a/apps/sim/app/api/tools/clickup/folders/route.ts b/apps/sim/app/api/tools/clickup/folders/route.ts deleted file mode 100644 index fc9063ce7a6..00000000000 --- a/apps/sim/app/api/tools/clickup/folders/route.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' -import { clickupFoldersSelectorContract } from '@/lib/api/contracts/selectors' -import { parseRequest } from '@/lib/api/server' -import { authorizeCredentialUse } from '@/lib/auth/credential-access' -import { validateAlphanumericId } from '@/lib/core/security/input-validation' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service' -import { CLICKUP_API_BASE_URL, clickupAuthorizationHeader } from '@/tools/clickup/shared' - -const logger = createLogger('ClickUpFoldersAPI') - -export const dynamic = 'force-dynamic' - -interface ClickUpNamedResource { - id: string | number - name?: string -} - -export const POST = withRouteHandler(async (request: NextRequest) => { - try { - const requestId = generateRequestId() - const parsed = await parseRequest(clickupFoldersSelectorContract, request, {}) - if (!parsed.success) return parsed.response - const { credential, workflowId, spaceId } = parsed.data.body - - const spaceIdValidation = validateAlphanumericId(spaceId, 'spaceId') - if (!spaceIdValidation.isValid) { - logger.error('Invalid spaceId', { error: spaceIdValidation.error }) - return NextResponse.json({ error: spaceIdValidation.error }, { status: 400 }) - } - - const authz = await authorizeCredentialUse(request, { - credentialId: credential, - workflowId, - }) - if (!authz.ok || !authz.credentialOwnerUserId) { - return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) - } - - const accessToken = await refreshAccessTokenIfNeeded( - credential, - authz.credentialOwnerUserId, - requestId - ) - if (!accessToken) { - logger.error('Failed to get access token', { - credentialId: credential, - userId: authz.credentialOwnerUserId, - }) - return NextResponse.json( - { error: 'Could not retrieve access token', authRequired: true }, - { status: 401 } - ) - } - - const response = await fetch( - `${CLICKUP_API_BASE_URL}/space/${encodeURIComponent(spaceId)}/folder`, - { - headers: { - Authorization: clickupAuthorizationHeader(accessToken), - Accept: 'application/json', - }, - } - ) - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})) - logger.error('Failed to fetch ClickUp folders', { - status: response.status, - error: errorData, - }) - return NextResponse.json( - { error: 'Failed to fetch ClickUp folders' }, - { status: response.status } - ) - } - - const data = (await response.json().catch(() => ({}))) as { - folders?: ClickUpNamedResource[] - } - const folders = (Array.isArray(data.folders) ? data.folders : []).map((item) => ({ - id: String(item.id), - name: item.name || `Folder ${item.id}`, - })) - - return NextResponse.json({ folders }) - } catch (error) { - logger.error('Error fetching ClickUp folders', error) - return NextResponse.json({ error: 'Failed to fetch ClickUp folders' }, { status: 500 }) - } -}) diff --git a/apps/sim/app/api/tools/clickup/lists/route.ts b/apps/sim/app/api/tools/clickup/lists/route.ts deleted file mode 100644 index f77d7153b2b..00000000000 --- a/apps/sim/app/api/tools/clickup/lists/route.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' -import { clickupListsSelectorContract } from '@/lib/api/contracts/selectors' -import { parseRequest } from '@/lib/api/server' -import { authorizeCredentialUse } from '@/lib/auth/credential-access' -import { validateAlphanumericId } from '@/lib/core/security/input-validation' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service' -import { CLICKUP_API_BASE_URL, clickupAuthorizationHeader } from '@/tools/clickup/shared' - -const logger = createLogger('ClickUpListsAPI') - -export const dynamic = 'force-dynamic' - -interface ClickUpNamedResource { - id: string | number - name?: string -} - -export const POST = withRouteHandler(async (request: NextRequest) => { - try { - const requestId = generateRequestId() - const parsed = await parseRequest(clickupListsSelectorContract, request, {}) - if (!parsed.success) return parsed.response - const { credential, workflowId, folderId, spaceId } = parsed.data.body - - if (folderId?.trim()) { - const folderIdValidation = validateAlphanumericId(folderId.trim(), 'folderId') - if (!folderIdValidation.isValid) { - logger.error('Invalid folderId', { error: folderIdValidation.error }) - return NextResponse.json({ error: folderIdValidation.error }, { status: 400 }) - } - } - - if (spaceId?.trim()) { - const spaceIdValidation = validateAlphanumericId(spaceId.trim(), 'spaceId') - if (!spaceIdValidation.isValid) { - logger.error('Invalid spaceId', { error: spaceIdValidation.error }) - return NextResponse.json({ error: spaceIdValidation.error }, { status: 400 }) - } - } - - const authz = await authorizeCredentialUse(request, { - credentialId: credential, - workflowId, - }) - if (!authz.ok || !authz.credentialOwnerUserId) { - return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) - } - - const accessToken = await refreshAccessTokenIfNeeded( - credential, - authz.credentialOwnerUserId, - requestId - ) - if (!accessToken) { - logger.error('Failed to get access token', { - credentialId: credential, - userId: authz.credentialOwnerUserId, - }) - return NextResponse.json( - { error: 'Could not retrieve access token', authRequired: true }, - { status: 401 } - ) - } - - const response = await fetch( - folderId?.trim() - ? `${CLICKUP_API_BASE_URL}/folder/${encodeURIComponent(folderId.trim())}/list` - : `${CLICKUP_API_BASE_URL}/space/${encodeURIComponent((spaceId ?? '').trim())}/list`, - { - headers: { - Authorization: clickupAuthorizationHeader(accessToken), - Accept: 'application/json', - }, - } - ) - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})) - logger.error('Failed to fetch ClickUp lists', { - status: response.status, - error: errorData, - }) - return NextResponse.json( - { error: 'Failed to fetch ClickUp lists' }, - { status: response.status } - ) - } - - const data = (await response.json().catch(() => ({}))) as { - lists?: ClickUpNamedResource[] - } - const lists = (Array.isArray(data.lists) ? data.lists : []).map((item) => ({ - id: String(item.id), - name: item.name || `List ${item.id}`, - })) - - return NextResponse.json({ lists }) - } catch (error) { - logger.error('Error fetching ClickUp lists', error) - return NextResponse.json({ error: 'Failed to fetch ClickUp lists' }, { status: 500 }) - } -}) diff --git a/apps/sim/app/api/tools/clickup/spaces/route.ts b/apps/sim/app/api/tools/clickup/spaces/route.ts deleted file mode 100644 index 2f8d6ca5932..00000000000 --- a/apps/sim/app/api/tools/clickup/spaces/route.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' -import { clickupSpacesSelectorContract } from '@/lib/api/contracts/selectors' -import { parseRequest } from '@/lib/api/server' -import { authorizeCredentialUse } from '@/lib/auth/credential-access' -import { validateAlphanumericId } from '@/lib/core/security/input-validation' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service' -import { CLICKUP_API_BASE_URL, clickupAuthorizationHeader } from '@/tools/clickup/shared' - -const logger = createLogger('ClickUpSpacesAPI') - -export const dynamic = 'force-dynamic' - -interface ClickUpNamedResource { - id: string | number - name?: string -} - -export const POST = withRouteHandler(async (request: NextRequest) => { - try { - const requestId = generateRequestId() - const parsed = await parseRequest(clickupSpacesSelectorContract, request, {}) - if (!parsed.success) return parsed.response - const { credential, workflowId, teamId } = parsed.data.body - - const teamIdValidation = validateAlphanumericId(teamId, 'teamId') - if (!teamIdValidation.isValid) { - logger.error('Invalid teamId', { error: teamIdValidation.error }) - return NextResponse.json({ error: teamIdValidation.error }, { status: 400 }) - } - - const authz = await authorizeCredentialUse(request, { - credentialId: credential, - workflowId, - }) - if (!authz.ok || !authz.credentialOwnerUserId) { - return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) - } - - const accessToken = await refreshAccessTokenIfNeeded( - credential, - authz.credentialOwnerUserId, - requestId - ) - if (!accessToken) { - logger.error('Failed to get access token', { - credentialId: credential, - userId: authz.credentialOwnerUserId, - }) - return NextResponse.json( - { error: 'Could not retrieve access token', authRequired: true }, - { status: 401 } - ) - } - - const response = await fetch( - `${CLICKUP_API_BASE_URL}/team/${encodeURIComponent(teamId)}/space`, - { - headers: { - Authorization: clickupAuthorizationHeader(accessToken), - Accept: 'application/json', - }, - } - ) - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})) - logger.error('Failed to fetch ClickUp spaces', { - status: response.status, - error: errorData, - }) - return NextResponse.json( - { error: 'Failed to fetch ClickUp spaces' }, - { status: response.status } - ) - } - - const data = (await response.json().catch(() => ({}))) as { - spaces?: ClickUpNamedResource[] - } - const spaces = (Array.isArray(data.spaces) ? data.spaces : []).map((item) => ({ - id: String(item.id), - name: item.name || `Space ${item.id}`, - })) - - return NextResponse.json({ spaces }) - } catch (error) { - logger.error('Error fetching ClickUp spaces', error) - return NextResponse.json({ error: 'Failed to fetch ClickUp spaces' }, { status: 500 }) - } -}) diff --git a/apps/sim/app/api/tools/clickup/workspaces/route.ts b/apps/sim/app/api/tools/clickup/workspaces/route.ts deleted file mode 100644 index 9c2990a18f7..00000000000 --- a/apps/sim/app/api/tools/clickup/workspaces/route.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' -import { clickupWorkspacesSelectorContract } from '@/lib/api/contracts/selectors' -import { parseRequest } from '@/lib/api/server' -import { authorizeCredentialUse } from '@/lib/auth/credential-access' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service' -import { CLICKUP_API_BASE_URL, clickupAuthorizationHeader } from '@/tools/clickup/shared' - -const logger = createLogger('ClickUpWorkspacesAPI') - -export const dynamic = 'force-dynamic' - -interface ClickUpTeam { - id: string | number - name?: string -} - -export const POST = withRouteHandler(async (request: NextRequest) => { - try { - const requestId = generateRequestId() - const parsed = await parseRequest(clickupWorkspacesSelectorContract, request, {}) - if (!parsed.success) return parsed.response - const { credential, workflowId } = parsed.data.body - - const authz = await authorizeCredentialUse(request, { - credentialId: credential, - workflowId, - }) - if (!authz.ok || !authz.credentialOwnerUserId) { - return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) - } - - const accessToken = await refreshAccessTokenIfNeeded( - credential, - authz.credentialOwnerUserId, - requestId - ) - if (!accessToken) { - logger.error('Failed to get access token', { - credentialId: credential, - userId: authz.credentialOwnerUserId, - }) - return NextResponse.json( - { error: 'Could not retrieve access token', authRequired: true }, - { status: 401 } - ) - } - - const response = await fetch(`${CLICKUP_API_BASE_URL}/team`, { - headers: { - Authorization: clickupAuthorizationHeader(accessToken), - Accept: 'application/json', - }, - }) - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})) - logger.error('Failed to fetch ClickUp workspaces', { - status: response.status, - error: errorData, - }) - return NextResponse.json( - { error: 'Failed to fetch ClickUp workspaces' }, - { status: response.status } - ) - } - - const data = (await response.json().catch(() => ({}))) as { teams?: ClickUpTeam[] } - const workspaces = (Array.isArray(data.teams) ? data.teams : []).map((team) => ({ - id: String(team.id), - name: team.name || `Workspace ${team.id}`, - })) - - return NextResponse.json({ workspaces }) - } catch (error) { - logger.error('Error fetching ClickUp workspaces', error) - return NextResponse.json({ error: 'Failed to fetch ClickUp workspaces' }, { status: 500 }) - } -}) diff --git a/apps/sim/app/api/tools/cloudwatch/describe-log-groups/route.ts b/apps/sim/app/api/tools/cloudwatch/describe-log-groups/route.ts index 4a2aabea1c9..1ec2be6c7f1 100644 --- a/apps/sim/app/api/tools/cloudwatch/describe-log-groups/route.ts +++ b/apps/sim/app/api/tools/cloudwatch/describe-log-groups/route.ts @@ -1,4 +1,3 @@ -import { DescribeLogGroupsCommand } from '@aws-sdk/client-cloudwatch-logs' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' @@ -6,16 +5,10 @@ import { cloudwatchLogGroupsSelectorContract } from '@/lib/api/contracts/selecto import { parseToolRequest } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { createCloudWatchLogsClient } from '@/app/api/tools/cloudwatch/utils' +import { listCloudWatchLogGroups } from '@/tools/cloudwatch/listing' const logger = createLogger('CloudWatchDescribeLogGroups') -/** AWS DescribeLogGroups caps `limit` at 50 items per page. */ -const LOG_GROUPS_PAGE_SIZE = 50 - -/** Upper bound on pages drained to avoid unbounded loops on very large accounts. */ -const MAX_LOG_GROUPS_PAGES = 20 - export const POST = withRouteHandler(async (request: NextRequest) => { try { const auth = await checkSessionOrInternalAuth(request) @@ -32,69 +25,19 @@ export const POST = withRouteHandler(async (request: NextRequest) => { logger.info('Describing CloudWatch log groups') - const client = createCloudWatchLogsClient({ - region: validatedData.region, - accessKeyId: validatedData.accessKeyId, - secretAccessKey: validatedData.secretAccessKey, + const logGroups = await listCloudWatchLogGroups({ + credentials: { + region: validatedData.region, + accessKeyId: validatedData.accessKeyId, + secretAccessKey: validatedData.secretAccessKey, + }, + prefix: validatedData.prefix, + limit: validatedData.limit, + signal: request.signal, }) - try { - const totalLimit = validatedData.limit - const logGroups: { - logGroupName: string - arn: string - storedBytes: number - retentionInDays: number | undefined - creationTime: number | undefined - }[] = [] - let nextToken: string | undefined - - for (let page = 0; page < MAX_LOG_GROUPS_PAGES; page++) { - const pageLimit = - totalLimit !== undefined - ? Math.min(LOG_GROUPS_PAGE_SIZE, totalLimit - logGroups.length) - : LOG_GROUPS_PAGE_SIZE - - const command = new DescribeLogGroupsCommand({ - ...(validatedData.prefix && { logGroupNamePrefix: validatedData.prefix }), - limit: pageLimit, - ...(nextToken && { nextToken }), - }) - - const response = await client.send(command) - - for (const lg of response.logGroups ?? []) { - logGroups.push({ - logGroupName: lg.logGroupName ?? '', - arn: lg.arn ?? '', - storedBytes: lg.storedBytes ?? 0, - retentionInDays: lg.retentionInDays, - creationTime: lg.creationTime, - }) - } - - nextToken = response.nextToken - if (!nextToken) break - if (totalLimit !== undefined && logGroups.length >= totalLimit) break - - if (page === MAX_LOG_GROUPS_PAGES - 1) { - logger.warn( - `DescribeLogGroups hit pagination cap of ${MAX_LOG_GROUPS_PAGES} pages; log group list may be incomplete` - ) - } - } - - const cappedLogGroups = totalLimit !== undefined ? logGroups.slice(0, totalLimit) : logGroups - - logger.info(`Successfully described ${cappedLogGroups.length} log groups`) - - return NextResponse.json({ - success: true, - output: { logGroups: cappedLogGroups }, - }) - } finally { - client.destroy() - } + logger.info(`Successfully described ${logGroups.length} log groups`) + return NextResponse.json({ success: true, output: { logGroups } }) } catch (error) { logger.error('DescribeLogGroups failed', { error: toError(error).message }) return NextResponse.json( diff --git a/apps/sim/app/api/tools/cloudwatch/describe-log-streams/route.ts b/apps/sim/app/api/tools/cloudwatch/describe-log-streams/route.ts index 8a8a5617797..a6fc98da09f 100644 --- a/apps/sim/app/api/tools/cloudwatch/describe-log-streams/route.ts +++ b/apps/sim/app/api/tools/cloudwatch/describe-log-streams/route.ts @@ -5,7 +5,7 @@ import { cloudwatchLogStreamsSelectorContract } from '@/lib/api/contracts/select import { parseToolRequest } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { createCloudWatchLogsClient, describeLogStreams } from '@/app/api/tools/cloudwatch/utils' +import { listCloudWatchLogStreams } from '@/tools/cloudwatch/listing' const logger = createLogger('CloudWatchDescribeLogStreams') @@ -25,27 +25,20 @@ export const POST = withRouteHandler(async (request: NextRequest) => { logger.info(`Describing log streams for group: ${validatedData.logGroupName}`) - const client = createCloudWatchLogsClient({ - region: validatedData.region, - accessKeyId: validatedData.accessKeyId, - secretAccessKey: validatedData.secretAccessKey, + const logStreams = await listCloudWatchLogStreams({ + credentials: { + region: validatedData.region, + accessKeyId: validatedData.accessKeyId, + secretAccessKey: validatedData.secretAccessKey, + }, + logGroupName: validatedData.logGroupName, + prefix: validatedData.prefix, + limit: validatedData.limit, + signal: request.signal, }) - try { - const result = await describeLogStreams(client, validatedData.logGroupName, { - prefix: validatedData.prefix, - limit: validatedData.limit, - }) - - logger.info(`Successfully described ${result.logStreams.length} log streams`) - - return NextResponse.json({ - success: true, - output: { logStreams: result.logStreams }, - }) - } finally { - client.destroy() - } + logger.info(`Successfully described ${logStreams.length} log streams`) + return NextResponse.json({ success: true, output: { logStreams } }) } catch (error) { logger.error('DescribeLogStreams failed', { error: toError(error).message }) return NextResponse.json( diff --git a/apps/sim/app/api/tools/confluence/page/route.ts b/apps/sim/app/api/tools/confluence/page/route.ts index 5adfe362bc4..7241c826f6f 100644 --- a/apps/sim/app/api/tools/confluence/page/route.ts +++ b/apps/sim/app/api/tools/confluence/page/route.ts @@ -9,6 +9,7 @@ import { parseRequest } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { validateJiraCloudId } from '@/lib/core/security/input-validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { fetchConfluencePage } from '@/tools/confluence/client' import { getConfluenceCloudId } from '@/tools/confluence/utils' import { parseAtlassianErrorMessage } from '@/tools/jira/utils' @@ -35,14 +36,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 }) } - const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/pages/${pageId}?body-format=storage` - - const response = await fetch(url, { - method: 'GET', - headers: { - Accept: 'application/json', - Authorization: `Bearer ${accessToken}`, - }, + const response = await fetchConfluencePage({ + cloudId, + pageId, + accessToken, }) if (!response.ok) { diff --git a/apps/sim/app/api/tools/confluence/pages/route.ts b/apps/sim/app/api/tools/confluence/pages/route.ts deleted file mode 100644 index 7d470190c42..00000000000 --- a/apps/sim/app/api/tools/confluence/pages/route.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' -import { confluencePagesSelectorContract } from '@/lib/api/contracts/selectors/confluence' -import { parseRequest } from '@/lib/api/server' -import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' -import { validateJiraCloudId } from '@/lib/core/security/input-validation' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { getConfluenceCloudId } from '@/tools/confluence/utils' -import { parseAtlassianErrorMessage } from '@/tools/jira/utils' - -const logger = createLogger('ConfluencePagesAPI') - -export const dynamic = 'force-dynamic' - -// List pages or search pages -export const POST = withRouteHandler(async (request: NextRequest) => { - try { - const auth = await checkSessionOrInternalAuth(request) - if (!auth.success || !auth.userId) { - return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) - } - - const parsed = await parseRequest(confluencePagesSelectorContract, request, {}) - if (!parsed.success) return parsed.response - - const { domain, accessToken, title, cloudId: providedCloudId, limit } = parsed.data.body - - const cloudId = providedCloudId || (await getConfluenceCloudId(domain, accessToken)) - - const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId') - if (!cloudIdValidation.isValid) { - return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 }) - } - - const baseUrl = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/pages` - const queryParams = new URLSearchParams() - - if (limit) { - queryParams.append('limit', limit.toString()) - } - - if (title) { - queryParams.append('title', title) - } - - const queryString = queryParams.toString() - const url = queryString ? `${baseUrl}?${queryString}` : baseUrl - - logger.info(`Fetching Confluence pages from: ${url}`) - - const response = await fetch(url, { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${accessToken}`, - }, - }) - - logger.info('Response status:', response.status, response.statusText) - - if (!response.ok) { - const errorText = await response.text() - logger.error('Confluence API error response:', { - status: response.status, - statusText: response.statusText, - error: errorText, - }) - return NextResponse.json( - { error: parseAtlassianErrorMessage(response.status, response.statusText, errorText) }, - { status: response.status } - ) - } - - const data = await response.json() - logger.info('Confluence API response:', `${JSON.stringify(data, null, 2).substring(0, 300)}...`) - logger.info(`Found ${data.results?.length || 0} pages`) - - if (data.results && data.results.length > 0) { - logger.info('First few pages:') - for (const page of data.results.slice(0, 3)) { - logger.info(`- ${page.id}: ${page.title}`) - } - } - - return NextResponse.json({ - files: data.results.map((page: any) => ({ - id: page.id, - name: page.title, - mimeType: 'confluence/page', - url: page._links?.webui || '', - modifiedTime: page.version?.createdAt || '', - spaceId: page.spaceId, - webViewLink: page._links?.webui || '', - })), - }) - } catch (error) { - logger.error('Error fetching Confluence pages:', error) - return NextResponse.json( - { error: (error as Error).message || 'Internal server error' }, - { status: 500 } - ) - } -}) diff --git a/apps/sim/app/api/tools/confluence/selector-spaces/route.ts b/apps/sim/app/api/tools/confluence/selector-spaces/route.ts deleted file mode 100644 index ca7431294c5..00000000000 --- a/apps/sim/app/api/tools/confluence/selector-spaces/route.ts +++ /dev/null @@ -1,215 +0,0 @@ -import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' -import { confluenceSpacesSelectorContract } from '@/lib/api/contracts/selectors/confluence' -import { parseRequest } from '@/lib/api/server' -import { authorizeCredentialUse } from '@/lib/auth/credential-access' -import { validateJiraCloudId } from '@/lib/core/security/input-validation' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { - getAtlassianServiceAccountSecret, - refreshAccessTokenIfNeeded, - resolveOAuthAccountId, -} from '@/lib/oauth/credential-service' -import { ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID } from '@/lib/oauth/types' -import { getConfluenceCloudId } from '@/tools/confluence/utils' -import { parseAtlassianErrorMessage } from '@/tools/jira/utils' - -const logger = createLogger('ConfluenceSelectorSpacesAPI') - -export const dynamic = 'force-dynamic' - -const PAGE_LIMIT = 250 - -type SpaceStatus = 'current' | 'archived' - -/** A row as Confluence returns it. `status` is not marked required in the v2 schema. */ -interface SpaceRow { - id: string - name: string - key: string - status?: SpaceStatus -} - -interface SpacesResponse { - results?: SpaceRow[] - _links?: { next?: string } -} - -/** A row as this selector emits it, with `status` always resolved. */ -interface SelectorSpace { - id: string - name: string - key: string - status: SpaceStatus -} - -/** - * Cursor format: `:`. Empty inner cursor means "first page - * of that status". When current is exhausted we hand back `archived:` so the - * client transparently flips to the archived stream — listing both surfaces - * archived spaces in the dropdown, which would otherwise only be reachable by - * typing the space key manually even though sync works against archived spaces. - */ -function parseCursor(raw: string | undefined): { status: SpaceStatus; inner?: string } { - if (!raw) return { status: 'current' } - const idx = raw.indexOf(':') - if (idx === -1) return { status: 'current' } - const status = raw.slice(0, idx) === 'archived' ? 'archived' : 'current' - const inner = raw.slice(idx + 1) - return { status, inner: inner || undefined } -} - -export const POST = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - try { - const parsed = await parseRequest(confluenceSpacesSelectorContract, request, {}) - if (!parsed.success) return parsed.response - - const { credential, workflowId, domain, cursor, spaceKey } = parsed.data.body - - if (!credential) { - logger.error('Missing credential in request') - return NextResponse.json({ error: 'Credential is required' }, { status: 400 }) - } - - if (!domain) { - return NextResponse.json({ error: 'Domain is required' }, { status: 400 }) - } - - const authz = await authorizeCredentialUse(request, { - credentialId: credential, - workflowId, - }) - if (!authz.ok || !authz.credentialOwnerUserId) { - return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) - } - - const resolved = await resolveOAuthAccountId(credential) - const isAtlassianServiceAccount = - resolved?.providerId === ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID && !!resolved.credentialId - - let accessToken: string | null - let cloudId: string - if (isAtlassianServiceAccount) { - const secret = await getAtlassianServiceAccountSecret(resolved.credentialId!) - accessToken = secret.apiToken - cloudId = secret.cloudId - } else { - accessToken = await refreshAccessTokenIfNeeded( - credential, - authz.credentialOwnerUserId, - requestId - ) - if (!accessToken) { - logger.error('Failed to get access token', { - credentialId: credential, - userId: authz.credentialOwnerUserId, - }) - return NextResponse.json( - { error: 'Could not retrieve access token', authRequired: true }, - { status: 401 } - ) - } - cloudId = await getConfluenceCloudId(domain, accessToken) - } - - const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId') - if (!cloudIdValidation.isValid) { - return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 }) - } - - const baseUrl = `https://api.atlassian.com/ex/confluence/${cloudIdValidation.sanitized}/wiki/api/v2/spaces` - const { status, inner } = parseCursor(cursor) - - const requestSpaces = async ( - search: URLSearchParams - ): Promise<{ ok: true; data: SpacesResponse } | { ok: false; response: NextResponse }> => { - const response = await fetch(`${baseUrl}?${search.toString()}`, { - method: 'GET', - headers: { Accept: 'application/json', Authorization: `Bearer ${accessToken}` }, - }) - - if (!response.ok) { - const errorText = await response.text() - const message = parseAtlassianErrorMessage(response.status, response.statusText, errorText) - logger.error('Confluence API error response', { error: message, status: response.status }) - return { ok: false, response: NextResponse.json({ error: message }, { status: 502 }) } - } - - return { ok: true, data: await response.json() } - } - - const toSpaces = (rows: SpaceRow[] | undefined, queried: SpaceStatus): SelectorSpace[] => - (rows ?? []).map((space) => ({ - id: space.id, - name: space.name, - key: space.key, - // Trust the row's own status; fall back to the status this call asked for. - status: space.status ?? queried, - })) - - if (spaceKey) { - /** - * Exact-key lookup, bypassing the paged drain the dropdown otherwise depends - * on. Both statuses are queried explicitly rather than omitting `status`: it - * takes a single value on this endpoint (unlike `/pages`, where it is an array - * with a documented `current,archived` default) and `/spaces` documents no - * default, while archived spaces are reachable in the paged path and sync works - * against them. Concurrent because the key is user-typed text, so a miss — which - * dominates while typing — would otherwise pay two round-trips. - */ - const [current, archived] = await Promise.all([ - requestSpaces( - new URLSearchParams({ keys: spaceKey, limit: String(PAGE_LIMIT), status: 'current' }) - ), - requestSpaces( - new URLSearchParams({ keys: spaceKey, limit: String(PAGE_LIMIT), status: 'archived' }) - ), - ]) - // Only a total failure is fatal: one leg erroring must not discard a match - // the other leg found. - if (!current.ok && !archived.ok) return current.response - - // A single resolution, never a page in a drained stream, so no cursor. - return NextResponse.json({ - spaces: [ - ...(current.ok ? toSpaces(current.data.results, 'current') : []), - ...(archived.ok ? toSpaces(archived.data.results, 'archived') : []), - ], - nextCursor: undefined, - }) - } - - const params = new URLSearchParams({ limit: String(PAGE_LIMIT), status }) - if (inner) params.set('cursor', inner) - const result = await requestSpaces(params) - if (!result.ok) return result.response - const data = result.data - - let nextInner: string | undefined - const nextLink = data._links?.next - if (nextLink) { - try { - nextInner = new URL(nextLink, 'https://placeholder').searchParams.get('cursor') || undefined - } catch { - nextInner = undefined - } - } - - let nextCursor: string | undefined - if (nextInner) { - nextCursor = `${status}:${nextInner}` - } else if (status === 'current') { - nextCursor = 'archived:' - } - - return NextResponse.json({ spaces: toSpaces(data.results, status), nextCursor }) - } catch (error) { - logger.error('Error listing Confluence spaces:', error) - return NextResponse.json( - { error: (error as Error).message || 'Internal server error' }, - { status: 500 } - ) - } -}) diff --git a/apps/sim/app/api/tools/drive/file/route.ts b/apps/sim/app/api/tools/drive/file/route.ts deleted file mode 100644 index 69e569564cf..00000000000 --- a/apps/sim/app/api/tools/drive/file/route.ts +++ /dev/null @@ -1,186 +0,0 @@ -import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' -import { googleDriveFileSelectorContract } from '@/lib/api/contracts/selectors/google' -import { parseRequest } from '@/lib/api/server' -import { authorizeCredentialUse } from '@/lib/auth/credential-access' -import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' -import { validateAlphanumericId } from '@/lib/core/security/input-validation' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { - refreshAccessTokenIfNeeded, - ServiceAccountTokenError, -} from '@/lib/oauth/credential-service' -import { getScopesForService } from '@/lib/oauth/utils' -export const dynamic = 'force-dynamic' - -const logger = createLogger('GoogleDriveFileAPI') - -/** - * Get a single file from Google Drive - */ -export const GET = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - logger.info(`[${requestId}] Google Drive file request received`) - - const auth = await checkSessionOrInternalAuth(request) - if (!auth.success || !auth.userId) { - return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) - } - - try { - const parsed = await parseRequest( - googleDriveFileSelectorContract, - request, - {}, - { - validationErrorResponse: () => { - logger.warn(`[${requestId}] Missing required parameters`) - return NextResponse.json( - { error: 'Credential ID and File ID are required' }, - { status: 400 } - ) - }, - } - ) - if (!parsed.success) return parsed.response - - const { credentialId, fileId } = parsed.data.query - const workflowId = parsed.data.query.workflowId || undefined - const impersonateEmail = parsed.data.query.impersonateEmail || undefined - - const fileIdValidation = validateAlphanumericId(fileId, 'fileId', 255) - if (!fileIdValidation.isValid) { - logger.warn(`[${requestId}] Invalid file ID: ${fileIdValidation.error}`) - return NextResponse.json({ error: fileIdValidation.error }, { status: 400 }) - } - - const authz = await authorizeCredentialUse(request, { credentialId, workflowId }) - if (!authz.ok || !authz.credentialOwnerUserId) { - return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) - } - - const accessToken = await refreshAccessTokenIfNeeded( - credentialId, - authz.credentialOwnerUserId, - requestId, - getScopesForService('google-drive'), - impersonateEmail - ) - - if (!accessToken) { - return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 }) - } - - logger.info(`[${requestId}] Fetching file ${fileId} from Google Drive API`) - const response = await fetch( - `https://www.googleapis.com/drive/v3/files/${fileId}?fields=id,name,mimeType,iconLink,webViewLink,thumbnailLink,createdTime,modifiedTime,size,owners,exportLinks,shortcutDetails&supportsAllDrives=true`, - { - headers: { - Authorization: `Bearer ${accessToken}`, - }, - } - ) - - if (!response.ok && response.status === 404) { - logger.info(`[${requestId}] File not found, checking if it's a shared drive`) - const driveResponse = await fetch( - `https://www.googleapis.com/drive/v3/drives/${fileId}?fields=id,name`, - { - headers: { - Authorization: `Bearer ${accessToken}`, - }, - } - ) - - if (driveResponse.ok) { - const driveData = await driveResponse.json() - logger.info(`[${requestId}] Found shared drive: ${driveData.name}`) - return NextResponse.json( - { - file: { - id: driveData.id, - name: driveData.name, - mimeType: 'application/vnd.google-apps.folder', - iconLink: - 'https://ssl.gstatic.com/docs/doclist/images/icon_11_shared_collection_list_1.png', - }, - }, - { status: 200 } - ) - } - } - - if (!response.ok) { - const errorData = await response.json().catch(() => ({ error: { message: 'Unknown error' } })) - logger.error(`[${requestId}] Google Drive API error`, { - status: response.status, - error: errorData.error?.message || 'Failed to fetch file from Google Drive', - }) - return NextResponse.json( - { - error: errorData.error?.message || 'Failed to fetch file from Google Drive', - }, - { status: response.status } - ) - } - - const file = await response.json() - - const exportFormats: { [key: string]: string } = { - 'application/vnd.google-apps.document': 'application/pdf', - 'application/vnd.google-apps.spreadsheet': - 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - 'application/vnd.google-apps.presentation': 'application/pdf', - } - - if ( - file.mimeType === 'application/vnd.google-apps.shortcut' && - file.shortcutDetails?.targetId - ) { - const targetId = file.shortcutDetails.targetId - const shortcutResp = await fetch( - `https://www.googleapis.com/drive/v3/files/${targetId}?fields=id,name,mimeType,iconLink,webViewLink,thumbnailLink,createdTime,modifiedTime,size,owners,exportLinks&supportsAllDrives=true`, - { - headers: { Authorization: `Bearer ${accessToken}` }, - } - ) - if (shortcutResp.ok) { - const targetFile = await shortcutResp.json() - file.id = targetFile.id - file.name = targetFile.name - file.mimeType = targetFile.mimeType - file.iconLink = targetFile.iconLink - file.webViewLink = targetFile.webViewLink - file.thumbnailLink = targetFile.thumbnailLink - file.createdTime = targetFile.createdTime - file.modifiedTime = targetFile.modifiedTime - file.size = targetFile.size - file.owners = targetFile.owners - file.exportLinks = targetFile.exportLinks - } - } - - if (file.mimeType.startsWith('application/vnd.google-apps.')) { - const format = exportFormats[file.mimeType] || 'application/pdf' - if (!file.exportLinks) { - file.downloadUrl = `https://www.googleapis.com/drive/v3/files/${file.id}/export?mimeType=${encodeURIComponent( - format - )}&supportsAllDrives=true` - } else { - file.downloadUrl = file.exportLinks[format] - } - } else { - file.downloadUrl = `https://www.googleapis.com/drive/v3/files/${file.id}?alt=media&supportsAllDrives=true` - } - - return NextResponse.json({ file }, { status: 200 }) - } catch (error) { - if (error instanceof ServiceAccountTokenError) { - logger.warn(`[${requestId}] Service account token error`, { message: error.message }) - return NextResponse.json({ error: error.message }, { status: 400 }) - } - logger.error(`[${requestId}] Error fetching file from Google Drive`, error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } -}) diff --git a/apps/sim/app/api/tools/drive/files/route.ts b/apps/sim/app/api/tools/drive/files/route.ts deleted file mode 100644 index c3bdd3c6911..00000000000 --- a/apps/sim/app/api/tools/drive/files/route.ts +++ /dev/null @@ -1,232 +0,0 @@ -import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' -import { googleDriveFilesSelectorContract } from '@/lib/api/contracts/selectors/google' -import { parseRequest } from '@/lib/api/server' -import { authorizeCredentialUse } from '@/lib/auth/credential-access' -import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' -import { validateAlphanumericId } from '@/lib/core/security/input-validation' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { - refreshAccessTokenIfNeeded, - ServiceAccountTokenError, -} from '@/lib/oauth/credential-service' -import { drainGooglePagedList, GooglePageError } from '@/lib/oauth/google-pagination' -import { getScopesForService } from '@/lib/oauth/utils' -export const dynamic = 'force-dynamic' - -const logger = createLogger('GoogleDriveFilesAPI') - -const MAX_DRIVE_FILE_PAGES = 20 -const DRIVE_FILE_PAGE_SIZE = 100 - -interface DriveFilesResponse { - files?: DriveFile[] - nextPageToken?: string -} - -function escapeForDriveQuery(value: string): string { - return value.replace(/\\/g, '\\\\').replace(/'/g, "\\'") -} - -interface SharedDrive { - id: string - name: string - kind: string -} - -interface DriveFile { - id: string - name: string - mimeType: string - iconLink?: string - webViewLink?: string - thumbnailLink?: string - createdTime?: string - modifiedTime?: string - size?: string - owners?: unknown[] - parents?: string[] -} - -/** - * Fetches shared drives the user has access to - */ -async function fetchSharedDrives(accessToken: string, requestId: string): Promise { - try { - const response = await fetch( - 'https://www.googleapis.com/drive/v3/drives?pageSize=100&fields=drives(id,name)', - { - headers: { - Authorization: `Bearer ${accessToken}`, - }, - } - ) - - if (!response.ok) { - logger.warn(`[${requestId}] Failed to fetch shared drives`, { - status: response.status, - }) - return [] - } - - const data = await response.json() - const drives: SharedDrive[] = data.drives || [] - - return drives.map((drive) => ({ - id: drive.id, - name: drive.name, - mimeType: 'application/vnd.google-apps.folder', - iconLink: 'https://ssl.gstatic.com/docs/doclist/images/icon_11_shared_collection_list_1.png', - })) - } catch (error) { - logger.error(`[${requestId}] Error fetching shared drives`, error) - return [] - } -} - -export const GET = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - logger.info(`[${requestId}] Google Drive files request received`) - - const auth = await checkSessionOrInternalAuth(request) - if (!auth.success || !auth.userId) { - return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) - } - - try { - const parsed = await parseRequest( - googleDriveFilesSelectorContract, - request, - {}, - { - validationErrorResponse: () => { - logger.warn(`[${requestId}] Missing credential ID`) - return NextResponse.json({ error: 'Credential ID is required' }, { status: 400 }) - }, - } - ) - if (!parsed.success) return parsed.response - - const { credentialId, mimeType } = parsed.data.query - const query = parsed.data.query.query || '' - const folderId = parsed.data.query.folderId || parsed.data.query.parentId || '' - const workflowId = parsed.data.query.workflowId || undefined - const impersonateEmail = parsed.data.query.impersonateEmail || undefined - - const authz = await authorizeCredentialUse(request, { credentialId, workflowId }) - if (!authz.ok || !authz.credentialOwnerUserId) { - logger.warn(`[${requestId}] Unauthorized credential access attempt`, authz) - return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) - } - - const accessToken = await refreshAccessTokenIfNeeded( - credentialId, - authz.credentialOwnerUserId, - requestId, - getScopesForService('google-drive'), - impersonateEmail - ) - - if (!accessToken) { - return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 }) - } - - if (folderId) { - const folderIdValidation = validateAlphanumericId(folderId, 'folderId', 50) - if (!folderIdValidation.isValid) { - logger.warn(`[${requestId}] Invalid folderId`, { error: folderIdValidation.error }) - return NextResponse.json({ error: folderIdValidation.error }, { status: 400 }) - } - } - - const qParts: string[] = ['trashed = false'] - if (folderId) { - qParts.push(`'${escapeForDriveQuery(folderId)}' in parents`) - } - if (mimeType) { - qParts.push(`mimeType = '${escapeForDriveQuery(mimeType)}'`) - } - if (query) { - qParts.push(`name contains '${escapeForDriveQuery(query)}'`) - } - const q = qParts.join(' and ') - - let files: DriveFile[] - try { - const drained = await drainGooglePagedList({ - buildUrl: (pageToken) => { - const url = new URL('https://www.googleapis.com/drive/v3/files') - url.searchParams.set('q', q) - url.searchParams.set('corpora', 'allDrives') - url.searchParams.set('supportsAllDrives', 'true') - url.searchParams.set('includeItemsFromAllDrives', 'true') - url.searchParams.set('pageSize', String(DRIVE_FILE_PAGE_SIZE)) - url.searchParams.set( - 'fields', - 'nextPageToken,files(id,name,mimeType,iconLink,webViewLink,thumbnailLink,createdTime,modifiedTime,size,owners,parents)' - ) - if (pageToken) url.searchParams.set('pageToken', pageToken) - return url.toString() - }, - fetch: (url) => - fetch(url, { - headers: { - Authorization: `Bearer ${accessToken}`, - }, - }), - parseError: (response) => - response.json().catch(() => ({ error: { message: 'Unknown error' } })), - getItems: (body) => body.files, - getNextPageToken: (body) => body.nextPageToken, - maxPages: MAX_DRIVE_FILE_PAGES, - label: 'Google Drive files', - }) - files = drained.items - } catch (error) { - if (error instanceof GooglePageError) { - const errorBody = error.body as { error?: { message?: string } } - logger.error(`[${requestId}] Google Drive API error`, { - status: error.status, - error: errorBody?.error?.message || 'Failed to fetch files from Google Drive', - }) - return NextResponse.json( - { - error: errorBody?.error?.message || 'Failed to fetch files from Google Drive', - }, - { status: error.status } - ) - } - throw error - } - - if (mimeType === 'application/vnd.google-apps.spreadsheet') { - files = files.filter( - (file: DriveFile) => file.mimeType === 'application/vnd.google-apps.spreadsheet' - ) - } else if (mimeType === 'application/vnd.google-apps.document') { - files = files.filter( - (file: DriveFile) => file.mimeType === 'application/vnd.google-apps.document' - ) - } - - const isRootFolderListing = - !folderId && mimeType === 'application/vnd.google-apps.folder' && !query - if (isRootFolderListing) { - const sharedDrives = await fetchSharedDrives(accessToken, requestId) - if (sharedDrives.length > 0) { - logger.info(`[${requestId}] Found ${sharedDrives.length} shared drives`) - files = [...sharedDrives, ...files] - } - } - - return NextResponse.json({ files }, { status: 200 }) - } catch (error) { - if (error instanceof ServiceAccountTokenError) { - logger.warn(`[${requestId}] Service account token error`, { message: error.message }) - return NextResponse.json({ error: error.message }, { status: 400 }) - } - logger.error(`[${requestId}] Error fetching files from Google Drive`, error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } -}) diff --git a/apps/sim/app/api/tools/gmail/label/route.ts b/apps/sim/app/api/tools/gmail/label/route.ts deleted file mode 100644 index f1abd52383c..00000000000 --- a/apps/sim/app/api/tools/gmail/label/route.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' -import { gmailLabelSelectorContract } from '@/lib/api/contracts/selectors/google' -import { parseRequest } from '@/lib/api/server' -import { authorizeCredentialUse } from '@/lib/auth/credential-access' -import { validateAlphanumericId } from '@/lib/core/security/input-validation' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { - refreshAccessTokenIfNeeded, - ServiceAccountTokenError, -} from '@/lib/oauth/credential-service' -import { getScopesForService } from '@/lib/oauth/utils' - -export const dynamic = 'force-dynamic' - -const logger = createLogger('GmailLabelAPI') - -export const GET = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - - try { - const parsed = await parseRequest(gmailLabelSelectorContract, request, {}) - if (!parsed.success) return parsed.response - const { credentialId, labelId } = parsed.data.query - const impersonateEmail = parsed.data.query.impersonateEmail || undefined - - const labelIdValidation = validateAlphanumericId(labelId, 'labelId', 255) - if (!labelIdValidation.isValid) { - logger.warn(`[${requestId}] Invalid label ID: ${labelIdValidation.error}`) - return NextResponse.json({ error: labelIdValidation.error }, { status: 400 }) - } - - const credAccess = await authorizeCredentialUse(request, { - credentialId, - requireWorkflowIdForInternal: false, - }) - if (!credAccess.ok || !credAccess.credentialOwnerUserId) { - logger.warn(`[${requestId}] Credential access denied`, { error: credAccess.error }) - return NextResponse.json({ error: credAccess.error || 'Unauthorized' }, { status: 401 }) - } - - const accessToken = await refreshAccessTokenIfNeeded( - credentialId, - credAccess.credentialOwnerUserId, - requestId, - getScopesForService('gmail'), - impersonateEmail - ) - - if (!accessToken) { - return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 }) - } - - logger.info(`[${requestId}] Fetching label ${labelId} from Gmail API`) - const response = await fetch( - `https://gmail.googleapis.com/gmail/v1/users/me/labels/${labelId}`, - { - headers: { - Authorization: `Bearer ${accessToken}`, - }, - } - ) - - logger.info(`[${requestId}] Gmail API response status: ${response.status}`) - - if (!response.ok) { - const errorText = await response.text() - logger.error(`[${requestId}] Gmail API error response: ${errorText}`) - - try { - const error = JSON.parse(errorText) - return NextResponse.json({ error }, { status: response.status }) - } catch (_e) { - return NextResponse.json({ error: errorText }, { status: response.status }) - } - } - - const label = await response.json() - - let formattedName = label.name - - if (label.type === 'system') { - formattedName = label.name.charAt(0).toUpperCase() + label.name.slice(1).toLowerCase() - } - - const formattedLabel = { - id: label.id, - name: formattedName, - type: label.type, - messagesTotal: label.messagesTotal || 0, - messagesUnread: label.messagesUnread || 0, - } - - return NextResponse.json({ label: formattedLabel }, { status: 200 }) - } catch (error) { - if (error instanceof ServiceAccountTokenError) { - return NextResponse.json({ error: error.message }, { status: 400 }) - } - logger.error(`[${requestId}] Error fetching Gmail label:`, error) - return NextResponse.json({ error: 'Failed to fetch Gmail label' }, { status: 500 }) - } -}) diff --git a/apps/sim/app/api/tools/gmail/labels/route.ts b/apps/sim/app/api/tools/gmail/labels/route.ts deleted file mode 100644 index d531ec0a61d..00000000000 --- a/apps/sim/app/api/tools/gmail/labels/route.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' -import { gmailLabelsSelectorContract } from '@/lib/api/contracts/selectors/google' -import { parseRequest } from '@/lib/api/server' -import { authorizeCredentialUse } from '@/lib/auth/credential-access' -import { validateAlphanumericId } from '@/lib/core/security/input-validation' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { - getServiceAccountToken, - refreshAccessTokenIfNeeded, - ServiceAccountTokenError, -} from '@/lib/oauth/credential-service' -import { getScopesForService } from '@/lib/oauth/utils' -export const dynamic = 'force-dynamic' - -const logger = createLogger('GmailLabelsAPI') - -interface GmailLabel { - id: string - name: string - type: 'system' | 'user' - messagesTotal?: number - messagesUnread?: number -} - -export const GET = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - - try { - const parsed = await parseRequest(gmailLabelsSelectorContract, request, {}) - if (!parsed.success) return parsed.response - const { credentialId, query } = parsed.data.query - const impersonateEmail = parsed.data.query.impersonateEmail || undefined - - const credentialIdValidation = validateAlphanumericId(credentialId, 'credentialId', 255) - if (!credentialIdValidation.isValid) { - logger.warn(`[${requestId}] Invalid credential ID: ${credentialIdValidation.error}`) - return NextResponse.json({ error: credentialIdValidation.error }, { status: 400 }) - } - - const authz = await authorizeCredentialUse(request, { - credentialId, - requireWorkflowIdForInternal: false, - }) - if (!authz.ok || !authz.credentialOwnerUserId || !authz.resolvedCredentialId) { - return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) - } - - let accessToken: string | null = null - - if (authz.credentialType === 'service_account') { - accessToken = await getServiceAccountToken( - authz.resolvedCredentialId, - getScopesForService('gmail'), - impersonateEmail - ) - } else { - accessToken = await refreshAccessTokenIfNeeded( - credentialId, - authz.credentialOwnerUserId, - requestId, - getScopesForService('gmail') - ) - } - - if (!accessToken) { - return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 }) - } - - const response = await fetch('https://gmail.googleapis.com/gmail/v1/users/me/labels', { - headers: { - Authorization: `Bearer ${accessToken}`, - }, - }) - - logger.info(`[${requestId}] Gmail API response status: ${response.status}`) - - if (!response.ok) { - const errorText = await response.text() - logger.error(`[${requestId}] Gmail API error response: ${errorText}`) - - try { - const error = JSON.parse(errorText) - return NextResponse.json({ error }, { status: response.status }) - } catch (_e) { - return NextResponse.json({ error: errorText }, { status: response.status }) - } - } - - const data = await response.json() - if (!Array.isArray(data.labels)) { - logger.error(`[${requestId}] Unexpected labels response structure:`, data) - return NextResponse.json({ error: 'Invalid labels response' }, { status: 500 }) - } - - const labels = data.labels.map((label: GmailLabel) => { - let formattedName = label.name - - if (label.type === 'system') { - formattedName = label.name.charAt(0).toUpperCase() + label.name.slice(1).toLowerCase() - } - - return { - id: label.id, - name: formattedName, - type: label.type, - messagesTotal: label.messagesTotal || 0, - messagesUnread: label.messagesUnread || 0, - } - }) - - const filteredLabels = query - ? labels.filter((label: GmailLabel) => - label.name.toLowerCase().includes((query as string).toLowerCase()) - ) - : labels - - return NextResponse.json({ labels: filteredLabels }, { status: 200 }) - } catch (error) { - if (error instanceof ServiceAccountTokenError) { - return NextResponse.json({ error: error.message }, { status: 400 }) - } - logger.error(`[${requestId}] Error fetching Gmail labels:`, error) - return NextResponse.json({ error: 'Failed to fetch Gmail labels' }, { status: 500 }) - } -}) diff --git a/apps/sim/app/api/tools/google_bigquery/datasets/route.ts b/apps/sim/app/api/tools/google_bigquery/datasets/route.ts deleted file mode 100644 index db98979b24e..00000000000 --- a/apps/sim/app/api/tools/google_bigquery/datasets/route.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' -import { bigQueryDatasetsSelectorContract } from '@/lib/api/contracts/selectors/bigquery' -import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' -import { authorizeCredentialUse } from '@/lib/auth/credential-access' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { - refreshAccessTokenIfNeeded, - ServiceAccountTokenError, -} from '@/lib/oauth/credential-service' -import { drainGooglePagedList, GooglePageError } from '@/lib/oauth/google-pagination' -import { getScopesForService } from '@/lib/oauth/utils' - -const logger = createLogger('GoogleBigQueryDatasetsAPI') - -export const dynamic = 'force-dynamic' - -const MAX_DATASET_PAGES = 20 -const DATASET_PAGE_SIZE = 200 - -interface BigQueryDataset { - datasetReference: { datasetId: string; projectId: string } - friendlyName?: string -} - -interface BigQueryDatasetsResponse { - datasets?: BigQueryDataset[] - nextPageToken?: string -} - -/** - * POST /api/tools/google_bigquery/datasets - * - * Fetches the list of BigQuery datasets for a given project using the caller's OAuth credential. - * - * @param request - Incoming request containing `credential`, `workflowId`, and `projectId` in the JSON body - * @returns JSON response with a `datasets` array, each entry containing `datasetReference` and optional `friendlyName` - */ -export const POST = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - try { - const parsed = await parseRequest( - bigQueryDatasetsSelectorContract, - request, - {}, - { - validationErrorResponse: (error) => { - const path = error.issues.at(0)?.path[0] - const message = - path === 'credential' - ? 'Credential is required' - : path === 'projectId' - ? 'Project ID is required' - : getValidationErrorMessage(error, 'Invalid request') - logger.error(`Validation failed for BigQuery datasets request: ${message}`) - return NextResponse.json({ error: message }, { status: 400 }) - }, - } - ) - if (!parsed.success) return parsed.response - - const { credential, workflowId, projectId, impersonateEmail } = parsed.data.body - - const authz = await authorizeCredentialUse(request, { - credentialId: credential, - workflowId, - }) - if (!authz.ok || !authz.credentialOwnerUserId) { - return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) - } - - const accessToken = await refreshAccessTokenIfNeeded( - credential, - authz.credentialOwnerUserId, - requestId, - getScopesForService('google-bigquery'), - impersonateEmail - ) - if (!accessToken) { - logger.error('Failed to get access token', { - credentialId: credential, - userId: authz.credentialOwnerUserId, - }) - return NextResponse.json( - { error: 'Could not retrieve access token', authRequired: true }, - { status: 401 } - ) - } - - const { items } = await drainGooglePagedList({ - buildUrl: (pageToken) => { - const url = new URL( - `https://bigquery.googleapis.com/bigquery/v2/projects/${encodeURIComponent(projectId)}/datasets` - ) - url.searchParams.set('maxResults', String(DATASET_PAGE_SIZE)) - if (pageToken) url.searchParams.set('pageToken', pageToken) - return url.toString() - }, - fetch: (url) => - fetch(url, { - headers: { - Authorization: `Bearer ${accessToken}`, - 'Content-Type': 'application/json', - }, - }), - parseError: (response) => response.json().catch(() => ({})), - getItems: (body) => body.datasets, - getNextPageToken: (body) => body.nextPageToken, - maxPages: MAX_DATASET_PAGES, - label: 'BigQuery datasets', - }) - - const datasets = items.map((ds) => ({ - datasetReference: ds.datasetReference, - friendlyName: ds.friendlyName, - })) - - return NextResponse.json({ datasets }) - } catch (error) { - if (error instanceof GooglePageError) { - logger.error('Failed to fetch BigQuery datasets', { - status: error.status, - error: error.body, - }) - return NextResponse.json( - { error: 'Failed to fetch BigQuery datasets', details: error.body }, - { status: error.status } - ) - } - if (error instanceof ServiceAccountTokenError) { - return NextResponse.json({ error: error.message }, { status: 400 }) - } - logger.error('Error processing BigQuery datasets request:', error) - return NextResponse.json( - { error: 'Failed to retrieve BigQuery datasets', details: (error as Error).message }, - { status: 500 } - ) - } -}) diff --git a/apps/sim/app/api/tools/google_bigquery/tables/route.ts b/apps/sim/app/api/tools/google_bigquery/tables/route.ts deleted file mode 100644 index 8cb630b944e..00000000000 --- a/apps/sim/app/api/tools/google_bigquery/tables/route.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' -import { bigQueryTablesSelectorContract } from '@/lib/api/contracts/selectors/bigquery' -import { parseRequest } from '@/lib/api/server' -import { authorizeCredentialUse } from '@/lib/auth/credential-access' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { - refreshAccessTokenIfNeeded, - ServiceAccountTokenError, -} from '@/lib/oauth/credential-service' -import { drainGooglePagedList, GooglePageError } from '@/lib/oauth/google-pagination' -import { getScopesForService } from '@/lib/oauth/utils' - -const logger = createLogger('GoogleBigQueryTablesAPI') - -export const dynamic = 'force-dynamic' - -const MAX_TABLE_PAGES = 20 -const TABLE_PAGE_SIZE = 200 - -interface BigQueryTable { - tableReference: { tableId: string } - friendlyName?: string -} - -interface BigQueryTablesResponse { - tables?: BigQueryTable[] - nextPageToken?: string -} - -export const POST = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - try { - const parsed = await parseRequest( - bigQueryTablesSelectorContract, - request, - {}, - { - validationErrorResponse: (error) => { - const hasCredentialError = error.issues.some((issue) => issue.path[0] === 'credential') - if (hasCredentialError) { - logger.error('Missing credential in request') - return NextResponse.json({ error: 'Credential is required' }, { status: 400 }) - } - - const hasProjectIdError = error.issues.some((issue) => issue.path[0] === 'projectId') - if (hasProjectIdError) { - logger.error('Missing project ID in request') - return NextResponse.json({ error: 'Project ID is required' }, { status: 400 }) - } - - logger.error('Missing dataset ID in request') - return NextResponse.json({ error: 'Dataset ID is required' }, { status: 400 }) - }, - } - ) - if (!parsed.success) return parsed.response - - const { credential, workflowId, projectId, datasetId, impersonateEmail } = parsed.data.body - - const authz = await authorizeCredentialUse(request, { - credentialId: credential, - workflowId, - }) - if (!authz.ok || !authz.credentialOwnerUserId) { - return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) - } - - const accessToken = await refreshAccessTokenIfNeeded( - credential, - authz.credentialOwnerUserId, - requestId, - getScopesForService('google-bigquery'), - impersonateEmail - ) - if (!accessToken) { - logger.error('Failed to get access token', { - credentialId: credential, - userId: authz.credentialOwnerUserId, - }) - return NextResponse.json( - { error: 'Could not retrieve access token', authRequired: true }, - { status: 401 } - ) - } - - const { items } = await drainGooglePagedList({ - buildUrl: (pageToken) => { - const url = new URL( - `https://bigquery.googleapis.com/bigquery/v2/projects/${encodeURIComponent(projectId)}/datasets/${encodeURIComponent(datasetId)}/tables` - ) - url.searchParams.set('maxResults', String(TABLE_PAGE_SIZE)) - if (pageToken) url.searchParams.set('pageToken', pageToken) - return url.toString() - }, - fetch: (url) => - fetch(url, { - headers: { - Authorization: `Bearer ${accessToken}`, - 'Content-Type': 'application/json', - }, - }), - parseError: (response) => response.json().catch(() => ({})), - getItems: (body) => body.tables, - getNextPageToken: (body) => body.nextPageToken, - maxPages: MAX_TABLE_PAGES, - label: 'BigQuery tables', - }) - - const tables = items.map((t) => ({ - tableReference: t.tableReference, - friendlyName: t.friendlyName, - })) - - return NextResponse.json({ tables }) - } catch (error) { - if (error instanceof GooglePageError) { - logger.error('Failed to fetch BigQuery tables', { - status: error.status, - error: error.body, - }) - return NextResponse.json( - { error: 'Failed to fetch BigQuery tables', details: error.body }, - { status: error.status } - ) - } - if (error instanceof ServiceAccountTokenError) { - return NextResponse.json({ error: error.message }, { status: 400 }) - } - logger.error('Error processing BigQuery tables request:', error) - return NextResponse.json( - { error: 'Failed to retrieve BigQuery tables', details: (error as Error).message }, - { status: 500 } - ) - } -}) diff --git a/apps/sim/app/api/tools/google_calendar/calendars/route.ts b/apps/sim/app/api/tools/google_calendar/calendars/route.ts deleted file mode 100644 index 0102f8c3b78..00000000000 --- a/apps/sim/app/api/tools/google_calendar/calendars/route.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' -import { googleCalendarSelectorContract } from '@/lib/api/contracts/selectors/google' -import { parseRequest } from '@/lib/api/server' -import { authorizeCredentialUse } from '@/lib/auth/credential-access' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { - refreshAccessTokenIfNeeded, - ServiceAccountTokenError, -} from '@/lib/oauth/credential-service' -import { drainGooglePagedList, GooglePageError } from '@/lib/oauth/google-pagination' -import { getScopesForService } from '@/lib/oauth/utils' -export const dynamic = 'force-dynamic' - -const logger = createLogger('GoogleCalendarAPI') - -const MAX_CALENDAR_PAGES = 20 -const CALENDAR_PAGE_SIZE = 250 - -interface CalendarListItem { - id: string - summary: string - description?: string - primary?: boolean - accessRole: string - backgroundColor?: string - foregroundColor?: string -} - -interface CalendarListResponse { - items?: CalendarListItem[] - nextPageToken?: string -} - -/** - * Get calendars from Google Calendar - */ -export const GET = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - logger.info(`[${requestId}] Google Calendar calendars request received`) - - try { - const parsed = await parseRequest( - googleCalendarSelectorContract, - request, - {}, - { - validationErrorResponse: () => { - logger.warn(`[${requestId}] Missing credentialId parameter`) - return NextResponse.json({ error: 'Credential ID is required' }, { status: 400 }) - }, - } - ) - if (!parsed.success) return parsed.response - - const { credentialId } = parsed.data.query - const workflowId = parsed.data.query.workflowId || undefined - const impersonateEmail = parsed.data.query.impersonateEmail || undefined - - const authz = await authorizeCredentialUse(request, { credentialId, workflowId }) - if (!authz.ok || !authz.credentialOwnerUserId) { - return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) - } - - const accessToken = await refreshAccessTokenIfNeeded( - credentialId, - authz.credentialOwnerUserId, - requestId, - getScopesForService('google-calendar'), - impersonateEmail - ) - - if (!accessToken) { - return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 }) - } - - logger.info(`[${requestId}] Fetching calendars from Google Calendar API`) - - let calendars: CalendarListItem[] - try { - const drained = await drainGooglePagedList({ - buildUrl: (pageToken) => { - const url = new URL('https://www.googleapis.com/calendar/v3/users/me/calendarList') - url.searchParams.set('maxResults', String(CALENDAR_PAGE_SIZE)) - if (pageToken) url.searchParams.set('pageToken', pageToken) - return url.toString() - }, - fetch: (url) => - fetch(url, { - method: 'GET', - headers: { - Authorization: `Bearer ${accessToken}`, - 'Content-Type': 'application/json', - }, - }), - parseError: (response) => - response - .text() - .then((text) => JSON.parse(text)) - .catch(() => ({ error: { message: 'Unknown error' } })), - getItems: (body) => body.items, - getNextPageToken: (body) => body.nextPageToken, - maxPages: MAX_CALENDAR_PAGES, - label: 'Google Calendar calendars', - }) - calendars = drained.items - } catch (error) { - if (error instanceof GooglePageError) { - const errorData = error.body as { error?: { message?: string } } - logger.error(`[${requestId}] Google Calendar API error`, { - status: error.status, - error: errorData?.error?.message || 'Failed to fetch calendars', - }) - return NextResponse.json( - { error: errorData?.error?.message || 'Failed to fetch calendars' }, - { status: error.status } - ) - } - throw error - } - - calendars.sort((a, b) => { - if (a.primary && !b.primary) return -1 - if (!a.primary && b.primary) return 1 - return a.summary.localeCompare(b.summary) - }) - - logger.info(`[${requestId}] Successfully fetched ${calendars.length} calendars`) - - return NextResponse.json({ - calendars: calendars.map((calendar) => ({ - id: calendar.id, - summary: calendar.summary, - description: calendar.description, - primary: calendar.primary || false, - accessRole: calendar.accessRole, - backgroundColor: calendar.backgroundColor, - foregroundColor: calendar.foregroundColor, - })), - }) - } catch (error) { - if (error instanceof ServiceAccountTokenError) { - logger.warn(`[${requestId}] Service account token error`, { message: error.message }) - return NextResponse.json({ error: error.message }, { status: 400 }) - } - logger.error(`[${requestId}] Error fetching Google calendars`, error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } -}) diff --git a/apps/sim/app/api/tools/google_sheets/sheets/route.ts b/apps/sim/app/api/tools/google_sheets/sheets/route.ts deleted file mode 100644 index 18fca36e377..00000000000 --- a/apps/sim/app/api/tools/google_sheets/sheets/route.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' -import { googleSheetsSelectorContract } from '@/lib/api/contracts/selectors/google' -import { parseRequest } from '@/lib/api/server' -import { authorizeCredentialUse } from '@/lib/auth/credential-access' -import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { - refreshAccessTokenIfNeeded, - ServiceAccountTokenError, -} from '@/lib/oauth/credential-service' -import { getScopesForService } from '@/lib/oauth/utils' - -export const dynamic = 'force-dynamic' - -const logger = createLogger('GoogleSheetsAPI') - -interface SheetProperties { - sheetId: number - title: string - index: number -} - -interface Sheet { - properties: SheetProperties -} - -interface SpreadsheetResponse { - sheets: Sheet[] -} - -/** - * Get sheets (tabs) from a Google Spreadsheet - */ -export const GET = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - logger.info(`[${requestId}] Google Sheets sheets request received`) - - const auth = await checkSessionOrInternalAuth(request) - if (!auth.success || !auth.userId) { - return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) - } - - try { - const parsed = await parseRequest( - googleSheetsSelectorContract, - request, - {}, - { - validationErrorResponse: (error) => { - const missingCredential = error.issues.some((issue) => issue.path[0] === 'credentialId') - if (missingCredential) { - logger.warn(`[${requestId}] Missing credentialId parameter`) - return NextResponse.json({ error: 'Credential ID is required' }, { status: 400 }) - } - - logger.warn(`[${requestId}] Missing spreadsheetId parameter`) - return NextResponse.json({ error: 'Spreadsheet ID is required' }, { status: 400 }) - }, - } - ) - if (!parsed.success) return parsed.response - - const { credentialId, spreadsheetId } = parsed.data.query - const workflowId = parsed.data.query.workflowId || undefined - const impersonateEmail = parsed.data.query.impersonateEmail || undefined - - const authz = await authorizeCredentialUse(request, { credentialId, workflowId }) - if (!authz.ok || !authz.credentialOwnerUserId) { - return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) - } - - const accessToken = await refreshAccessTokenIfNeeded( - credentialId, - authz.credentialOwnerUserId, - requestId, - getScopesForService('google-sheets'), - impersonateEmail - ) - - if (!accessToken) { - return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 }) - } - - logger.info( - `[${requestId}] Fetching sheets from Google Sheets API for spreadsheet ${spreadsheetId}` - ) - - // Fetch spreadsheet metadata to get sheet names - const sheetsResponse = await fetch( - `https://sheets.googleapis.com/v4/spreadsheets/${spreadsheetId}?fields=sheets.properties`, - { - method: 'GET', - headers: { - Authorization: `Bearer ${accessToken}`, - 'Content-Type': 'application/json', - }, - } - ) - - if (!sheetsResponse.ok) { - const errorData = await sheetsResponse - .text() - .then((text) => JSON.parse(text)) - .catch(() => ({ error: { message: 'Unknown error' } })) - logger.error(`[${requestId}] Google Sheets API error`, { - status: sheetsResponse.status, - error: errorData.error?.message || 'Failed to fetch sheets', - }) - return NextResponse.json( - { error: errorData.error?.message || 'Failed to fetch sheets' }, - { status: sheetsResponse.status } - ) - } - - const data: SpreadsheetResponse = await sheetsResponse.json() - const sheets = data.sheets || [] - - // Sort sheets by index - sheets.sort((a, b) => a.properties.index - b.properties.index) - - logger.info(`[${requestId}] Successfully fetched ${sheets.length} sheets`) - - return NextResponse.json({ - sheets: sheets.map((sheet) => ({ - id: sheet.properties.title, // Use title as ID since that's what the API uses - name: sheet.properties.title, - sheetId: sheet.properties.sheetId, - index: sheet.properties.index, - })), - }) - } catch (error) { - if (error instanceof ServiceAccountTokenError) { - logger.warn(`[${requestId}] Service account token error`, { message: error.message }) - return NextResponse.json({ error: error.message }, { status: 400 }) - } - logger.error(`[${requestId}] Error fetching Google Sheets sheets`, error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } -}) diff --git a/apps/sim/app/api/tools/google_tasks/task-lists/route.ts b/apps/sim/app/api/tools/google_tasks/task-lists/route.ts deleted file mode 100644 index 6b6dff75db7..00000000000 --- a/apps/sim/app/api/tools/google_tasks/task-lists/route.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' -import { googleTasksTaskListsSelectorContract } from '@/lib/api/contracts/selectors/google' -import { parseRequest } from '@/lib/api/server' -import { authorizeCredentialUse } from '@/lib/auth/credential-access' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { - refreshAccessTokenIfNeeded, - ServiceAccountTokenError, -} from '@/lib/oauth/credential-service' -import { drainGooglePagedList, GooglePageError } from '@/lib/oauth/google-pagination' -import { getScopesForService } from '@/lib/oauth/utils' - -const logger = createLogger('GoogleTasksTaskListsAPI') - -export const dynamic = 'force-dynamic' - -const MAX_TASK_LIST_PAGES = 20 -const TASK_LIST_PAGE_SIZE = 1000 - -interface GoogleTaskList { - id: string - title: string -} - -interface GoogleTaskListsResponse { - items?: GoogleTaskList[] - nextPageToken?: string -} - -export const POST = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - try { - const parsed = await parseRequest( - googleTasksTaskListsSelectorContract, - request, - {}, - { - validationErrorResponse: () => { - logger.error('Missing credential in request') - return NextResponse.json({ error: 'Credential is required' }, { status: 400 }) - }, - } - ) - if (!parsed.success) return parsed.response - - const { credential, workflowId, impersonateEmail } = parsed.data.body - - const authz = await authorizeCredentialUse(request, { - credentialId: credential, - workflowId, - }) - if (!authz.ok || !authz.credentialOwnerUserId) { - return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) - } - - const accessToken = await refreshAccessTokenIfNeeded( - credential, - authz.credentialOwnerUserId, - requestId, - getScopesForService('google-tasks'), - impersonateEmail - ) - if (!accessToken) { - logger.error('Failed to get access token', { - credentialId: credential, - userId: authz.credentialOwnerUserId, - }) - return NextResponse.json( - { error: 'Could not retrieve access token', authRequired: true }, - { status: 401 } - ) - } - - const { items } = await drainGooglePagedList({ - buildUrl: (pageToken) => { - const url = new URL('https://tasks.googleapis.com/tasks/v1/users/@me/lists') - url.searchParams.set('maxResults', String(TASK_LIST_PAGE_SIZE)) - if (pageToken) url.searchParams.set('pageToken', pageToken) - return url.toString() - }, - fetch: (url) => - fetch(url, { - headers: { - Authorization: `Bearer ${accessToken}`, - 'Content-Type': 'application/json', - }, - }), - parseError: (response) => response.json().catch(() => ({})), - getItems: (body) => body.items, - getNextPageToken: (body) => body.nextPageToken, - maxPages: MAX_TASK_LIST_PAGES, - label: 'Google Tasks task lists', - }) - - const taskLists = items.map((list) => ({ - id: list.id, - title: list.title, - })) - - return NextResponse.json({ taskLists }) - } catch (error) { - if (error instanceof GooglePageError) { - logger.error('Failed to fetch Google Tasks task lists', { - status: error.status, - error: error.body, - }) - return NextResponse.json( - { error: 'Failed to fetch Google Tasks task lists', details: error.body }, - { status: error.status } - ) - } - if (error instanceof ServiceAccountTokenError) { - return NextResponse.json({ error: error.message }, { status: 400 }) - } - logger.error('Error processing Google Tasks task lists request:', error) - return NextResponse.json( - { error: 'Failed to retrieve Google Tasks task lists', details: (error as Error).message }, - { status: 500 } - ) - } -}) diff --git a/apps/sim/app/api/tools/harmonic/saved-searches/route.test.ts b/apps/sim/app/api/tools/harmonic/saved-searches/route.test.ts deleted file mode 100644 index 0261e2530d1..00000000000 --- a/apps/sim/app/api/tools/harmonic/saved-searches/route.test.ts +++ /dev/null @@ -1,403 +0,0 @@ -/** - * @vitest-environment node - */ -import { NextRequest } from 'next/server' -import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' -import { HARMONIC_SAVED_SEARCH_SELECTOR_MAX_OPTIONS } from '@/lib/api/contracts/selectors/harmonic' -import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors' - -const { - mockAuthorizeCredentialUse, - mockCheckSessionOrInternalAuth, - mockFetch, - mockResolveCredentialAccessToken, - mockResolveOAuthAccountId, -} = vi.hoisted(() => ({ - mockAuthorizeCredentialUse: vi.fn(), - mockCheckSessionOrInternalAuth: vi.fn(), - mockFetch: vi.fn(), - mockResolveCredentialAccessToken: vi.fn(), - mockResolveOAuthAccountId: vi.fn(), -})) - -vi.mock('@/lib/auth/credential-access', () => ({ - authorizeCredentialUse: mockAuthorizeCredentialUse, -})) -vi.mock('@/lib/auth/hybrid', () => ({ - checkSessionOrInternalAuth: mockCheckSessionOrInternalAuth, -})) -vi.mock('@/lib/oauth/credential-service', () => ({ - resolveCredentialAccessToken: mockResolveCredentialAccessToken, - resolveOAuthAccountId: mockResolveOAuthAccountId, -})) - -import { POST } from '@/app/api/tools/harmonic/saved-searches/route' - -const URL = 'http://localhost:3000/api/tools/harmonic/saved-searches' -const REQUEST_BODY = { credential: 'credential-1', workflowId: 'workflow-1' } as const - -function request( - body: unknown, - signal?: AbortSignal, - headers: Record = {} -): NextRequest { - return new NextRequest(URL, { - method: 'POST', - headers: { 'content-type': 'application/json', ...headers }, - body: typeof body === 'string' ? body : JSON.stringify(body), - signal, - }) -} - -function providerResponse( - body: unknown, - status = 200, - headers: Record = {} -): Response { - return new Response(typeof body === 'string' ? body : JSON.stringify(body), { - status, - headers: { 'content-type': 'application/json', ...headers }, - }) -} - -function peopleSearch(id: number, name = `Search ${id}`) { - return { - id, - entity_urn: `urn:harmonic:saved_search:${id}`, - name, - type: 'PERSONS', - query: { confidential: 'not returned' }, - } -} - -async function json(response: Response): Promise> { - return (await response.json()) as Record -} - -describe('POST /api/tools/harmonic/saved-searches', () => { - beforeEach(() => { - vi.clearAllMocks() - vi.stubGlobal('fetch', mockFetch) - mockCheckSessionOrInternalAuth.mockResolvedValue({ success: true, userId: 'caller-1' }) - mockAuthorizeCredentialUse.mockResolvedValue({ - ok: true, - credentialOwnerUserId: 'owner-1', - resolvedCredentialId: 'resolved-credential-1', - credentialType: 'service_account', - }) - mockResolveOAuthAccountId.mockResolvedValue({ - credentialType: 'service_account', - providerId: 'harmonic-service-account', - }) - mockResolveCredentialAccessToken.mockResolvedValue({ accessToken: 'server-only-api-key' }) - mockFetch.mockResolvedValue(providerResponse([])) - }) - - afterAll(() => vi.unstubAllGlobals()) - - it.each([ - ['unauthenticated malformed input', '{not-json', {}, 'Unauthorized'], - [ - 'external API-key caller', - REQUEST_BODY, - { 'x-api-key': 'external-key' }, - 'API key access not allowed for this endpoint', - ], - ])('authenticates before parsing an %s', async (_label, body, headers, error) => { - mockCheckSessionOrInternalAuth.mockResolvedValueOnce({ success: false, error }) - - const response = await POST(request(body, undefined, headers), {}) - - expect(response.status).toBe(401) - expect(await json(response)).toMatchObject({ error }) - expect(mockAuthorizeCredentialUse).not.toHaveBeenCalled() - expect(mockResolveOAuthAccountId).not.toHaveBeenCalled() - expect(mockResolveCredentialAccessToken).not.toHaveBeenCalled() - expect(mockFetch).not.toHaveBeenCalled() - }) - - it.each([ - ['invalid JSON', '{not-json', 400], - ['missing credential', { workflowId: 'workflow-1' }, 400], - ['blank workflow', { credential: 'credential-1', workflowId: ' ' }, 400], - ['unknown property', { ...REQUEST_BODY, apiKey: 'must-not-be-accepted' }, 400], - ['oversized request', { ...REQUEST_BODY, padding: 'x'.repeat(9 * 1024) }, 413], - ])('rejects %s before credential access', async (_label, body, expectedStatus) => { - const response = await POST(request(body), {}) - - expect(response.status).toBe(expectedStatus) - expect(mockAuthorizeCredentialUse).not.toHaveBeenCalled() - expect(mockResolveOAuthAccountId).not.toHaveBeenCalled() - expect(mockResolveCredentialAccessToken).not.toHaveBeenCalled() - expect(mockFetch).not.toHaveBeenCalled() - }) - - it('authorizes the exact workflow credential before metadata, secret resolution, and egress', async () => { - const response = await POST(request(REQUEST_BODY), {}) - - expect(response.status).toBe(200) - expect(mockAuthorizeCredentialUse).toHaveBeenCalledWith(expect.any(NextRequest), { - credentialId: 'credential-1', - workflowId: 'workflow-1', - callerUserId: 'caller-1', - }) - expect(mockResolveOAuthAccountId).toHaveBeenCalledWith('resolved-credential-1') - expect(mockResolveCredentialAccessToken).toHaveBeenCalledWith( - 'resolved-credential-1', - 'owner-1', - expect.any(String) - ) - expect(mockAuthorizeCredentialUse.mock.invocationCallOrder[0]).toBeLessThan( - mockResolveOAuthAccountId.mock.invocationCallOrder[0] - ) - expect(mockResolveOAuthAccountId.mock.invocationCallOrder[0]).toBeLessThan( - mockResolveCredentialAccessToken.mock.invocationCallOrder[0] - ) - expect(mockResolveCredentialAccessToken.mock.invocationCallOrder[0]).toBeLessThan( - mockFetch.mock.invocationCallOrder[0] - ) - }) - - it('fails closed on credential authorization before metadata, secrets, or egress', async () => { - mockAuthorizeCredentialUse.mockResolvedValueOnce({ ok: false, error: 'Forbidden' }) - - const response = await POST(request(REQUEST_BODY), {}) - - expect(response.status).toBe(403) - expect(mockResolveOAuthAccountId).not.toHaveBeenCalled() - expect(mockResolveCredentialAccessToken).not.toHaveBeenCalled() - expect(mockFetch).not.toHaveBeenCalled() - }) - - it.each([ - [ - 'non-service-account authorization', - () => - mockAuthorizeCredentialUse.mockResolvedValueOnce({ - ok: true, - credentialOwnerUserId: 'owner-1', - resolvedCredentialId: 'resolved-credential-1', - credentialType: 'oauth', - }), - ], - [ - 'wrong service-account provider', - () => - mockResolveOAuthAccountId.mockResolvedValueOnce({ - credentialType: 'service_account', - providerId: 'snowflake-service-account', - }), - ], - ['missing credential metadata', () => mockResolveOAuthAccountId.mockResolvedValueOnce(null)], - ])('rejects a %s before secret resolution or provider egress', async (_label, arrange) => { - arrange() - - const response = await POST(request(REQUEST_BODY), {}) - - expect(response.status).toBe(400) - expect(mockResolveCredentialAccessToken).not.toHaveBeenCalled() - expect(mockFetch).not.toHaveBeenCalled() - }) - - it('uses only the fixed Harmonic origin and apikey header and returns no secret data', async () => { - mockFetch.mockResolvedValueOnce( - providerResponse([ - peopleSearch(2, 'Zeta search'), - { - id: 99, - entity_urn: 'urn:harmonic:saved_search:99', - name: 'Companies', - type: 'COMPANIES', - }, - peopleSearch(1, 'Alpha search'), - peopleSearch(1, 'Alpha search'), - ]) - ) - - const response = await POST(request(REQUEST_BODY), {}) - const body = await json(response) - - expect(response.status).toBe(200) - expect(mockFetch).toHaveBeenCalledWith( - 'https://api.harmonic.ai/savedSearches', - expect.objectContaining({ method: 'GET', redirect: 'error' }) - ) - const init = mockFetch.mock.calls[0]?.[1] as RequestInit - expect(new Headers(init.headers).get('apikey')).toBe('server-only-api-key') - expect(new Headers(init.headers).get('authorization')).toBeNull() - expect(body).toEqual({ - savedSearches: [ - { id: '1', urn: 'urn:harmonic:saved_search:1', name: 'Alpha search' }, - { id: '2', urn: 'urn:harmonic:saved_search:2', name: 'Zeta search' }, - ], - }) - expect(JSON.stringify(body)).not.toContain('server-only-api-key') - expect(JSON.stringify(body)).not.toContain('confidential') - }) - - it('preserves a signed safe-integer ID without inventing an OpenAPI minimum', async () => { - mockFetch.mockResolvedValueOnce(providerResponse([peopleSearch(-7, 'Signed ID search')])) - - const response = await POST(request(REQUEST_BODY), {}) - - expect(response.status).toBe(200) - expect(await json(response)).toEqual({ - savedSearches: [ - { - id: '-7', - urn: 'urn:harmonic:saved_search:-7', - name: 'Signed ID search', - }, - ], - }) - }) - - it.each([ - [401, 401, true], - [403, 401, true], - [404, 400, undefined], - [429, 429, undefined], - [500, 502, undefined], - ])( - 'maps provider status %s without reflecting provider details', - async (status, expected, auth) => { - mockFetch.mockResolvedValueOnce(providerResponse({ error: 'provider secret detail' }, status)) - - const response = await POST(request(REQUEST_BODY), {}) - const body = await json(response) - - expect(response.status).toBe(expected) - expect(body.authRequired).toBe(auth) - expect(JSON.stringify(body)).not.toContain('provider secret detail') - } - ) - - it.each([ - ['non-array root', { data: [] }], - ['non-object row', [null]], - ['missing required person identity', [{ type: 'PERSONS', name: 'Broken' }]], - ['conflicting numeric identity', [peopleSearch(1), { ...peopleSearch(2), id: 1 }]], - ['too many raw rows', Array.from({ length: 2_001 }, () => ({ type: 'COMPANIES' }))], - ])('fails closed on a %s provider response', async (_label, providerBody) => { - mockFetch.mockResolvedValueOnce(providerResponse(providerBody)) - - const response = await POST(request(REQUEST_BODY), {}) - - expect(response.status).toBe(502) - expect(await json(response)).toEqual({ - error: 'Harmonic returned an invalid saved-search response.', - }) - }) - - it('rejects malformed and oversized provider bodies', async () => { - mockFetch - .mockResolvedValueOnce(providerResponse('{not-json')) - .mockResolvedValueOnce( - providerResponse('[]', 200, { 'content-length': String(1024 * 1024 + 1) }) - ) - - for (let requestNumber = 0; requestNumber < 2; requestNumber++) { - const response = await POST(request(REQUEST_BODY), {}) - expect(response.status).toBe(502) - expect(mockResolveCredentialAccessToken).toHaveBeenCalledTimes(requestNumber + 1) - } - }) - - it('accepts the exact people-option ceiling after filtering and deduplication', async () => { - const searches = Array.from( - { length: HARMONIC_SAVED_SEARCH_SELECTOR_MAX_OPTIONS }, - (_, index) => peopleSearch(index + 1) - ) - searches.push(peopleSearch(1), { - id: 9999, - entity_urn: 'urn:harmonic:saved_search:9999', - name: 'Company search', - type: 'COMPANIES', - query: { confidential: 'not returned' }, - }) - mockFetch.mockResolvedValueOnce(providerResponse(searches)) - - const response = await POST(request(REQUEST_BODY), {}) - const body = await json(response) - - expect(response.status).toBe(200) - expect(body.savedSearches).toHaveLength(HARMONIC_SAVED_SEARCH_SELECTOR_MAX_OPTIONS) - }) - - it('truncates to the option ceiling instead of failing the whole selector', async () => { - mockFetch.mockResolvedValueOnce( - providerResponse( - Array.from({ length: HARMONIC_SAVED_SEARCH_SELECTOR_MAX_OPTIONS + 25 }, (_, index) => - peopleSearch(index + 1) - ) - ) - ) - - const response = await POST(request(REQUEST_BODY), {}) - const body = await json(response) - - expect(response.status).toBe(200) - expect(body.savedSearches).toHaveLength(HARMONIC_SAVED_SEARCH_SELECTOR_MAX_OPTIONS) - }) - - it.each([ - [null, 401], - [new TokenServiceAccountValidationError('invalid_credentials', 401), 401], - [new TokenServiceAccountValidationError('provider_unavailable', 502), 502], - ])( - 'keeps credential-resolution failure %s away from provider egress', - async (failure, status) => { - if (failure) mockResolveCredentialAccessToken.mockRejectedValueOnce(failure) - else mockResolveCredentialAccessToken.mockResolvedValueOnce(null) - - const response = await POST(request(REQUEST_BODY), {}) - - expect(response.status).toBe(status) - expect(mockFetch).not.toHaveBeenCalled() - } - ) - - it('keeps unexpected credential infrastructure errors generic', async () => { - mockResolveCredentialAccessToken.mockRejectedValueOnce( - new Error('secret credential infrastructure detail') - ) - - const response = await POST(request(REQUEST_BODY), {}) - const body = await json(response) - - expect(response.status).toBe(500) - expect(body.error).toBe('Internal server error') - expect(JSON.stringify(body)).not.toContain('secret credential infrastructure detail') - expect(mockFetch).not.toHaveBeenCalled() - }) - - it('maps provider network failures and timeouts without leaking the thrown message', async () => { - mockFetch - .mockRejectedValueOnce(new Error('network secret detail')) - .mockRejectedValueOnce(new DOMException('timeout secret detail', 'TimeoutError')) - - const networkResponse = await POST(request(REQUEST_BODY), {}) - expect(networkResponse.status).toBe(502) - expect(JSON.stringify(await json(networkResponse))).not.toContain('network secret detail') - - const timeoutResponse = await POST(request(REQUEST_BODY), {}) - expect(timeoutResponse.status).toBe(504) - expect(JSON.stringify(await json(timeoutResponse))).not.toContain('timeout secret detail') - }) - - it('propagates client cancellation through provider egress', async () => { - const controller = new AbortController() - mockFetch.mockImplementationOnce((_url: string, init: RequestInit) => { - return new Promise((_resolve, reject) => { - init.signal?.addEventListener('abort', () => reject(init.signal?.reason), { once: true }) - }) - }) - - const pending = POST(request(REQUEST_BODY, controller.signal), {}) - await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1)) - controller.abort(new Error('caller cancelled')) - const response = await pending - - expect(response.status).toBe(499) - }) -}) diff --git a/apps/sim/app/api/tools/harmonic/saved-searches/route.ts b/apps/sim/app/api/tools/harmonic/saved-searches/route.ts deleted file mode 100644 index 36d94b8af28..00000000000 --- a/apps/sim/app/api/tools/harmonic/saved-searches/route.ts +++ /dev/null @@ -1,253 +0,0 @@ -import { createLogger } from '@sim/logger' -import { isPlainRecord } from '@sim/utils/object' -import { type NextRequest, NextResponse } from 'next/server' -import { - HARMONIC_SAVED_SEARCH_SELECTOR_MAX_OPTIONS, - type HarmonicSavedSearchesSelectorResponse, - harmonicPeopleSavedSearchProviderSchema, - harmonicSavedSearchesSelectorContract, -} from '@/lib/api/contracts/selectors/harmonic' -import { parseRequest } from '@/lib/api/server' -import { authorizeCredentialUse } from '@/lib/auth/credential-access' -import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' -import { generateRequestId } from '@/lib/core/utils/request' -import { readResponseJsonWithLimit } from '@/lib/core/utils/stream-limits' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { HARMONIC_SERVICE_ACCOUNT_PROVIDER_ID } from '@/lib/credentials/token-service-accounts/descriptors' -import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors' -import { resolveCredentialAccessToken, resolveOAuthAccountId } from '@/lib/oauth/credential-service' - -export const dynamic = 'force-dynamic' - -const logger = createLogger('HarmonicSavedSearchesAPI') -const HARMONIC_SAVED_SEARCHES_URL = 'https://api.harmonic.ai/savedSearches' -const SELECTOR_REQUEST_MAX_BYTES = 8 * 1024 -const PROVIDER_RESPONSE_MAX_BYTES = 1024 * 1024 -const PROVIDER_RESPONSE_MAX_ROWS = 2_000 -const PROVIDER_FETCH_TIMEOUT_MS = 10_000 - -type SavedSearchOption = HarmonicSavedSearchesSelectorResponse['savedSearches'][number] - -function throwIfAborted(signal: AbortSignal): void { - if (!signal.aborted) return - throw signal.reason instanceof Error - ? signal.reason - : new DOMException('Harmonic selector request was cancelled', 'AbortError') -} - -async function discardResponseBody(response: Response): Promise { - await response.body?.cancel().catch(() => {}) -} - -async function providerFailureResponse(response: Response): Promise { - await discardResponseBody(response) - if (response.status === 401 || response.status === 403) { - return NextResponse.json( - { - error: 'Harmonic rejected this credential. Reconnect it and try again.', - authRequired: true, - }, - { status: 401 } - ) - } - if (response.status === 429) { - return NextResponse.json( - { error: 'Harmonic rate-limited saved-search discovery. Try again shortly.' }, - { status: 429 } - ) - } - if (response.status >= 400 && response.status < 500) { - return NextResponse.json( - { error: 'Harmonic could not list saved searches for this request.' }, - { status: 400 } - ) - } - return NextResponse.json({ error: 'Harmonic saved-search discovery failed.' }, { status: 502 }) -} - -function credentialFailureResponse(error?: unknown): NextResponse { - if ( - error instanceof TokenServiceAccountValidationError && - error.code === 'provider_unavailable' - ) { - return NextResponse.json( - { error: 'The Harmonic credential service is temporarily unavailable.' }, - { status: 502 } - ) - } - return NextResponse.json( - { - error: 'Could not resolve the Harmonic credential. Reconnect it and try again.', - authRequired: true, - }, - { status: 401 } - ) -} - -function normalizeSavedSearches(value: unknown): SavedSearchOption[] { - if (!Array.isArray(value) || value.length > PROVIDER_RESPONSE_MAX_ROWS) { - throw new Error('Harmonic returned an invalid saved-search collection') - } - - const byUrn = new Map() - const urnById = new Map() - for (const item of value) { - if (!isPlainRecord(item)) { - throw new Error('Harmonic returned a malformed saved-search entry') - } - if (item.type !== 'PERSONS') continue - - const parsed = harmonicPeopleSavedSearchProviderSchema.safeParse(item) - if (!parsed.success) { - throw new Error('Harmonic returned a malformed people saved search') - } - const option = { - id: String(parsed.data.id), - urn: parsed.data.entity_urn, - name: parsed.data.name, - } - const existingByUrn = byUrn.get(option.urn) - const existingUrnForId = urnById.get(option.id) - if ( - (existingByUrn && (existingByUrn.id !== option.id || existingByUrn.name !== option.name)) || - (existingUrnForId && existingUrnForId !== option.urn) - ) { - throw new Error('Harmonic returned conflicting saved-search identities') - } - if (existingByUrn) continue - if (byUrn.size >= HARMONIC_SAVED_SEARCH_SELECTOR_MAX_OPTIONS) { - /** - * `GET /savedSearches` is unpaginated, so this ceiling bounds customer data - * rather than a provider catalog. Every sibling selector with a data-driven - * bound truncates and warns; failing here would leave the dropdown dead with - * no in-place recovery. - */ - logger.warn('Harmonic saved-search list hit the option ceiling; list may be incomplete', { - cap: HARMONIC_SAVED_SEARCH_SELECTOR_MAX_OPTIONS, - }) - break - } - byUrn.set(option.urn, option) - urnById.set(option.id, option.urn) - } - - return [...byUrn.values()].sort( - (left, right) => left.name.localeCompare(right.name) || left.urn.localeCompare(right.urn) - ) -} - -/** - * Lists the bounded people saved searches used by `harmonic.savedSearches`. - * This editor/executor selector follows the established Bitbucket and NetSuite - * selector route pattern: surface authentication happens first, then the - * shared workflow-scoped credential authorization helper runs before provider - * metadata, secret resolution, or external egress. - */ -export const POST = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - const caller = await checkSessionOrInternalAuth(request, { requireWorkflowId: true }) - if (!caller.success || !caller.userId) { - return NextResponse.json({ error: caller.error || 'Authentication required' }, { status: 401 }) - } - - const parsed = await parseRequest( - harmonicSavedSearchesSelectorContract, - request, - {}, - { maxBodyBytes: SELECTOR_REQUEST_MAX_BYTES } - ) - if (!parsed.success) return parsed.response - const { credential, workflowId } = parsed.data.body - - const authorization = await authorizeCredentialUse(request, { - credentialId: credential, - workflowId, - callerUserId: caller.userId, - }) - if (!authorization.ok || !authorization.credentialOwnerUserId) { - return NextResponse.json({ error: authorization.error || 'Unauthorized' }, { status: 403 }) - } - - const resolvedCredentialId = authorization.resolvedCredentialId ?? credential - const credentialMetadata = await resolveOAuthAccountId(resolvedCredentialId) - if ( - authorization.credentialType !== 'service_account' || - credentialMetadata?.credentialType !== 'service_account' || - credentialMetadata.providerId !== HARMONIC_SERVICE_ACCOUNT_PROVIDER_ID - ) { - return NextResponse.json({ error: 'Select a Harmonic API-key account.' }, { status: 400 }) - } - - throwIfAborted(request.signal) - let token - try { - token = await resolveCredentialAccessToken( - resolvedCredentialId, - authorization.credentialOwnerUserId, - requestId - ) - } catch (error) { - throwIfAborted(request.signal) - logger.warn('Failed to resolve Harmonic selector credential', { - credentialId: resolvedCredentialId, - errorType: error instanceof Error ? error.name : 'unknown', - }) - if (error instanceof TokenServiceAccountValidationError) { - return credentialFailureResponse(error) - } - throw error - } - throwIfAborted(request.signal) - if (!token?.accessToken) return credentialFailureResponse() - - const timeoutSignal = AbortSignal.timeout(PROVIDER_FETCH_TIMEOUT_MS) - const providerSignal = AbortSignal.any([request.signal, timeoutSignal]) - let response: Response - try { - response = await fetch(HARMONIC_SAVED_SEARCHES_URL, { - method: 'GET', - headers: { Accept: 'application/json', apikey: token.accessToken }, - redirect: 'error', - signal: providerSignal, - }) - } catch (error) { - if (request.signal.aborted) throw error - if (timeoutSignal.aborted || (error instanceof DOMException && error.name === 'TimeoutError')) { - return NextResponse.json( - { error: 'Harmonic saved-search discovery timed out.' }, - { status: 504 } - ) - } - logger.warn('Harmonic saved-search request failed', { - errorType: error instanceof Error ? error.name : 'unknown', - }) - return NextResponse.json({ error: 'Harmonic saved-search discovery failed.' }, { status: 502 }) - } - - if (!response.ok) return providerFailureResponse(response) - - try { - const providerBody = await readResponseJsonWithLimit(response, { - label: 'Harmonic saved-search response', - maxBytes: PROVIDER_RESPONSE_MAX_BYTES, - signal: providerSignal, - }) - throwIfAborted(providerSignal) - return NextResponse.json({ savedSearches: normalizeSavedSearches(providerBody) }) - } catch (error) { - if (request.signal.aborted) throw error - if (timeoutSignal.aborted || (error instanceof DOMException && error.name === 'TimeoutError')) { - return NextResponse.json( - { error: 'Harmonic saved-search discovery timed out.' }, - { status: 504 } - ) - } - logger.warn('Harmonic saved-search response was invalid', { - errorType: error instanceof Error ? error.name : 'unknown', - }) - return NextResponse.json( - { error: 'Harmonic returned an invalid saved-search response.' }, - { status: 502 } - ) - } -}) diff --git a/apps/sim/app/api/tools/hubspot/lists/route.ts b/apps/sim/app/api/tools/hubspot/lists/route.ts deleted file mode 100644 index 171474f48c5..00000000000 --- a/apps/sim/app/api/tools/hubspot/lists/route.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' -import { hubspotListsSelectorContract } from '@/lib/api/contracts/selectors/hubspot' -import { parseRequest } from '@/lib/api/server' -import { authorizeCredentialUse } from '@/lib/auth/credential-access' -import { validateAlphanumericId } from '@/lib/core/security/input-validation' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service' - -export const dynamic = 'force-dynamic' - -const logger = createLogger('HubSpotListsAPI') - -interface HubSpotList { - listId: string - name: string - objectTypeId?: string - processingType?: string - deletedAt?: string | null -} - -export const GET = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - - try { - const parsed = await parseRequest(hubspotListsSelectorContract, request, {}) - if (!parsed.success) return parsed.response - const { credentialId, objectTypeId, query } = parsed.data.query - - const credentialIdValidation = validateAlphanumericId(credentialId, 'credentialId', 255) - if (!credentialIdValidation.isValid) { - logger.warn(`[${requestId}] Invalid credential ID: ${credentialIdValidation.error}`) - return NextResponse.json({ error: credentialIdValidation.error }, { status: 400 }) - } - - const authz = await authorizeCredentialUse(request, { - credentialId, - requireWorkflowIdForInternal: false, - }) - if (!authz.ok || !authz.credentialOwnerUserId || !authz.resolvedCredentialId) { - return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) - } - - const accessToken = await refreshAccessTokenIfNeeded( - credentialId, - authz.credentialOwnerUserId, - requestId - ) - if (!accessToken) { - return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 }) - } - - const params = new URLSearchParams() - if (objectTypeId) params.set('objectTypeId', objectTypeId as string) - params.set('count', '500') - - const response = await fetch( - `https://api.hubapi.com/crm/v3/lists/search?${params.toString()}`, - { - method: 'POST', - headers: { - Authorization: `Bearer ${accessToken}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - query: '', - processingTypes: ['MANUAL', 'DYNAMIC', 'SNAPSHOT'], - ...(objectTypeId ? { additionalProperties: ['hs_object_id'] } : {}), - }), - } - ) - - if (!response.ok) { - const errorText = await response.text().catch(() => '') - logger.error(`[${requestId}] HubSpot lists API error ${response.status}: ${errorText}`) - return NextResponse.json( - { error: errorText || 'Failed to fetch HubSpot lists' }, - { status: response.status } - ) - } - - const data = (await response.json()) as { lists?: HubSpotList[] } - const filterTerm = (query as string | undefined)?.toLowerCase() - const lists = (data.lists ?? []) - .filter((l) => !l.deletedAt) - .map((l) => ({ - id: l.listId, - name: l.name, - objectType: l.objectTypeId, - processingType: l.processingType, - })) - .filter( - (l) => - !filterTerm || - l.id.toLowerCase().includes(filterTerm) || - l.name.toLowerCase().includes(filterTerm) - ) - .sort((a, b) => a.name.localeCompare(b.name)) - - return NextResponse.json({ lists }, { status: 200 }) - } catch (error) { - logger.error(`[${requestId}] Error fetching HubSpot lists:`, error) - return NextResponse.json({ error: 'Failed to fetch HubSpot lists' }, { status: 500 }) - } -}) diff --git a/apps/sim/app/api/tools/hubspot/owners/route.ts b/apps/sim/app/api/tools/hubspot/owners/route.ts deleted file mode 100644 index 43f0b3576b6..00000000000 --- a/apps/sim/app/api/tools/hubspot/owners/route.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' -import { hubspotOwnersSelectorContract } from '@/lib/api/contracts/selectors/hubspot' -import { parseRequest } from '@/lib/api/server' -import { authorizeCredentialUse } from '@/lib/auth/credential-access' -import { validateAlphanumericId } from '@/lib/core/security/input-validation' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service' - -export const dynamic = 'force-dynamic' - -const logger = createLogger('HubSpotOwnersAPI') - -interface HubSpotOwner { - id: string - email?: string - firstName?: string - lastName?: string - archived?: boolean -} - -export const GET = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - - try { - const parsed = await parseRequest(hubspotOwnersSelectorContract, request, {}) - if (!parsed.success) return parsed.response - const { credentialId, query } = parsed.data.query - - const credentialIdValidation = validateAlphanumericId(credentialId, 'credentialId', 255) - if (!credentialIdValidation.isValid) { - logger.warn(`[${requestId}] Invalid credential ID: ${credentialIdValidation.error}`) - return NextResponse.json({ error: credentialIdValidation.error }, { status: 400 }) - } - - const authz = await authorizeCredentialUse(request, { - credentialId, - requireWorkflowIdForInternal: false, - }) - if (!authz.ok || !authz.credentialOwnerUserId || !authz.resolvedCredentialId) { - return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) - } - - const accessToken = await refreshAccessTokenIfNeeded( - credentialId, - authz.credentialOwnerUserId, - requestId - ) - if (!accessToken) { - return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 }) - } - - const collected: HubSpotOwner[] = [] - let after: string | undefined - let pages = 0 - do { - const params = new URLSearchParams({ limit: '100' }) - if (after) params.set('after', after) - const response = await fetch(`https://api.hubapi.com/crm/v3/owners?${params.toString()}`, { - headers: { Authorization: `Bearer ${accessToken}` }, - }) - - if (!response.ok) { - const errorText = await response.text().catch(() => '') - logger.error(`[${requestId}] HubSpot owners API error ${response.status}: ${errorText}`) - return NextResponse.json( - { error: errorText || 'Failed to fetch HubSpot owners' }, - { status: response.status } - ) - } - - const data = (await response.json()) as { - results?: HubSpotOwner[] - paging?: { next?: { after?: string } } - } - if (data.results?.length) collected.push(...data.results) - after = data.paging?.next?.after - pages++ - } while (after && pages < 10) - - const filterTerm = (query as string | undefined)?.toLowerCase() - const owners = collected - .filter((o) => !o.archived) - .map((o) => ({ - id: o.id, - name: [o.firstName, o.lastName].filter(Boolean).join(' ') || o.email || o.id, - email: o.email, - })) - .filter( - (o) => - !filterTerm || - o.name.toLowerCase().includes(filterTerm) || - (o.email?.toLowerCase().includes(filterTerm) ?? false) - ) - .sort((a, b) => a.name.localeCompare(b.name)) - - return NextResponse.json({ owners }, { status: 200 }) - } catch (error) { - logger.error(`[${requestId}] Error fetching HubSpot owners:`, error) - return NextResponse.json({ error: 'Failed to fetch HubSpot owners' }, { status: 500 }) - } -}) diff --git a/apps/sim/app/api/tools/hubspot/pipelines/route.ts b/apps/sim/app/api/tools/hubspot/pipelines/route.ts deleted file mode 100644 index c7fd92edaa1..00000000000 --- a/apps/sim/app/api/tools/hubspot/pipelines/route.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' -import { hubspotPipelinesSelectorContract } from '@/lib/api/contracts/selectors/hubspot' -import { parseRequest } from '@/lib/api/server' -import { authorizeCredentialUse } from '@/lib/auth/credential-access' -import { validateAlphanumericId } from '@/lib/core/security/input-validation' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service' - -export const dynamic = 'force-dynamic' - -const logger = createLogger('HubSpotPipelinesAPI') - -const BUILT_IN_PATH: Record = { - contact: 'contacts', - company: 'companies', - deal: 'deals', - ticket: 'tickets', -} - -interface HubSpotPipeline { - id: string - label: string - stages?: Array<{ id: string; label: string }> - archived?: boolean -} - -export const GET = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - - try { - const parsed = await parseRequest(hubspotPipelinesSelectorContract, request, {}) - if (!parsed.success) return parsed.response - const { credentialId, objectType } = parsed.data.query - - const credentialIdValidation = validateAlphanumericId(credentialId, 'credentialId', 255) - if (!credentialIdValidation.isValid) { - logger.warn(`[${requestId}] Invalid credential ID: ${credentialIdValidation.error}`) - return NextResponse.json({ error: credentialIdValidation.error }, { status: 400 }) - } - - const authz = await authorizeCredentialUse(request, { - credentialId, - requireWorkflowIdForInternal: false, - }) - if (!authz.ok || !authz.credentialOwnerUserId || !authz.resolvedCredentialId) { - return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) - } - - const accessToken = await refreshAccessTokenIfNeeded( - credentialId, - authz.credentialOwnerUserId, - requestId - ) - if (!accessToken) { - return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 }) - } - - const pathSegment = BUILT_IN_PATH[objectType] ?? objectType - const response = await fetch( - `https://api.hubapi.com/crm/v3/pipelines/${encodeURIComponent(pathSegment)}`, - { headers: { Authorization: `Bearer ${accessToken}` } } - ) - - if (!response.ok) { - const errorText = await response.text().catch(() => '') - logger.error(`[${requestId}] HubSpot pipelines API error ${response.status}: ${errorText}`) - return NextResponse.json( - { error: errorText || 'Failed to fetch HubSpot pipelines' }, - { status: response.status } - ) - } - - const data = (await response.json()) as { results?: HubSpotPipeline[] } - const pipelines = (data.results ?? []) - .filter((p) => !p.archived) - .map((p) => ({ - id: p.id, - name: p.label, - stages: p.stages?.map((s) => ({ id: s.id, label: s.label })), - })) - .sort((a, b) => a.name.localeCompare(b.name)) - - return NextResponse.json({ pipelines }, { status: 200 }) - } catch (error) { - logger.error(`[${requestId}] Error fetching HubSpot pipelines:`, error) - return NextResponse.json({ error: 'Failed to fetch HubSpot pipelines' }, { status: 500 }) - } -}) diff --git a/apps/sim/app/api/tools/hubspot/properties/route.ts b/apps/sim/app/api/tools/hubspot/properties/route.ts deleted file mode 100644 index 3d52b9b0d8f..00000000000 --- a/apps/sim/app/api/tools/hubspot/properties/route.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' -import { hubspotPropertiesSelectorContract } from '@/lib/api/contracts/selectors/hubspot' -import { parseRequest } from '@/lib/api/server' -import { authorizeCredentialUse } from '@/lib/auth/credential-access' -import { validateAlphanumericId } from '@/lib/core/security/input-validation' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service' - -export const dynamic = 'force-dynamic' - -const logger = createLogger('HubSpotPropertiesAPI') - -const BUILT_IN_PATH: Record = { - contact: 'contacts', - company: 'companies', - deal: 'deals', - ticket: 'tickets', -} - -interface HubSpotProperty { - name: string - label: string - type?: string - fieldType?: string - groupName?: string - hidden?: boolean - archived?: boolean -} - -export const GET = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - - try { - const parsed = await parseRequest(hubspotPropertiesSelectorContract, request, {}) - if (!parsed.success) return parsed.response - const { credentialId, objectType, query } = parsed.data.query - - const credentialIdValidation = validateAlphanumericId(credentialId, 'credentialId', 255) - if (!credentialIdValidation.isValid) { - logger.warn(`[${requestId}] Invalid credential ID: ${credentialIdValidation.error}`) - return NextResponse.json({ error: credentialIdValidation.error }, { status: 400 }) - } - - const authz = await authorizeCredentialUse(request, { - credentialId, - requireWorkflowIdForInternal: false, - }) - if (!authz.ok || !authz.credentialOwnerUserId || !authz.resolvedCredentialId) { - return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) - } - - const accessToken = await refreshAccessTokenIfNeeded( - credentialId, - authz.credentialOwnerUserId, - requestId - ) - if (!accessToken) { - return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 }) - } - - const pathSegment = BUILT_IN_PATH[objectType] ?? objectType - const response = await fetch( - `https://api.hubapi.com/crm/v3/properties/${encodeURIComponent(pathSegment)}`, - { headers: { Authorization: `Bearer ${accessToken}` } } - ) - - if (!response.ok) { - const errorText = await response.text().catch(() => '') - logger.error(`[${requestId}] HubSpot properties API error ${response.status}: ${errorText}`) - return NextResponse.json( - { error: errorText || 'Failed to fetch HubSpot properties' }, - { status: response.status } - ) - } - - const data = (await response.json()) as { results?: HubSpotProperty[] } - if (!Array.isArray(data.results)) { - return NextResponse.json({ error: 'Invalid HubSpot properties response' }, { status: 500 }) - } - - const filterTerm = (query as string | undefined)?.toLowerCase() - const properties = data.results - .filter((p) => !p.hidden && !p.archived) - .map((p) => ({ - id: p.name, - name: p.label || p.name, - type: p.type, - fieldType: p.fieldType, - groupName: p.groupName, - })) - .filter( - (p) => - !filterTerm || - p.id.toLowerCase().includes(filterTerm) || - p.name.toLowerCase().includes(filterTerm) - ) - .sort((a, b) => a.name.localeCompare(b.name)) - - return NextResponse.json({ properties }, { status: 200 }) - } catch (error) { - logger.error(`[${requestId}] Error fetching HubSpot properties:`, error) - return NextResponse.json({ error: 'Failed to fetch HubSpot properties' }, { status: 500 }) - } -}) diff --git a/apps/sim/app/api/tools/imap/mailboxes/route.ts b/apps/sim/app/api/tools/imap/mailboxes/route.ts deleted file mode 100644 index b66c9eb34d4..00000000000 --- a/apps/sim/app/api/tools/imap/mailboxes/route.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import { ImapFlow } from 'imapflow' -import { type NextRequest, NextResponse } from 'next/server' -import { imapMailboxesContract } from '@/lib/api/contracts/tools/imap' -import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { validateDatabaseHost } from '@/lib/core/security/input-validation.server' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' - -const logger = createLogger('ImapMailboxesAPI') - -export const POST = withRouteHandler(async (request: NextRequest) => { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ success: false, message: 'Unauthorized' }, { status: 401 }) - } - - const parsed = await parseRequest( - imapMailboxesContract, - request, - {}, - { - validationErrorResponse: (error) => - NextResponse.json( - { - success: false, - message: getValidationErrorMessage( - error, - 'Missing required fields: host, username, password' - ), - }, - { status: 400 } - ), - invalidJsonResponse: () => - NextResponse.json( - { success: false, message: 'Request body must be valid JSON' }, - { status: 400 } - ), - } - ) - if (!parsed.success) return parsed.response - const { host, port, secure, username, password } = parsed.data.body - - try { - const hostValidation = await validateDatabaseHost(host, 'host') - if (!hostValidation.isValid) { - return NextResponse.json({ success: false, message: hostValidation.error }, { status: 400 }) - } - - const client = new ImapFlow({ - host: hostValidation.resolvedIP!, - servername: host, - port, - secure, - auth: { - user: username, - pass: password, - }, - tls: { - rejectUnauthorized: true, - }, - logger: false, - }) - - try { - await client.connect() - - const listResult = await client.list() - const mailboxes = listResult.map((mailbox) => ({ - path: mailbox.path, - name: mailbox.name, - delimiter: mailbox.delimiter, - })) - - await client.logout() - - mailboxes.sort((a, b) => { - if (a.path === 'INBOX') return -1 - if (b.path === 'INBOX') return 1 - return a.path.localeCompare(b.path) - }) - - return NextResponse.json({ - success: true, - mailboxes, - }) - } catch (error) { - try { - await client.logout() - } catch { - // Ignore logout errors - } - throw error - } - } catch (error) { - const errorMessage = getErrorMessage(error, 'Unknown error') - logger.error('Error fetching IMAP mailboxes:', errorMessage) - - let userMessage = 'Failed to connect to IMAP server. Please check your connection settings.' - if ( - errorMessage.includes('AUTHENTICATIONFAILED') || - errorMessage.includes('Invalid credentials') - ) { - userMessage = 'Invalid username or password. For Gmail, use an App Password.' - } - - return NextResponse.json({ success: false, message: userMessage }, { status: 500 }) - } -}) diff --git a/apps/sim/app/api/tools/jira/issues/route.ts b/apps/sim/app/api/tools/jira/issues/route.ts deleted file mode 100644 index 34e92befb13..00000000000 --- a/apps/sim/app/api/tools/jira/issues/route.ts +++ /dev/null @@ -1,237 +0,0 @@ -import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' -import { - jiraIssueSelectorContract, - jiraIssuesSelectorContract, -} from '@/lib/api/contracts/selectors/jira' -import { parseRequest } from '@/lib/api/server' -import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' -import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { getJiraCloudId, parseAtlassianErrorMessage } from '@/tools/jira/utils' - -export const dynamic = 'force-dynamic' - -const logger = createLogger('JiraIssuesAPI') - -const createErrorResponse = async (response: Response) => { - const errorText = await response.text().catch(() => '') - return parseAtlassianErrorMessage(response.status, response.statusText, errorText) -} - -export const POST = withRouteHandler(async (request: NextRequest) => { - try { - const auth = await checkSessionOrInternalAuth(request) - if (!auth.success || !auth.userId) { - return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) - } - - const parsed = await parseRequest(jiraIssueSelectorContract, request, {}) - if (!parsed.success) return parsed.response - - const { domain, accessToken, issueKeys, cloudId: providedCloudId } = parsed.data.body - - if (issueKeys.length === 0) { - logger.info('No issue keys provided, returning empty result') - return NextResponse.json({ issues: [] }) - } - - const ISSUE_KEY_RE = /^[A-Za-z][A-Za-z0-9_]*-\d+$/ - const sanitizedKeys: string[] = [] - for (const k of issueKeys) { - if (typeof k !== 'string') continue - const trimmed = k.trim() - if (!ISSUE_KEY_RE.test(trimmed)) { - return NextResponse.json({ error: `Invalid Jira issue key: "${trimmed}"` }, { status: 400 }) - } - sanitizedKeys.push(trimmed) - } - if (sanitizedKeys.length === 0) { - return NextResponse.json({ issues: [] }) - } - - const cloudId = providedCloudId || (await getJiraCloudId(domain, accessToken)) - - const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId') - if (!cloudIdValidation.isValid) { - return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 }) - } - - // Use search/jql endpoint (GET) with URL parameters - const jql = `issueKey in (${sanitizedKeys.join(',')})` - const params = new URLSearchParams({ - jql, - fields: 'summary,status,assignee,updated,project', - maxResults: String(Math.min(sanitizedKeys.length, 100)), - }) - const searchUrl = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/search/jql?${params.toString()}` - - const response = await fetch(searchUrl, { - method: 'GET', - headers: { - Authorization: `Bearer ${accessToken}`, - Accept: 'application/json', - }, - }) - - if (!response.ok) { - logger.error(`Jira API error: ${response.status} ${response.statusText}`) - const errorMessage = await createErrorResponse(response) - if (response.status === 401 || response.status === 403) { - return NextResponse.json( - { - error: errorMessage, - authRequired: true, - requiredScopes: ['read:jira-work'], - }, - { status: response.status } - ) - } - return NextResponse.json({ error: errorMessage }, { status: response.status }) - } - - const data = await response.json() - const issues = (data.issues || []).map((it: any) => ({ - id: it.key, - name: it.fields?.summary || it.key, - mimeType: 'jira/issue', - url: `https://${domain}/browse/${it.key}`, - modifiedTime: it.fields?.updated, - webViewLink: `https://${domain}/browse/${it.key}`, - })) - - return NextResponse.json({ issues, cloudId }) - } catch (error) { - logger.error('Error fetching Jira issues:', error) - return NextResponse.json( - { error: (error as Error).message || 'Internal server error' }, - { status: 500 } - ) - } -}) - -export const GET = withRouteHandler(async (request: NextRequest) => { - try { - const auth = await checkSessionOrInternalAuth(request) - if (!auth.success || !auth.userId) { - return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) - } - - const parsed = await parseRequest(jiraIssuesSelectorContract, request, {}) - if (!parsed.success) return parsed.response - - const { - domain, - accessToken, - cloudId: providedCloudId, - query = '', - projectId = '', - manualProjectId = '', - all, - limit, - } = parsed.data.query - - const cloudId = providedCloudId || (await getJiraCloudId(domain, accessToken)) - - const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId') - if (!cloudIdValidation.isValid) { - return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 }) - } - - if (projectId) { - const projectIdValidation = validateAlphanumericId(projectId, 'projectId', 100) - if (!projectIdValidation.isValid) { - return NextResponse.json({ error: projectIdValidation.error }, { status: 400 }) - } - } - if (manualProjectId) { - const manualProjectIdValidation = validateAlphanumericId( - manualProjectId, - 'manualProjectId', - 100 - ) - if (!manualProjectIdValidation.isValid) { - return NextResponse.json({ error: manualProjectIdValidation.error }, { status: 400 }) - } - } - - let data: any - - if (query || projectId || manualProjectId) { - const SAFETY_CAP = 1000 - const PAGE_SIZE = 100 - const target = Math.min(all ? limit || SAFETY_CAP : 25, SAFETY_CAP) - const projectKey = (projectId || manualProjectId || '').trim() - - const escapeJql = (s: string) => s.replace(/\\/g, '\\\\').replace(/"/g, '\\"') - - const buildUrl = (token?: string) => { - const jqlParts: string[] = [] - if (projectKey) jqlParts.push(`project = "${escapeJql(projectKey)}"`) - if (query) { - const q = escapeJql(query) - jqlParts.push(`(key ~ "${q}" OR summary ~ "${q}")`) - } - const jql = `${jqlParts.length ? `${jqlParts.join(' AND ')} ` : ''}ORDER BY updated DESC` - const params = new URLSearchParams({ - jql, - fields: 'summary,key,updated', - maxResults: String(Math.min(PAGE_SIZE, target)), - }) - if (token) params.set('nextPageToken', token) - return `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/search/jql?${params.toString()}` - } - - let nextPageToken: string | undefined - let collected: any[] = [] - - do { - const apiUrl = buildUrl(nextPageToken) - const response = await fetch(apiUrl, { - method: 'GET', - headers: { - Authorization: `Bearer ${accessToken}`, - Accept: 'application/json', - }, - }) - - if (!response.ok) { - const errorMessage = await createErrorResponse(response) - if (response.status === 401 || response.status === 403) { - return NextResponse.json( - { - error: errorMessage, - authRequired: true, - requiredScopes: ['read:jira-work'], - }, - { status: response.status } - ) - } - return NextResponse.json({ error: errorMessage }, { status: response.status }) - } - - const page = await response.json() - const issues = page.issues || [] - collected = collected.concat(issues) - nextPageToken = page.nextPageToken - if (!nextPageToken || issues.length === 0) break - } while (all && collected.length < target) - - const issues = collected.slice(0, target).map((it: any) => ({ - key: it.key, - summary: it.fields?.summary || it.key, - })) - data = { sections: [{ issues }], cloudId } - } else { - data = { sections: [], cloudId } - } - - return NextResponse.json({ ...data, cloudId }) - } catch (error) { - logger.error('Error fetching Jira issue suggestions:', error) - return NextResponse.json( - { error: (error as Error).message || 'Internal server error' }, - { status: 500 } - ) - } -}) diff --git a/apps/sim/app/api/tools/jira/projects/route.ts b/apps/sim/app/api/tools/jira/projects/route.ts deleted file mode 100644 index 2ee1244cb2f..00000000000 --- a/apps/sim/app/api/tools/jira/projects/route.ts +++ /dev/null @@ -1,251 +0,0 @@ -import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' -import { - jiraProjectSelectorContract, - jiraProjectsSelectorContract, -} from '@/lib/api/contracts/selectors/jira' -import { parseRequest } from '@/lib/api/server' -import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' -import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { getJiraCloudId, parseAtlassianErrorMessage } from '@/tools/jira/utils' - -export const dynamic = 'force-dynamic' - -const logger = createLogger('JiraProjectsAPI') - -const JIRA_PROJECTS_PAGE_SIZE = 50 -const MAX_JIRA_PROJECTS_PAGES = 40 - -interface JiraProjectSearchPage { - values?: unknown[] - isLast?: boolean - maxResults?: number -} - -/** - * Drains the offset-paginated Jira `/project/search` endpoint, advancing - * `startAt` by the server-returned page size until `isLast === true` (or a short - * page is seen). Bounded by `MAX_JIRA_PROJECTS_PAGES`; emits a `logger.warn` and - * returns the partial set rather than looping unbounded when the cap is hit. - */ -async function fetchAllJiraProjects( - apiUrl: string, - baseParams: URLSearchParams, - accessToken: string -): Promise<{ values: unknown[]; lastResponse: Response }> { - const values: unknown[] = [] - let startAt = 0 - let lastResponse: Response - - for (let page = 0; page < MAX_JIRA_PROJECTS_PAGES; page++) { - const params = new URLSearchParams(baseParams) - params.set('startAt', String(startAt)) - params.set('maxResults', String(JIRA_PROJECTS_PAGE_SIZE)) - - const finalUrl = `${apiUrl}?${params.toString()}` - logger.info(`Fetching Jira projects from: ${finalUrl}`) - - const response = await fetch(finalUrl, { - method: 'GET', - headers: { - Authorization: `Bearer ${accessToken}`, - Accept: 'application/json', - }, - }) - - logger.info(`Response status: ${response.status} ${response.statusText}`) - - if (!response.ok) { - return { values, lastResponse: response } - } - - const data = (await response.json()) as JiraProjectSearchPage - lastResponse = response - - const pageValues = data.values ?? [] - values.push(...pageValues) - - const pageSize = - data.maxResults && data.maxResults > 0 ? data.maxResults : JIRA_PROJECTS_PAGE_SIZE - if (data.isLast === true || pageValues.length < pageSize) { - return { values, lastResponse } - } - - startAt += pageValues.length - - if (page === MAX_JIRA_PROJECTS_PAGES - 1) { - logger.warn('Jira project search hit pagination cap; project list may be incomplete', { - pages: MAX_JIRA_PROJECTS_PAGES, - collected: values.length, - }) - } - } - - return { values, lastResponse: lastResponse! } -} - -export const GET = withRouteHandler(async (request: NextRequest) => { - try { - const auth = await checkSessionOrInternalAuth(request) - if (!auth.success || !auth.userId) { - return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) - } - - const parsed = await parseRequest(jiraProjectsSelectorContract, request, {}) - if (!parsed.success) return parsed.response - - const { domain, accessToken, cloudId: providedCloudId, query = '' } = parsed.data.query - - if (!domain) { - return NextResponse.json({ error: 'Domain is required' }, { status: 400 }) - } - - if (!accessToken) { - return NextResponse.json({ error: 'Access token is required' }, { status: 400 }) - } - - const cloudId = providedCloudId || (await getJiraCloudId(domain, accessToken)) - logger.info(`Using cloud ID: ${cloudId}`) - - const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId') - if (!cloudIdValidation.isValid) { - return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 }) - } - - const apiUrl = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/project/search` - - const queryParams = new URLSearchParams() - if (query) { - queryParams.append('query', query) - } - queryParams.append('orderBy', 'name') - queryParams.append('expand', 'description,lead,url,projectKeys') - - const { values, lastResponse } = await fetchAllJiraProjects(apiUrl, queryParams, accessToken) - - if (!lastResponse.ok) { - const errorText = await lastResponse.text() - logger.error('Jira API error:', { status: lastResponse.status, error: errorText }) - return NextResponse.json( - { - error: parseAtlassianErrorMessage( - lastResponse.status, - lastResponse.statusText, - errorText - ), - }, - { status: lastResponse.status } - ) - } - - logger.info(`Jira API Response Status: ${lastResponse.status}`) - logger.info(`Found projects: ${values.length}`) - - const projects = - values.map((project: any) => ({ - id: project.id, - key: project.key, - name: project.name, - url: project.self, - avatarUrl: project.avatarUrls?.['48x48'], - description: project.description, - projectTypeKey: project.projectTypeKey, - simplified: project.simplified, - style: project.style, - isPrivate: project.isPrivate, - })) || [] - - return NextResponse.json({ - projects, - cloudId, - }) - } catch (error) { - logger.error('Error fetching Jira projects:', error) - return NextResponse.json( - { error: (error as Error).message || 'Internal server error' }, - { status: 500 } - ) - } -}) - -export const POST = withRouteHandler(async (request: NextRequest) => { - try { - const auth = await checkSessionOrInternalAuth(request) - if (!auth.success || !auth.userId) { - return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) - } - - const parsed = await parseRequest(jiraProjectSelectorContract, request, {}) - if (!parsed.success) return parsed.response - - const { domain, accessToken, projectId, cloudId: providedCloudId } = parsed.data.body - - if (!domain) { - return NextResponse.json({ error: 'Domain is required' }, { status: 400 }) - } - - if (!accessToken) { - return NextResponse.json({ error: 'Access token is required' }, { status: 400 }) - } - - if (!projectId) { - return NextResponse.json({ error: 'Project ID is required' }, { status: 400 }) - } - - const cloudId = providedCloudId || (await getJiraCloudId(domain, accessToken)) - - const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId') - if (!cloudIdValidation.isValid) { - return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 }) - } - - const projectIdValidation = validateAlphanumericId(projectId, 'projectId', 100) - if (!projectIdValidation.isValid) { - return NextResponse.json({ error: projectIdValidation.error }, { status: 400 }) - } - - const apiUrl = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/project/${projectId}` - - const response = await fetch(apiUrl, { - method: 'GET', - headers: { - Authorization: `Bearer ${accessToken}`, - Accept: 'application/json', - }, - }) - - if (!response.ok) { - const errorText = await response.text() - logger.error('Jira API error:', { status: response.status, error: errorText }) - return NextResponse.json( - { error: parseAtlassianErrorMessage(response.status, response.statusText, errorText) }, - { status: response.status } - ) - } - - const project = await response.json() - - return NextResponse.json({ - project: { - id: project.id, - key: project.key, - name: project.name, - url: project.self, - avatarUrl: project.avatarUrls?.['48x48'], - description: project.description, - projectTypeKey: project.projectTypeKey, - simplified: project.simplified, - style: project.style, - isPrivate: project.isPrivate, - }, - cloudId, - }) - } catch (error) { - logger.error('Error fetching Jira project:', error) - return NextResponse.json( - { error: (error as Error).message || 'Internal server error' }, - { status: 500 } - ) - } -}) diff --git a/apps/sim/app/api/tools/jsm/selector-requesttypes/route.ts b/apps/sim/app/api/tools/jsm/selector-requesttypes/route.ts deleted file mode 100644 index 705dc2be2e0..00000000000 --- a/apps/sim/app/api/tools/jsm/selector-requesttypes/route.ts +++ /dev/null @@ -1,177 +0,0 @@ -import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' -import { jsmRequestTypesSelectorContract } from '@/lib/api/contracts/selectors/jsm' -import { parseRequest } from '@/lib/api/server' -import { authorizeCredentialUse } from '@/lib/auth/credential-access' -import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service' -import { getJiraCloudId, parseAtlassianErrorMessage } from '@/tools/jira/utils' -import { getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils' - -const logger = createLogger('JsmSelectorRequestTypesAPI') - -export const dynamic = 'force-dynamic' - -const JSM_REQUEST_TYPES_PAGE_SIZE = 100 -const MAX_JSM_REQUEST_TYPES_PAGES = 50 - -interface JsmPagedResponse { - values?: T[] - isLastPage?: boolean - _links?: { next?: string } -} - -interface JsmRequestTypeValue { - id: string - name: string -} - -/** - * Drains the offset-paginated JSM `/servicedesk/{id}/requesttype` endpoint, - * advancing `start` by the number of rows actually returned until - * `isLastPage === true` (or `_links.next` is absent, or a page comes back - * empty). Advancing by the real row count — not the requested `limit` — - * prevents skipping items if the server returns a short non-final page. Bounded - * by `MAX_JSM_REQUEST_TYPES_PAGES`; emits a `logger.warn` and returns the - * partial set rather than looping unbounded when the cap is hit. - */ -async function fetchAllJsmRequestTypes( - requestTypeUrl: string, - accessToken: string -): Promise<{ values: JsmRequestTypeValue[]; lastResponse: Response }> { - const values: JsmRequestTypeValue[] = [] - let start = 0 - let lastResponse: Response - - for (let page = 0; page < MAX_JSM_REQUEST_TYPES_PAGES; page++) { - const url = `${requestTypeUrl}?start=${start}&limit=${JSM_REQUEST_TYPES_PAGE_SIZE}` - - const response = await fetch(url, { - method: 'GET', - headers: getJsmHeaders(accessToken), - }) - - if (!response.ok) { - return { values, lastResponse: response } - } - - const data = (await response.json()) as JsmPagedResponse - lastResponse = response - - const pageValues = data.values ?? [] - values.push(...pageValues) - - if (data.isLastPage === true || !data._links?.next || pageValues.length === 0) { - return { values, lastResponse } - } - - start += pageValues.length - - if (page === MAX_JSM_REQUEST_TYPES_PAGES - 1) { - logger.warn('JSM request type list hit pagination cap; list may be incomplete', { - pages: MAX_JSM_REQUEST_TYPES_PAGES, - collected: values.length, - }) - } - } - - return { values, lastResponse: lastResponse! } -} - -export const POST = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - try { - const parsed = await parseRequest(jsmRequestTypesSelectorContract, request, {}) - if (!parsed.success) return parsed.response - - const { credential, workflowId, domain, serviceDeskId } = parsed.data.body - - if (!credential) { - logger.error('Missing credential in request') - return NextResponse.json({ error: 'Credential is required' }, { status: 400 }) - } - - if (!domain) { - return NextResponse.json({ error: 'Domain is required' }, { status: 400 }) - } - - if (!serviceDeskId) { - return NextResponse.json({ error: 'Service Desk ID is required' }, { status: 400 }) - } - - const serviceDeskIdValidation = validateAlphanumericId(serviceDeskId, 'serviceDeskId') - if (!serviceDeskIdValidation.isValid) { - return NextResponse.json({ error: serviceDeskIdValidation.error }, { status: 400 }) - } - - const authz = await authorizeCredentialUse(request, { - credentialId: credential, - workflowId, - }) - if (!authz.ok || !authz.credentialOwnerUserId) { - return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) - } - - const accessToken = await refreshAccessTokenIfNeeded( - credential, - authz.credentialOwnerUserId, - requestId - ) - if (!accessToken) { - logger.error('Failed to get access token', { - credentialId: credential, - userId: authz.credentialOwnerUserId, - }) - return NextResponse.json( - { error: 'Could not retrieve access token', authRequired: true }, - { status: 401 } - ) - } - - const cloudId = await getJiraCloudId(domain, accessToken) - - const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId') - if (!cloudIdValidation.isValid) { - return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 }) - } - - const baseUrl = getJsmApiBaseUrl(cloudIdValidation.sanitized!) - const requestTypeUrl = `${baseUrl}/servicedesk/${serviceDeskIdValidation.sanitized}/requesttype` - - const { values, lastResponse } = await fetchAllJsmRequestTypes(requestTypeUrl, accessToken) - - if (!lastResponse.ok) { - const errorText = await lastResponse.text() - logger.error('JSM API error:', { - status: lastResponse.status, - statusText: lastResponse.statusText, - error: errorText, - }) - return NextResponse.json( - { - error: parseAtlassianErrorMessage( - lastResponse.status, - lastResponse.statusText, - errorText - ), - }, - { status: lastResponse.status } - ) - } - - const requestTypes = values.map((rt) => ({ - id: rt.id, - name: rt.name, - })) - - return NextResponse.json({ requestTypes }) - } catch (error) { - logger.error('Error listing JSM request types:', error) - return NextResponse.json( - { error: (error as Error).message || 'Internal server error' }, - { status: 500 } - ) - } -}) diff --git a/apps/sim/app/api/tools/jsm/selector-servicedesks/route.ts b/apps/sim/app/api/tools/jsm/selector-servicedesks/route.ts deleted file mode 100644 index 9dc55ea0a83..00000000000 --- a/apps/sim/app/api/tools/jsm/selector-servicedesks/route.ts +++ /dev/null @@ -1,167 +0,0 @@ -import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' -import { jsmServiceDesksSelectorContract } from '@/lib/api/contracts/selectors/jsm' -import { parseRequest } from '@/lib/api/server' -import { authorizeCredentialUse } from '@/lib/auth/credential-access' -import { validateJiraCloudId } from '@/lib/core/security/input-validation' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service' -import { getJiraCloudId, parseAtlassianErrorMessage } from '@/tools/jira/utils' -import { getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils' - -const logger = createLogger('JsmSelectorServiceDesksAPI') - -export const dynamic = 'force-dynamic' - -const JSM_SERVICE_DESKS_PAGE_SIZE = 100 -const MAX_JSM_SERVICE_DESKS_PAGES = 50 - -interface JsmPagedResponse { - values?: T[] - isLastPage?: boolean - _links?: { next?: string } -} - -interface JsmServiceDeskValue { - id: string - projectName: string -} - -/** - * Drains the offset-paginated JSM `/servicedesk` endpoint, advancing `start` by - * the number of rows actually returned until `isLastPage === true` (or - * `_links.next` is absent, or a page comes back empty). Advancing by the real - * row count — not the requested `limit` — prevents skipping items if the server - * returns a short non-final page. Bounded by `MAX_JSM_SERVICE_DESKS_PAGES`; - * emits a `logger.warn` and returns the partial set rather than looping - * unbounded when the cap is hit. - */ -async function fetchAllJsmServiceDesks( - baseUrl: string, - accessToken: string -): Promise<{ values: JsmServiceDeskValue[]; lastResponse: Response }> { - const values: JsmServiceDeskValue[] = [] - let start = 0 - let lastResponse: Response - - for (let page = 0; page < MAX_JSM_SERVICE_DESKS_PAGES; page++) { - const url = `${baseUrl}/servicedesk?start=${start}&limit=${JSM_SERVICE_DESKS_PAGE_SIZE}` - - const response = await fetch(url, { - method: 'GET', - headers: getJsmHeaders(accessToken), - }) - - if (!response.ok) { - return { values, lastResponse: response } - } - - const data = (await response.json()) as JsmPagedResponse - lastResponse = response - - const pageValues = data.values ?? [] - values.push(...pageValues) - - if (data.isLastPage === true || !data._links?.next || pageValues.length === 0) { - return { values, lastResponse } - } - - start += pageValues.length - - if (page === MAX_JSM_SERVICE_DESKS_PAGES - 1) { - logger.warn('JSM service desk list hit pagination cap; list may be incomplete', { - pages: MAX_JSM_SERVICE_DESKS_PAGES, - collected: values.length, - }) - } - } - - return { values, lastResponse: lastResponse! } -} - -export const POST = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - try { - const parsed = await parseRequest(jsmServiceDesksSelectorContract, request, {}) - if (!parsed.success) return parsed.response - - const { credential, workflowId, domain } = parsed.data.body - - if (!credential) { - logger.error('Missing credential in request') - return NextResponse.json({ error: 'Credential is required' }, { status: 400 }) - } - - if (!domain) { - return NextResponse.json({ error: 'Domain is required' }, { status: 400 }) - } - - const authz = await authorizeCredentialUse(request, { - credentialId: credential, - workflowId, - }) - if (!authz.ok || !authz.credentialOwnerUserId) { - return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) - } - - const accessToken = await refreshAccessTokenIfNeeded( - credential, - authz.credentialOwnerUserId, - requestId - ) - if (!accessToken) { - logger.error('Failed to get access token', { - credentialId: credential, - userId: authz.credentialOwnerUserId, - }) - return NextResponse.json( - { error: 'Could not retrieve access token', authRequired: true }, - { status: 401 } - ) - } - - const cloudId = await getJiraCloudId(domain, accessToken) - - const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId') - if (!cloudIdValidation.isValid) { - return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 }) - } - - const baseUrl = getJsmApiBaseUrl(cloudIdValidation.sanitized!) - - const { values, lastResponse } = await fetchAllJsmServiceDesks(baseUrl, accessToken) - - if (!lastResponse.ok) { - const errorText = await lastResponse.text() - logger.error('JSM API error:', { - status: lastResponse.status, - statusText: lastResponse.statusText, - error: errorText, - }) - return NextResponse.json( - { - error: parseAtlassianErrorMessage( - lastResponse.status, - lastResponse.statusText, - errorText - ), - }, - { status: lastResponse.status } - ) - } - - const serviceDesks = values.map((sd) => ({ - id: sd.id, - name: sd.projectName, - })) - - return NextResponse.json({ serviceDesks }) - } catch (error) { - logger.error('Error listing JSM service desks:', error) - return NextResponse.json( - { error: (error as Error).message || 'Internal server error' }, - { status: 500 } - ) - } -}) diff --git a/apps/sim/app/api/tools/linear/projects/route.ts b/apps/sim/app/api/tools/linear/projects/route.ts deleted file mode 100644 index 360453b615a..00000000000 --- a/apps/sim/app/api/tools/linear/projects/route.ts +++ /dev/null @@ -1,140 +0,0 @@ -import type { Project, Team } from '@linear/sdk' -import { LinearClient } from '@linear/sdk' -import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' -import { linearProjectsSelectorContract } from '@/lib/api/contracts/selectors' -import { parseRequest } from '@/lib/api/server' -import { authorizeCredentialUse } from '@/lib/auth/credential-access' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service' - -export const dynamic = 'force-dynamic' - -const logger = createLogger('LinearProjectsAPI') - -/** Linear's maximum page size for a single connection request. */ -const LINEAR_PAGE_SIZE = 250 - -/** - * Upper bound on pages to drain from a single team's projects connection. At - * 250 projects/page this covers 2,500 projects per team; the cap guards - * against runaway loops on a broken `hasNextPage` rather than a realistic - * limit. - */ -const MAX_PROJECTS_PAGES = 10 - -/** - * Drains a single team's projects connection by following - * `pageInfo.endCursor` until `hasNextPage` is false. Bounded by - * `MAX_PROJECTS_PAGES`; logs a warning if the cap is hit so a truncated list - * is visible rather than silently dropped. - */ -async function fetchAllTeamProjects(team: Team): Promise { - const projects: Project[] = [] - let after: string | undefined - - for (let page = 0; page < MAX_PROJECTS_PAGES; page++) { - const result = await team.projects({ first: LINEAR_PAGE_SIZE, after }) - projects.push(...result.nodes) - - if (!result.pageInfo.hasNextPage) { - return projects - } - after = result.pageInfo.endCursor ?? undefined - if (!after) { - return projects - } - if (page === MAX_PROJECTS_PAGES - 1) { - logger.warn('Linear projects pagination hit cap; project list may be incomplete', { - teamId: team.id, - cap: MAX_PROJECTS_PAGES, - fetched: projects.length, - }) - } - } - - return projects -} - -export const POST = withRouteHandler(async (request: NextRequest) => { - try { - const parsed = await parseRequest(linearProjectsSelectorContract, request, {}) - if (!parsed.success) return parsed.response - const { credential, teamId, workflowId } = parsed.data.body - - const requestId = generateRequestId() - const authz = await authorizeCredentialUse(request, { - credentialId: credential, - workflowId, - }) - if (!authz.ok || !authz.credentialOwnerUserId) { - return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) - } - - const accessToken = await refreshAccessTokenIfNeeded( - credential, - authz.credentialOwnerUserId, - requestId - ) - if (!accessToken) { - logger.error('Failed to get access token', { - credentialId: credential, - userId: authz.credentialOwnerUserId, - }) - return NextResponse.json( - { error: 'Could not retrieve access token', authRequired: true }, - { status: 401 } - ) - } - - const linearClient = accessToken.startsWith('lin_api_') - ? // Personal API keys use the SDK's apiKey option (bare Authorization - // header), matching linearAuthorizationHeader in @/tools/linear/utils. - new LinearClient({ apiKey: accessToken }) - : new LinearClient({ accessToken }) - - /** - * teamId may be a single ID or a comma-separated list when the basic-mode - * team selector is in multi-select. Fetch projects from each team in - * parallel and dedupe by project ID (Linear projects can be cross-team). - */ - const teamIds = teamId - .split(',') - .map((s) => s.trim()) - .filter(Boolean) - - const perTeam = await Promise.all( - teamIds.map(async (id) => { - const team = await linearClient.team(id) - const teamProjects = await fetchAllTeamProjects(team) - return teamProjects.map((project: Project) => ({ - id: project.id, - name: project.name, - })) - }) - ) - - const seen = new Set() - const projects: Array<{ id: string; name: string }> = [] - for (const teamProjects of perTeam) { - for (const project of teamProjects) { - if (seen.has(project.id)) continue - seen.add(project.id) - projects.push(project) - } - } - - if (projects.length === 0) { - logger.info('No projects found for team(s)', { teamIds }) - } - - return NextResponse.json({ projects }) - } catch (error) { - logger.error('Error processing Linear projects request:', error) - return NextResponse.json( - { error: 'Failed to retrieve Linear projects', details: (error as Error).message }, - { status: 500 } - ) - } -}) diff --git a/apps/sim/app/api/tools/linear/teams/route.ts b/apps/sim/app/api/tools/linear/teams/route.ts deleted file mode 100644 index 5d788bc2b07..00000000000 --- a/apps/sim/app/api/tools/linear/teams/route.ts +++ /dev/null @@ -1,108 +0,0 @@ -import type { Team } from '@linear/sdk' -import { LinearClient } from '@linear/sdk' -import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' -import { linearTeamsSelectorContract } from '@/lib/api/contracts/selectors' -import { parseRequest } from '@/lib/api/server' -import { authorizeCredentialUse } from '@/lib/auth/credential-access' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service' - -export const dynamic = 'force-dynamic' - -const logger = createLogger('LinearTeamsAPI') - -/** Linear's maximum page size for a single connection request. */ -const LINEAR_PAGE_SIZE = 250 - -/** - * Upper bound on pages to drain from the teams connection. At 250 teams/page - * this covers 2,500 teams; the cap guards against runaway loops on a broken - * `hasNextPage` rather than a realistic limit. - */ -const MAX_TEAMS_PAGES = 10 - -/** - * Drains the full Linear teams connection by following - * `pageInfo.endCursor` until `hasNextPage` is false. Bounded by - * `MAX_TEAMS_PAGES`; logs a warning if the cap is hit so a truncated list is - * visible rather than silently dropped. - */ -async function fetchAllTeams(linearClient: LinearClient): Promise { - const teams: Team[] = [] - let after: string | undefined - - for (let page = 0; page < MAX_TEAMS_PAGES; page++) { - const result = await linearClient.teams({ first: LINEAR_PAGE_SIZE, after }) - teams.push(...result.nodes) - - if (!result.pageInfo.hasNextPage) { - return teams - } - after = result.pageInfo.endCursor ?? undefined - if (!after) { - return teams - } - if (page === MAX_TEAMS_PAGES - 1) { - logger.warn('Linear teams pagination hit cap; team list may be incomplete', { - cap: MAX_TEAMS_PAGES, - fetched: teams.length, - }) - } - } - - return teams -} - -export const POST = withRouteHandler(async (request: NextRequest) => { - try { - const requestId = generateRequestId() - const parsed = await parseRequest(linearTeamsSelectorContract, request, {}) - if (!parsed.success) return parsed.response - const { credential, workflowId } = parsed.data.body - - const authz = await authorizeCredentialUse(request, { - credentialId: credential, - workflowId, - }) - if (!authz.ok || !authz.credentialOwnerUserId) { - return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) - } - - const accessToken = await refreshAccessTokenIfNeeded( - credential, - authz.credentialOwnerUserId, - requestId - ) - if (!accessToken) { - logger.error('Failed to get access token', { - credentialId: credential, - userId: authz.credentialOwnerUserId, - }) - return NextResponse.json( - { error: 'Could not retrieve access token', authRequired: true }, - { status: 401 } - ) - } - - const linearClient = accessToken.startsWith('lin_api_') - ? // Personal API keys use the SDK's apiKey option (bare Authorization - // header), matching linearAuthorizationHeader in @/tools/linear/utils. - new LinearClient({ apiKey: accessToken }) - : new LinearClient({ accessToken }) - const allTeams = await fetchAllTeams(linearClient) - const teams = allTeams.map((team: Team) => ({ - id: team.id, - name: team.name, - })) - - return NextResponse.json({ teams }) - } catch (error) { - logger.error('Error processing Linear teams request:', error) - return NextResponse.json( - { error: 'Failed to retrieve Linear teams', details: (error as Error).message }, - { status: 500 } - ) - } -}) diff --git a/apps/sim/app/api/tools/microsoft-teams/channels/route.ts b/apps/sim/app/api/tools/microsoft-teams/channels/route.ts deleted file mode 100644 index 27070fb419a..00000000000 --- a/apps/sim/app/api/tools/microsoft-teams/channels/route.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { createLogger } from '@sim/logger' -import { toError } from '@sim/utils/errors' -import { type NextRequest, NextResponse } from 'next/server' -import { microsoftChannelsSelectorContract } from '@/lib/api/contracts/selectors/microsoft' -import { parseRequest } from '@/lib/api/server' -import { authorizeCredentialUse } from '@/lib/auth/credential-access' -import { validateMicrosoftGraphId } from '@/lib/core/security/input-validation' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service' -import { assertGraphNextPageUrl, getGraphNextPageUrl } from '@/tools/sharepoint/utils' - -export const dynamic = 'force-dynamic' - -const logger = createLogger('TeamsChannelsAPI') - -/** - * Upper bound on Microsoft Graph pages drained when listing a team's channels. - * The `teams/{id}/channels` endpoint does not support `$top`, so paging is - * driven entirely by the server via `@odata.nextLink`. The cap prevents an - * unbounded loop; hitting it is logged as a warning. - */ -const MAX_CHANNELS_PAGES = 20 - -interface GraphChannel { - id: string - displayName?: string -} - -export const POST = withRouteHandler(async (request: NextRequest) => { - try { - const parsed = await parseRequest(microsoftChannelsSelectorContract, request, {}) - if (!parsed.success) return parsed.response - const { credential, teamId, workflowId } = parsed.data.body - - const teamIdValidation = validateMicrosoftGraphId(teamId, 'Team ID') - if (!teamIdValidation.isValid) { - logger.warn('Invalid team ID provided', { teamId, error: teamIdValidation.error }) - return NextResponse.json({ error: teamIdValidation.error }, { status: 400 }) - } - - try { - const authz = await authorizeCredentialUse(request, { - credentialId: credential, - workflowId, - }) - if (!authz.ok || !authz.credentialOwnerUserId) { - return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) - } - const accessToken = await refreshAccessTokenIfNeeded( - credential, - authz.credentialOwnerUserId, - 'TeamsChannelsAPI' - ) - - if (!accessToken) { - logger.error('Failed to get access token', { - credentialId: credential, - userId: authz.credentialOwnerUserId, - }) - return NextResponse.json( - { - error: 'Could not retrieve access token', - authRequired: true, - }, - { status: 401 } - ) - } - - const channels: GraphChannel[] = [] - let nextPageUrl: string | undefined = - `https://graph.microsoft.com/v1.0/teams/${encodeURIComponent(teamId)}/channels` - - for (let page = 0; page < MAX_CHANNELS_PAGES; page++) { - const response = await fetch(nextPageUrl, { - method: 'GET', - headers: { - Authorization: `Bearer ${accessToken}`, - 'Content-Type': 'application/json', - }, - }) - - if (!response.ok) { - const errorData = await response.json() - logger.error('Microsoft Graph API error getting channels', { - status: response.status, - error: errorData, - endpoint: nextPageUrl, - }) - - if (response.status === 401) { - return NextResponse.json( - { - error: 'Authentication failed. Please reconnect your Microsoft Teams account.', - authRequired: true, - }, - { status: 401 } - ) - } - - throw new Error(`Microsoft Graph API error: ${JSON.stringify(errorData)}`) - } - - const data = await response.json() - if (Array.isArray(data.value)) { - channels.push(...(data.value as GraphChannel[])) - } - - const rawNextLink = getGraphNextPageUrl(data) - if (!rawNextLink) { - nextPageUrl = undefined - break - } - nextPageUrl = assertGraphNextPageUrl(rawNextLink) - - if (page === MAX_CHANNELS_PAGES - 1) { - logger.warn( - 'Hit Microsoft Graph channels pagination cap; channel list may be incomplete', - { maxPages: MAX_CHANNELS_PAGES, collected: channels.length } - ) - } - } - - return NextResponse.json({ - channels: channels, - }) - } catch (innerError) { - logger.error('Error during API requests:', innerError) - - const errorMessage = toError(innerError).message - if ( - errorMessage.includes('auth') || - errorMessage.includes('token') || - errorMessage.includes('unauthorized') || - errorMessage.includes('unauthenticated') - ) { - return NextResponse.json( - { - error: 'Authentication failed. Please reconnect your Microsoft Teams account.', - authRequired: true, - details: errorMessage, - }, - { status: 401 } - ) - } - - throw innerError - } - } catch (error) { - logger.error('Error processing Channels request:', error) - return NextResponse.json( - { - error: 'Failed to retrieve Microsoft Teams channels', - details: (error as Error).message, - }, - { status: 500 } - ) - } -}) diff --git a/apps/sim/app/api/tools/microsoft-teams/chats/route.ts b/apps/sim/app/api/tools/microsoft-teams/chats/route.ts deleted file mode 100644 index afc5fc7668d..00000000000 --- a/apps/sim/app/api/tools/microsoft-teams/chats/route.ts +++ /dev/null @@ -1,270 +0,0 @@ -import { createLogger } from '@sim/logger' -import { toError } from '@sim/utils/errors' -import { type NextRequest, NextResponse } from 'next/server' -import { microsoftChatsSelectorContract } from '@/lib/api/contracts/selectors/microsoft' -import { parseRequest } from '@/lib/api/server' -import { authorizeCredentialUse } from '@/lib/auth/credential-access' -import { validateMicrosoftGraphId } from '@/lib/core/security/input-validation' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service' -import { assertGraphNextPageUrl, getGraphNextPageUrl } from '@/tools/sharepoint/utils' - -export const dynamic = 'force-dynamic' - -const logger = createLogger('TeamsChatsAPI') - -/** - * Largest page size the `me/chats` Microsoft Graph endpoint permits via `$top`. - */ -const CHATS_PAGE_SIZE = 50 - -/** - * Upper bound on Microsoft Graph pages drained when listing the user's chats. - * Paging follows `@odata.nextLink`. The cap prevents an unbounded loop; hitting - * it is logged as a warning. - */ -const MAX_CHATS_PAGES = 20 - -interface GraphChat { - id: string - topic?: string -} - -/** - * Helper function to get chat members and create a meaningful name - * - * @param chatId - Microsoft Teams chat ID to get display name for - * @param accessToken - Access token for Microsoft Graph API - * @param chatTopic - Optional existing chat topic - * @returns A meaningful display name for the chat - */ -const getChatDisplayName = async ( - chatId: string, - accessToken: string, - chatTopic?: string -): Promise => { - try { - const chatIdValidation = validateMicrosoftGraphId(chatId, 'chatId') - if (!chatIdValidation.isValid) { - logger.warn('Invalid chat ID in getChatDisplayName', { - error: chatIdValidation.error, - chatId: chatId.substring(0, 50), - }) - return `Chat ${chatId.substring(0, 8)}...` - } - - if (chatTopic?.trim() && chatTopic !== 'null') { - return chatTopic - } - - const membersResponse = await fetch( - `https://graph.microsoft.com/v1.0/chats/${encodeURIComponent(chatId)}/members`, - { - method: 'GET', - headers: { - Authorization: `Bearer ${accessToken}`, - 'Content-Type': 'application/json', - }, - } - ) - - if (membersResponse.ok) { - const membersData = await membersResponse.json() - const members = membersData.value || [] - - const memberNames = members - .filter((member: any) => member.displayName && member.displayName !== 'Unknown') - .map((member: any) => member.displayName) - .slice(0, 3) - - if (memberNames.length > 0) { - if (memberNames.length === 1) { - return memberNames[0] - } - if (memberNames.length === 2) { - return memberNames.join(' & ') - } - return `${memberNames.slice(0, 2).join(', ')} & ${memberNames.length - 2} more` - } - } - - try { - const messagesResponse = await fetch( - `https://graph.microsoft.com/v1.0/chats/${encodeURIComponent(chatId)}/messages?$top=10&$orderby=createdDateTime desc`, - { - method: 'GET', - headers: { - Authorization: `Bearer ${accessToken}`, - 'Content-Type': 'application/json', - }, - } - ) - - if (messagesResponse.ok) { - const messagesData = await messagesResponse.json() - const messages = messagesData.value || [] - - for (const message of messages) { - if (message.eventDetail?.chatDisplayName) { - return message.eventDetail.chatDisplayName - } - } - - const senderNames = [ - ...new Set( - messages - .filter( - (msg: any) => msg.from?.user?.displayName && msg.from.user.displayName !== 'Unknown' - ) - .map((msg: any) => msg.from.user.displayName) - ), - ].slice(0, 3) - - if (senderNames.length > 0) { - if (senderNames.length === 1) { - return senderNames[0] as string - } - if (senderNames.length === 2) { - return senderNames.join(' & ') - } - return `${senderNames.slice(0, 2).join(', ')} & ${senderNames.length - 2} more` - } - } - } catch (error) { - logger.warn( - `Failed to get better name from messages for chat ${chatId}: ${toError(error).message}` - ) - } - - return `Chat ${chatId.split(':')[0] || chatId.substring(0, 8)}...` - } catch (error) { - logger.warn(`Failed to get display name for chat ${chatId}: ${toError(error).message}`) - return `Chat ${chatId.split(':')[0] || chatId.substring(0, 8)}...` - } -} - -export const POST = withRouteHandler(async (request: NextRequest) => { - try { - const parsed = await parseRequest(microsoftChatsSelectorContract, request, {}) - if (!parsed.success) return parsed.response - const { credential, workflowId } = parsed.data.body - - try { - const authz = await authorizeCredentialUse(request, { - credentialId: credential, - workflowId, - }) - if (!authz.ok || !authz.credentialOwnerUserId) { - return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) - } - const accessToken = await refreshAccessTokenIfNeeded( - credential, - authz.credentialOwnerUserId, - 'TeamsChatsAPI' - ) - - if (!accessToken) { - logger.error('Failed to get access token', { - credentialId: credential, - userId: authz.credentialOwnerUserId, - }) - return NextResponse.json({ error: 'Could not retrieve access token' }, { status: 401 }) - } - - const rawChats: GraphChat[] = [] - let nextPageUrl: string | undefined = - `https://graph.microsoft.com/v1.0/me/chats?$top=${CHATS_PAGE_SIZE}` - - for (let page = 0; page < MAX_CHATS_PAGES; page++) { - const response = await fetch(nextPageUrl, { - method: 'GET', - headers: { - Authorization: `Bearer ${accessToken}`, - 'Content-Type': 'application/json', - }, - }) - - if (!response.ok) { - const errorData = await response.json() - logger.error('Microsoft Graph API error getting chats', { - status: response.status, - error: errorData, - endpoint: nextPageUrl, - }) - - if (response.status === 401) { - return NextResponse.json( - { - error: 'Authentication failed. Please reconnect your Microsoft Teams account.', - authRequired: true, - }, - { status: 401 } - ) - } - - throw new Error(`Microsoft Graph API error: ${JSON.stringify(errorData)}`) - } - - const data = await response.json() - if (Array.isArray(data.value)) { - rawChats.push(...(data.value as GraphChat[])) - } - - const rawNextLink = getGraphNextPageUrl(data) - if (!rawNextLink) { - nextPageUrl = undefined - break - } - nextPageUrl = assertGraphNextPageUrl(rawNextLink) - - if (page === MAX_CHATS_PAGES - 1) { - logger.warn('Hit Microsoft Graph chats pagination cap; chat list may be incomplete', { - maxPages: MAX_CHATS_PAGES, - collected: rawChats.length, - }) - } - } - - const chats = await Promise.all( - rawChats.map(async (chat) => ({ - id: chat.id, - displayName: await getChatDisplayName(chat.id, accessToken, chat.topic), - })) - ) - - return NextResponse.json({ - chats: chats, - }) - } catch (innerError) { - logger.error('Error during API requests:', innerError) - - const errorMessage = toError(innerError).message - if ( - errorMessage.includes('auth') || - errorMessage.includes('token') || - errorMessage.includes('unauthorized') || - errorMessage.includes('unauthenticated') - ) { - return NextResponse.json( - { - error: 'Authentication failed. Please reconnect your Microsoft Teams account.', - authRequired: true, - details: errorMessage, - }, - { status: 401 } - ) - } - - throw innerError - } - } catch (error) { - logger.error('Error processing Chats request:', error) - return NextResponse.json( - { - error: 'Failed to retrieve Microsoft Teams chats', - details: (error as Error).message, - }, - { status: 500 } - ) - } -}) diff --git a/apps/sim/app/api/tools/microsoft-teams/teams/route.ts b/apps/sim/app/api/tools/microsoft-teams/teams/route.ts deleted file mode 100644 index 7206e85ab24..00000000000 --- a/apps/sim/app/api/tools/microsoft-teams/teams/route.ts +++ /dev/null @@ -1,153 +0,0 @@ -import { createLogger } from '@sim/logger' -import { toError } from '@sim/utils/errors' -import { type NextRequest, NextResponse } from 'next/server' -import { microsoftTeamsSelectorContract } from '@/lib/api/contracts/selectors/microsoft' -import { parseRequest } from '@/lib/api/server' -import { authorizeCredentialUse } from '@/lib/auth/credential-access' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service' -import { assertGraphNextPageUrl, getGraphNextPageUrl } from '@/tools/sharepoint/utils' - -export const dynamic = 'force-dynamic' - -const logger = createLogger('TeamsTeamsAPI') - -/** - * Upper bound on Microsoft Graph pages drained when listing the user's joined - * teams. The `me/joinedTeams` endpoint does not support `$top`, so paging is - * driven entirely by the server via `@odata.nextLink`. The cap prevents an - * unbounded loop; hitting it is logged as a warning. - */ -const MAX_TEAMS_PAGES = 20 - -interface GraphTeam { - id: string - displayName?: string -} - -export const POST = withRouteHandler(async (request: NextRequest) => { - try { - const parsed = await parseRequest(microsoftTeamsSelectorContract, request, {}) - if (!parsed.success) return parsed.response - const { credential, workflowId } = parsed.data.body - - try { - const authz = await authorizeCredentialUse(request, { - credentialId: credential, - workflowId, - }) - if (!authz.ok || !authz.credentialOwnerUserId) { - return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) - } - - const accessToken = await refreshAccessTokenIfNeeded( - credential, - authz.credentialOwnerUserId, - 'TeamsTeamsAPI' - ) - - if (!accessToken) { - logger.error('Failed to get access token', { - credentialId: credential, - userId: authz.credentialOwnerUserId, - }) - return NextResponse.json( - { - error: 'Could not retrieve access token', - authRequired: true, - }, - { status: 401 } - ) - } - - const teams: GraphTeam[] = [] - let nextPageUrl: string | undefined = 'https://graph.microsoft.com/v1.0/me/joinedTeams' - - for (let page = 0; page < MAX_TEAMS_PAGES; page++) { - const response = await fetch(nextPageUrl, { - method: 'GET', - headers: { - Authorization: `Bearer ${accessToken}`, - 'Content-Type': 'application/json', - }, - }) - - if (!response.ok) { - const errorData = await response.json() - logger.error('Microsoft Graph API error getting teams', { - status: response.status, - error: errorData, - endpoint: nextPageUrl, - }) - - // Check for auth errors specifically - if (response.status === 401) { - return NextResponse.json( - { - error: 'Authentication failed. Please reconnect your Microsoft Teams account.', - authRequired: true, - }, - { status: 401 } - ) - } - - throw new Error(`Microsoft Graph API error: ${JSON.stringify(errorData)}`) - } - - const data = await response.json() - if (Array.isArray(data.value)) { - teams.push(...(data.value as GraphTeam[])) - } - - const rawNextLink = getGraphNextPageUrl(data) - if (!rawNextLink) { - nextPageUrl = undefined - break - } - nextPageUrl = assertGraphNextPageUrl(rawNextLink) - - if (page === MAX_TEAMS_PAGES - 1) { - logger.warn('Hit Microsoft Graph teams pagination cap; team list may be incomplete', { - maxPages: MAX_TEAMS_PAGES, - collected: teams.length, - }) - } - } - - return NextResponse.json({ - teams: teams, - }) - } catch (innerError) { - logger.error('Error during API requests:', innerError) - - // Check if it's an authentication error - const errorMessage = toError(innerError).message - if ( - errorMessage.includes('auth') || - errorMessage.includes('token') || - errorMessage.includes('unauthorized') || - errorMessage.includes('unauthenticated') - ) { - return NextResponse.json( - { - error: 'Authentication failed. Please reconnect your Microsoft Teams account.', - authRequired: true, - details: errorMessage, - }, - { status: 401 } - ) - } - - throw innerError - } - } catch (error) { - logger.error('Error processing Teams request:', error) - return NextResponse.json( - { - error: 'Failed to retrieve Microsoft Teams teams', - details: (error as Error).message, - }, - { status: 500 } - ) - } -}) diff --git a/apps/sim/app/api/tools/microsoft_excel/drives/route.ts b/apps/sim/app/api/tools/microsoft_excel/drives/route.ts deleted file mode 100644 index 99dbd4d9f09..00000000000 --- a/apps/sim/app/api/tools/microsoft_excel/drives/route.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' -import { microsoftExcelDrivesSelectorContract } from '@/lib/api/contracts/selectors/microsoft' -import { parseRequest } from '@/lib/api/server' -import { authorizeCredentialUse } from '@/lib/auth/credential-access' -import { validatePathSegment, validateSharePointSiteId } from '@/lib/core/security/input-validation' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service' -import { extractGraphError, GRAPH_ID_PATTERN } from '@/tools/microsoft_excel/utils' -import { assertGraphNextPageUrl, getGraphNextPageUrl } from '@/tools/sharepoint/utils' - -export const dynamic = 'force-dynamic' - -const logger = createLogger('MicrosoftExcelDrivesAPI') - -/** - * Upper bound on Microsoft Graph pages drained when listing site drives. - * Each page returns up to `$top=999` drives, so this caps the result set at - * roughly 10k drives while preventing an unbounded server-side loop. - */ -const MAX_DRIVES_PAGES = 10 - -interface GraphDrive { - id: string - name: string - driveType: string - webUrl?: string -} - -/** - * List document libraries (drives) for a SharePoint site. - * Used by the microsoft.excel.drives selector to let users pick - * which drive contains their Excel file. - */ -export const POST = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - - try { - const parsed = await parseRequest(microsoftExcelDrivesSelectorContract, request, {}) - if (!parsed.success) return parsed.response - const { credential, workflowId, siteId, driveId } = parsed.data.body - - const siteIdValidation = validateSharePointSiteId(siteId, 'siteId') - if (!siteIdValidation.isValid) { - logger.warn(`[${requestId}] Invalid siteId format`) - return NextResponse.json({ error: siteIdValidation.error }, { status: 400 }) - } - - const authz = await authorizeCredentialUse(request, { - credentialId: credential, - workflowId, - }) - if (!authz.ok || !authz.credentialOwnerUserId) { - return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) - } - - const accessToken = await refreshAccessTokenIfNeeded( - credential, - authz.credentialOwnerUserId, - requestId - ) - if (!accessToken) { - logger.warn(`[${requestId}] Failed to obtain valid access token`) - return NextResponse.json( - { error: 'Failed to obtain valid access token', authRequired: true }, - { status: 401 } - ) - } - - // Single-drive lookup when driveId is provided (used by fetchById) - if (driveId) { - const driveIdValidation = validatePathSegment(driveId, { - paramName: 'driveId', - customPattern: GRAPH_ID_PATTERN, - }) - if (!driveIdValidation.isValid) { - return NextResponse.json({ error: driveIdValidation.error }, { status: 400 }) - } - - const url = `https://graph.microsoft.com/v1.0/sites/${siteId}/drives/${driveId}?$select=id,name,driveType,webUrl` - const response = await fetch(url, { - headers: { Authorization: `Bearer ${accessToken}` }, - }) - - if (!response.ok) { - const errorMessage = await extractGraphError(response) - return NextResponse.json({ error: errorMessage }, { status: response.status }) - } - - const data: GraphDrive = await response.json() - return NextResponse.json( - { drive: { id: data.id, name: data.name, driveType: data.driveType } }, - { status: 200 } - ) - } - - // List all drives for the site - let nextUrl: string | undefined = - `https://graph.microsoft.com/v1.0/sites/${siteId}/drives?$select=id,name,driveType,webUrl&$top=999` - - const rawDrives: GraphDrive[] = [] - for (let page = 0; page < MAX_DRIVES_PAGES && nextUrl; page++) { - const response = await fetch(nextUrl, { - headers: { - Authorization: `Bearer ${accessToken}`, - }, - }) - - if (!response.ok) { - const errorMessage = await extractGraphError(response) - logger.error(`[${requestId}] Microsoft Graph API error fetching drives`, { - status: response.status, - error: errorMessage, - }) - return NextResponse.json({ error: errorMessage }, { status: response.status }) - } - - const data = await response.json() - if (Array.isArray(data.value)) { - rawDrives.push(...data.value) - } - - const nextLink = getGraphNextPageUrl(data) - nextUrl = nextLink ? assertGraphNextPageUrl(nextLink) : undefined - if (nextUrl && page === MAX_DRIVES_PAGES - 1) { - logger.warn( - `[${requestId}] Site drives pagination hit ${MAX_DRIVES_PAGES}-page cap; result may be incomplete` - ) - } - } - - const drives = rawDrives.map((drive: GraphDrive) => ({ - id: drive.id, - name: drive.name, - driveType: drive.driveType, - })) - - logger.info(`[${requestId}] Successfully fetched ${drives.length} drives for site ${siteId}`) - return NextResponse.json({ drives }, { status: 200 }) - } catch (error) { - logger.error(`[${requestId}] Error fetching drives`, error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } -}) diff --git a/apps/sim/app/api/tools/microsoft_excel/sheets/route.ts b/apps/sim/app/api/tools/microsoft_excel/sheets/route.ts deleted file mode 100644 index bd6ff64e8df..00000000000 --- a/apps/sim/app/api/tools/microsoft_excel/sheets/route.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import { type NextRequest, NextResponse } from 'next/server' -import { microsoftExcelSheetsSelectorContract } from '@/lib/api/contracts/selectors/microsoft' -import { parseRequest } from '@/lib/api/server' -import { authorizeCredentialUse } from '@/lib/auth/credential-access' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service' -import { extractGraphError, getItemBasePath } from '@/tools/microsoft_excel/utils' - -export const dynamic = 'force-dynamic' - -const logger = createLogger('MicrosoftExcelAPI') - -interface Worksheet { - id: string - name: string - position: number - visibility: string -} - -interface WorksheetsResponse { - value: Worksheet[] -} - -/** - * Get worksheets (tabs) from a Microsoft Excel workbook - */ -export const GET = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - logger.info(`[${requestId}] Microsoft Excel sheets request received`) - - try { - const parsed = await parseRequest(microsoftExcelSheetsSelectorContract, request, {}) - if (!parsed.success) return parsed.response - const { credentialId, spreadsheetId, driveId, workflowId } = parsed.data.query - - const authz = await authorizeCredentialUse(request, { credentialId, workflowId }) - if (!authz.ok || !authz.credentialOwnerUserId) { - return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) - } - - const accessToken = await refreshAccessTokenIfNeeded( - credentialId, - authz.credentialOwnerUserId, - requestId - ) - - if (!accessToken) { - return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 }) - } - - logger.info( - `[${requestId}] Fetching worksheets from Microsoft Graph API for workbook ${spreadsheetId}` - ) - - let basePath: string - try { - basePath = getItemBasePath(spreadsheetId, driveId) - } catch (error) { - return NextResponse.json( - { error: getErrorMessage(error, 'Invalid parameters') }, - { status: 400 } - ) - } - - const worksheetsResponse = await fetch(`${basePath}/workbook/worksheets`, { - method: 'GET', - headers: { - Authorization: `Bearer ${accessToken}`, - 'Content-Type': 'application/json', - }, - }) - - if (!worksheetsResponse.ok) { - const errorMessage = await extractGraphError(worksheetsResponse) - logger.error(`[${requestId}] Microsoft Graph API error`, { - status: worksheetsResponse.status, - error: errorMessage, - }) - return NextResponse.json({ error: errorMessage }, { status: worksheetsResponse.status }) - } - - const data: WorksheetsResponse = await worksheetsResponse.json() - const worksheets = data.value || [] - - // Sort worksheets by position - worksheets.sort((a, b) => a.position - b.position) - - logger.info(`[${requestId}] Successfully fetched ${worksheets.length} worksheets`) - - return NextResponse.json({ - sheets: worksheets.map((worksheet) => ({ - id: worksheet.name, // Use name as ID since that's what the API uses for addressing - name: worksheet.name, - worksheetId: worksheet.id, - position: worksheet.position, - visibility: worksheet.visibility, - })), - }) - } catch (error) { - logger.error(`[${requestId}] Error fetching Microsoft Excel worksheets`, error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } -}) diff --git a/apps/sim/app/api/tools/microsoft_planner/plans/route.ts b/apps/sim/app/api/tools/microsoft_planner/plans/route.ts deleted file mode 100644 index bee66b58459..00000000000 --- a/apps/sim/app/api/tools/microsoft_planner/plans/route.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' -import { microsoftPlannerPlansSelectorContract } from '@/lib/api/contracts/selectors/microsoft' -import { parseRequest } from '@/lib/api/server' -import { authorizeCredentialUse } from '@/lib/auth/credential-access' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service' -import { assertGraphNextPageUrl, getGraphNextPageUrl } from '@/tools/sharepoint/utils' - -const logger = createLogger('MicrosoftPlannerPlansAPI') - -export const dynamic = 'force-dynamic' - -/** - * Upper bound on Microsoft Graph pages drained when listing Planner plans. - * Planner uses server-side paging (`$top` is generally ignored), so this caps - * the `@odata.nextLink` follow loop to prevent an unbounded drain. - */ -const MAX_PLANS_PAGES = 20 - -export const POST = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - - try { - const parsed = await parseRequest(microsoftPlannerPlansSelectorContract, request, {}) - if (!parsed.success) return parsed.response - const { credential, workflowId } = parsed.data.body - - const authz = await authorizeCredentialUse(request, { - credentialId: credential, - workflowId, - }) - if (!authz.ok || !authz.credentialOwnerUserId) { - return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) - } - - const accessToken = await refreshAccessTokenIfNeeded( - credential, - authz.credentialOwnerUserId, - requestId - ) - if (!accessToken) { - logger.error(`[${requestId}] Failed to obtain valid access token`) - return NextResponse.json( - { error: 'Failed to obtain valid access token', authRequired: true }, - { status: 401 } - ) - } - - let nextUrl: string | undefined = 'https://graph.microsoft.com/v1.0/me/planner/plans' - - const rawPlans: { id: string; title: string }[] = [] - for (let page = 0; page < MAX_PLANS_PAGES && nextUrl; page++) { - const response = await fetch(nextUrl, { - headers: { - Authorization: `Bearer ${accessToken}`, - }, - }) - - if (!response.ok) { - const errorText = await response.text() - logger.error(`[${requestId}] Microsoft Graph API error:`, errorText) - return NextResponse.json( - { error: 'Failed to fetch plans from Microsoft Graph' }, - { status: response.status } - ) - } - - const data = await response.json() - if (Array.isArray(data.value)) { - rawPlans.push(...data.value) - } - - const nextLink = getGraphNextPageUrl(data) - nextUrl = nextLink ? assertGraphNextPageUrl(nextLink) : undefined - if (nextUrl && page === MAX_PLANS_PAGES - 1) { - logger.warn( - `[${requestId}] Planner plans pagination hit ${MAX_PLANS_PAGES}-page cap; result may be incomplete` - ) - } - } - - const filteredPlans = rawPlans.map((plan: { id: string; title: string }) => ({ - id: plan.id, - title: plan.title, - })) - - return NextResponse.json({ plans: filteredPlans }) - } catch (error) { - logger.error(`[${requestId}] Error fetching Microsoft Planner plans:`, error) - return NextResponse.json({ error: 'Failed to fetch plans' }, { status: 500 }) - } -}) diff --git a/apps/sim/app/api/tools/microsoft_planner/tasks/route.ts b/apps/sim/app/api/tools/microsoft_planner/tasks/route.ts deleted file mode 100644 index e04a9bc2e3f..00000000000 --- a/apps/sim/app/api/tools/microsoft_planner/tasks/route.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' -import { microsoftPlannerTasksSelectorContract } from '@/lib/api/contracts/selectors/microsoft' -import { parseRequest } from '@/lib/api/server' -import { authorizeCredentialUse } from '@/lib/auth/credential-access' -import { validateMicrosoftGraphId } from '@/lib/core/security/input-validation' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service' -import type { PlannerTask } from '@/tools/microsoft_planner/types' -import { assertGraphNextPageUrl, getGraphNextPageUrl } from '@/tools/sharepoint/utils' - -const logger = createLogger('MicrosoftPlannerTasksAPI') - -export const dynamic = 'force-dynamic' - -/** - * Upper bound on Microsoft Graph pages drained when listing a plan's tasks. - * Planner uses server-side paging (`$top` is generally ignored), so this caps - * the `@odata.nextLink` follow loop to prevent an unbounded drain. - */ -const MAX_TASKS_PAGES = 20 - -export const POST = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - - try { - const parsed = await parseRequest(microsoftPlannerTasksSelectorContract, request, {}) - if (!parsed.success) return parsed.response - const { credential, workflowId, planId } = parsed.data.body - - const planIdValidation = validateMicrosoftGraphId(planId, 'planId') - if (!planIdValidation.isValid) { - logger.error(`[${requestId}] Invalid planId: ${planIdValidation.error}`) - return NextResponse.json({ error: planIdValidation.error }, { status: 400 }) - } - - const authz = await authorizeCredentialUse(request, { - credentialId: credential, - workflowId, - }) - if (!authz.ok || !authz.credentialOwnerUserId) { - return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) - } - - const accessToken = await refreshAccessTokenIfNeeded( - credential, - authz.credentialOwnerUserId, - requestId - ) - if (!accessToken) { - logger.error(`[${requestId}] Failed to obtain valid access token`) - return NextResponse.json( - { error: 'Failed to obtain valid access token', authRequired: true }, - { status: 401 } - ) - } - - let nextUrl: string | undefined = - `https://graph.microsoft.com/v1.0/planner/plans/${planIdValidation.sanitized}/tasks` - - const rawTasks: PlannerTask[] = [] - for (let page = 0; page < MAX_TASKS_PAGES && nextUrl; page++) { - const response = await fetch(nextUrl, { - headers: { - Authorization: `Bearer ${accessToken}`, - }, - }) - - if (!response.ok) { - const errorText = await response.text() - logger.error(`[${requestId}] Microsoft Graph API error:`, errorText) - return NextResponse.json( - { error: 'Failed to fetch tasks from Microsoft Graph' }, - { status: response.status } - ) - } - - const data = await response.json() - if (Array.isArray(data.value)) { - rawTasks.push(...data.value) - } - - const nextLink = getGraphNextPageUrl(data) - nextUrl = nextLink ? assertGraphNextPageUrl(nextLink) : undefined - if (nextUrl && page === MAX_TASKS_PAGES - 1) { - logger.warn( - `[${requestId}] Planner tasks pagination hit ${MAX_TASKS_PAGES}-page cap; result may be incomplete` - ) - } - } - - const filteredTasks = rawTasks.map((task: PlannerTask) => ({ - id: task.id, - title: task.title, - planId: task.planId, - bucketId: task.bucketId, - percentComplete: task.percentComplete, - priority: task.priority, - dueDateTime: task.dueDateTime, - createdDateTime: task.createdDateTime, - completedDateTime: task.completedDateTime, - hasDescription: task.hasDescription, - assignments: task.assignments ? Object.keys(task.assignments) : [], - })) - - return NextResponse.json({ - tasks: filteredTasks, - metadata: { - planId, - planUrl: `https://graph.microsoft.com/v1.0/planner/plans/${planId}`, - }, - }) - } catch (error) { - logger.error(`[${requestId}] Error fetching Microsoft Planner tasks:`, error) - return NextResponse.json({ error: 'Failed to fetch tasks' }, { status: 500 }) - } -}) diff --git a/apps/sim/app/api/tools/monday/boards/route.ts b/apps/sim/app/api/tools/monday/boards/route.ts deleted file mode 100644 index bc877e2fcea..00000000000 --- a/apps/sim/app/api/tools/monday/boards/route.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' -import { mondayBoardsSelectorContract } from '@/lib/api/contracts/selectors' -import { parseRequest } from '@/lib/api/server' -import { authorizeCredentialUse } from '@/lib/auth/credential-access' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service' -import { MONDAY_API_URL, mondayHeaders } from '@/tools/monday/utils' - -export const dynamic = 'force-dynamic' - -const logger = createLogger('MondayBoardsAPI') - -/** - * Monday's GraphQL `boards(limit: N, page: P, state: active)` has no cursor: - * `page` starts at 1 and you stop once a page returns fewer than `limit` items - * (or an empty page). We request the largest page (`MONDAY_BOARDS_LIMIT`) and - * bound the drain with `MAX_MONDAY_PAGES`. - */ -const MONDAY_BOARDS_LIMIT = 100 -const MAX_MONDAY_PAGES = 50 - -interface MondayGraphQLError { - message?: string -} - -interface MondayBoard { - id: string - name: string -} - -interface MondayBoardsResponse { - errors?: MondayGraphQLError[] - error_message?: string - data?: { - boards?: MondayBoard[] - } -} - -export const POST = withRouteHandler(async (request: NextRequest) => { - try { - const requestId = generateRequestId() - const parsed = await parseRequest(mondayBoardsSelectorContract, request, {}) - if (!parsed.success) return parsed.response - const { credential, workflowId } = parsed.data.body - - const authz = await authorizeCredentialUse(request, { - credentialId: credential, - workflowId, - }) - if (!authz.ok || !authz.credentialOwnerUserId) { - return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) - } - - const accessToken = await refreshAccessTokenIfNeeded( - credential, - authz.credentialOwnerUserId, - requestId - ) - if (!accessToken) { - logger.error('Failed to get access token', { - credentialId: credential, - userId: authz.credentialOwnerUserId, - }) - return NextResponse.json( - { error: 'Could not retrieve access token', authRequired: true }, - { status: 401 } - ) - } - - const allBoards: MondayBoard[] = [] - let page = 1 - - for (; page <= MAX_MONDAY_PAGES; page++) { - const response = await fetch(MONDAY_API_URL, { - method: 'POST', - headers: mondayHeaders(accessToken), - body: JSON.stringify({ - query: `{ boards(limit: ${MONDAY_BOARDS_LIMIT}, page: ${page}, state: active) { id name } }`, - }), - }) - - if (!response.ok) { - const details = await response.text().catch(() => '') - logger.error('Monday.com API HTTP error', { - status: response.status, - statusText: response.statusText, - details, - }) - return NextResponse.json( - { error: `Monday.com API error: ${response.status} ${response.statusText}` }, - { status: 500 } - ) - } - - const data = (await response.json()) as MondayBoardsResponse - - if (data.errors?.length) { - logger.error('Monday.com API error', { errors: data.errors }) - return NextResponse.json( - { error: data.errors[0].message || 'Monday.com API error' }, - { status: 500 } - ) - } - - if (data.error_message) { - logger.error('Monday.com API error', { error_message: data.error_message }) - return NextResponse.json({ error: data.error_message }, { status: 500 }) - } - - const pageBoards = data.data?.boards || [] - allBoards.push(...pageBoards) - - if (pageBoards.length < MONDAY_BOARDS_LIMIT) { - break - } - - if (page === MAX_MONDAY_PAGES) { - logger.warn( - 'Monday boards pagination hit MAX_MONDAY_PAGES cap; board list may be incomplete', - { - maxPages: MAX_MONDAY_PAGES, - } - ) - } - } - - const boards = allBoards.map((board) => ({ - id: board.id, - name: board.name, - })) - - return NextResponse.json({ boards }) - } catch (error) { - logger.error('Error processing Monday boards request:', error) - return NextResponse.json( - { error: 'Failed to retrieve Monday boards', details: (error as Error).message }, - { status: 500 } - ) - } -}) diff --git a/apps/sim/app/api/tools/monday/groups/route.ts b/apps/sim/app/api/tools/monday/groups/route.ts deleted file mode 100644 index de80412e1cf..00000000000 --- a/apps/sim/app/api/tools/monday/groups/route.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' -import { mondayGroupsSelectorContract } from '@/lib/api/contracts/selectors' -import { parseRequest } from '@/lib/api/server' -import { authorizeCredentialUse } from '@/lib/auth/credential-access' -import { validateMondayNumericId } from '@/lib/core/security/input-validation' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service' -import { MONDAY_API_URL, mondayHeaders } from '@/tools/monday/utils' - -export const dynamic = 'force-dynamic' - -const logger = createLogger('MondayGroupsAPI') - -interface MondayGraphQLError { - message?: string -} - -interface MondayGroupsResponse { - errors?: MondayGraphQLError[] - error_message?: string - data?: { - boards?: Array<{ - groups?: Array<{ - id: string - title: string - }> - }> - } -} - -export const POST = withRouteHandler(async (request: NextRequest) => { - try { - const requestId = generateRequestId() - const parsed = await parseRequest(mondayGroupsSelectorContract, request, {}) - if (!parsed.success) return parsed.response - const { credential, boardId, workflowId } = parsed.data.body - - const boardIdValidation = validateMondayNumericId(boardId, 'boardId') - if (!boardIdValidation.isValid) { - return NextResponse.json({ error: boardIdValidation.error }, { status: 400 }) - } - - const authz = await authorizeCredentialUse(request, { - credentialId: credential, - workflowId, - }) - if (!authz.ok || !authz.credentialOwnerUserId) { - return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) - } - - const accessToken = await refreshAccessTokenIfNeeded( - credential, - authz.credentialOwnerUserId, - requestId - ) - if (!accessToken) { - logger.error('Failed to get access token', { - credentialId: credential, - userId: authz.credentialOwnerUserId, - }) - return NextResponse.json( - { error: 'Could not retrieve access token', authRequired: true }, - { status: 401 } - ) - } - - const response = await fetch(MONDAY_API_URL, { - method: 'POST', - headers: mondayHeaders(accessToken), - body: JSON.stringify({ - query: `{ boards(ids: [${boardIdValidation.sanitized}]) { groups { id title } } }`, - }), - }) - - const data = (await response.json()) as MondayGroupsResponse - - if (data.errors?.length) { - logger.error('Monday.com API error', { errors: data.errors }) - return NextResponse.json( - { error: data.errors[0].message || 'Monday.com API error' }, - { status: 500 } - ) - } - - if (data.error_message) { - logger.error('Monday.com API error', { error_message: data.error_message }) - return NextResponse.json({ error: data.error_message }, { status: 500 }) - } - - const board = data.data?.boards?.[0] - const groups = (board?.groups || []).map((group) => ({ - id: group.id, - name: group.title, - })) - - return NextResponse.json({ groups }) - } catch (error) { - logger.error('Error processing Monday groups request:', error) - return NextResponse.json( - { error: 'Failed to retrieve Monday groups', details: (error as Error).message }, - { status: 500 } - ) - } -}) diff --git a/apps/sim/app/api/tools/netsuite/objects/route.test.ts b/apps/sim/app/api/tools/netsuite/objects/route.test.ts deleted file mode 100644 index a709cf52da0..00000000000 --- a/apps/sim/app/api/tools/netsuite/objects/route.test.ts +++ /dev/null @@ -1,462 +0,0 @@ -/** - * @vitest-environment node - */ -import { NextRequest } from 'next/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors' - -const { - mockAuthorizeCredentialUse, - mockCheckSessionOrInternalAuth, - mockGetAsyncStatus, - mockListRecordTypes, - mockResolveCredentialAccessToken, - mockResolveOAuthAccountId, -} = vi.hoisted(() => ({ - mockAuthorizeCredentialUse: vi.fn(), - mockCheckSessionOrInternalAuth: vi.fn(), - mockGetAsyncStatus: vi.fn(), - mockListRecordTypes: vi.fn(), - mockResolveCredentialAccessToken: vi.fn(), - mockResolveOAuthAccountId: vi.fn(), -})) - -vi.mock('@/lib/auth/credential-access', () => ({ - authorizeCredentialUse: mockAuthorizeCredentialUse, -})) -vi.mock('@/lib/auth/hybrid', () => ({ - checkSessionOrInternalAuth: mockCheckSessionOrInternalAuth, -})) -vi.mock('@/lib/oauth/credential-service', () => ({ - resolveCredentialAccessToken: mockResolveCredentialAccessToken, - resolveOAuthAccountId: mockResolveOAuthAccountId, -})) -vi.mock('@/tools/netsuite/get_async_status', () => ({ - netsuiteGetAsyncStatusTool: { directExecution: mockGetAsyncStatus }, -})) -vi.mock('@/tools/netsuite/list_record_types', () => ({ - netsuiteListRecordTypesTool: { directExecution: mockListRecordTypes }, -})) - -import { POST } from '@/app/api/tools/netsuite/objects/route' - -const URL = 'http://localhost:3000/api/tools/netsuite/objects' -const ORIGIN = 'https://1234567.suitetalk.api.netsuite.com' -const RECORD_TYPES_BODY = { - credential: 'credential-1', - workflowId: 'workflow-1', - kind: 'record_types', -} as const - -function request( - body: unknown, - signal?: AbortSignal, - headers: Record = {} -): NextRequest { - return new NextRequest(URL, { - method: 'POST', - headers: { 'content-type': 'application/json', ...headers }, - body: typeof body === 'string' ? body : JSON.stringify(body), - signal, - }) -} - -async function json(response: Response): Promise> { - return (await response.json()) as Record -} - -function success(data: unknown) { - return { success: true, output: { status: 200, data } } -} - -function failure(status?: number) { - return { success: false, output: { status, data: null, error: 'provider secret' } } -} - -describe('POST /api/tools/netsuite/objects', () => { - beforeEach(() => { - vi.clearAllMocks() - mockCheckSessionOrInternalAuth.mockResolvedValue({ success: true, userId: 'user-1' }) - mockAuthorizeCredentialUse.mockResolvedValue({ - ok: true, - credentialOwnerUserId: 'owner-1', - resolvedCredentialId: 'resolved-credential-1', - credentialType: 'service_account', - }) - mockResolveOAuthAccountId.mockResolvedValue({ - credentialType: 'service_account', - providerId: 'netsuite-service-account', - }) - mockResolveCredentialAccessToken.mockResolvedValue({ - accessToken: 'short-lived-token', - instanceUrl: ORIGIN, - }) - mockListRecordTypes.mockResolvedValue(success({ items: [{ name: 'customer' }] })) - mockGetAsyncStatus.mockResolvedValue(success({ items: [] })) - }) - - it.each([ - ['unauthenticated malformed input', '{not-json', {}, 'Unauthorized'], - [ - 'API-key caller', - RECORD_TYPES_BODY, - { 'x-api-key': 'external-api-key' }, - 'API key access not allowed for this endpoint', - ], - ])('authenticates before parsing and rejects an %s', async (_label, body, headers, error) => { - mockCheckSessionOrInternalAuth.mockResolvedValueOnce({ success: false, error }) - - const response = await POST(request(body, undefined, headers), {}) - - expect(response.status).toBe(401) - expect(await json(response)).toMatchObject({ error }) - expect(mockCheckSessionOrInternalAuth).toHaveBeenCalledWith(expect.any(NextRequest), { - requireWorkflowId: true, - }) - expect(mockAuthorizeCredentialUse).not.toHaveBeenCalled() - }) - - it.each([ - ['invalid JSON', '{not-json', 400], - ['removed dataset selector kind', { ...RECORD_TYPES_BODY, kind: 'datasets' }, 400], - [ - 'missing async job', - { credential: 'credential-1', workflowId: 'workflow-1', kind: 'async_tasks' }, - 400, - ], - ['unexpected record-type job', { ...RECORD_TYPES_BODY, jobId: 'job-1' }, 400], - ['oversized body', { ...RECORD_TYPES_BODY, padding: 'x'.repeat(17 * 1024) }, 413], - ])('rejects %s', async (_label, body, expectedStatus) => { - const response = await POST(request(body), {}) - - expect(response.status).toBe(expectedStatus) - expect(mockAuthorizeCredentialUse).not.toHaveBeenCalled() - }) - - it('authorizes the exact credential and injects only resolved provider authentication', async () => { - const controller = new AbortController() - const discoveryRequest = request(RECORD_TYPES_BODY, controller.signal) - const response = await POST(discoveryRequest, {}) - - expect(response.status).toBe(200) - expect(mockAuthorizeCredentialUse).toHaveBeenCalledWith(expect.any(NextRequest), { - credentialId: 'credential-1', - workflowId: 'workflow-1', - callerUserId: 'user-1', - }) - expect(mockResolveOAuthAccountId).toHaveBeenCalledWith('resolved-credential-1') - expect(mockResolveCredentialAccessToken).toHaveBeenCalledWith( - 'resolved-credential-1', - 'owner-1', - expect.any(String) - ) - expect(mockListRecordTypes).toHaveBeenCalledWith( - { - oauthCredential: 'resolved-credential-1', - accessToken: 'short-lived-token', - instanceUrl: ORIGIN, - }, - discoveryRequest.signal - ) - }) - - it.each([ - [ - 'credential authorization', - () => mockAuthorizeCredentialUse.mockResolvedValueOnce({ ok: false, error: 'Forbidden' }), - 403, - ], - [ - 'non-service-account credential', - () => - mockAuthorizeCredentialUse.mockResolvedValueOnce({ - ok: true, - credentialOwnerUserId: 'owner-1', - credentialType: 'oauth', - }), - 400, - ], - [ - 'wrong service-account provider', - () => - mockResolveOAuthAccountId.mockResolvedValueOnce({ - credentialType: 'service_account', - providerId: 'snowflake-service-account', - }), - 400, - ], - ])('fails closed on invalid %s', async (_label, arrange, expectedStatus) => { - arrange() - - const response = await POST(request(RECORD_TYPES_BODY), {}) - - expect(response.status).toBe(expectedStatus) - expect(mockListRecordTypes).not.toHaveBeenCalled() - }) - - it('dispatches async task discovery with the exact job, view, auth, and signal', async () => { - const body = { ...RECORD_TYPES_BODY, kind: 'async_tasks', jobId: 'job 1' } as const - const task2 = '/services/rest/async/v1/job/job%201/task/task-2' - const task1 = `${ORIGIN}/services/rest/async/v1/job/job%201/task/task-1` - mockGetAsyncStatus.mockResolvedValueOnce( - success({ - items: [ - { links: [{ rel: 'self', href: task2 }] }, - { - links: [ - { rel: 'self', href: task1 }, - { rel: 'self', href: task1 }, - ], - }, - ], - }) - ) - - const discoveryRequest = request(body) - const response = await POST(discoveryRequest, {}) - - expect(response.status).toBe(200) - expect(mockGetAsyncStatus).toHaveBeenCalledWith( - { - oauthCredential: 'resolved-credential-1', - accessToken: 'short-lived-token', - instanceUrl: ORIGIN, - jobId: 'job 1', - view: 'tasks', - }, - discoveryRequest.signal - ) - expect(await json(response)).toEqual({ - objects: [ - { id: 'task-1', label: 'task-1', detail: null }, - { id: 'task-2', label: 'task-2', detail: null }, - ], - }) - }) - - it('skips non-self task link relationships instead of failing discovery', async () => { - const href = `${ORIGIN}/services/rest/async/v1/job/job-1/task/task-1` - mockGetAsyncStatus.mockResolvedValueOnce( - success({ - items: [ - { - links: [ - { rel: 'canonical', href: `${ORIGIN}/services/rest/async/v1/job/job-1` }, - { rel: 'self', href }, - ], - }, - ], - }) - ) - - const response = await POST( - request({ ...RECORD_TYPES_BODY, kind: 'async_tasks', jobId: 'job-1' }), - {} - ) - - expect(response.status).toBe(200) - expect(await json(response)).toEqual({ - objects: [{ id: 'task-1', label: 'task-1', detail: null }], - }) - }) - - it('fails discovery when a task entry has no self link', async () => { - mockGetAsyncStatus.mockResolvedValueOnce( - success({ - items: [ - { links: [{ rel: 'canonical', href: `${ORIGIN}/services/rest/async/v1/job/job-1` }] }, - ], - }) - ) - - const response = await POST( - request({ ...RECORD_TYPES_BODY, kind: 'async_tasks', jobId: 'job-1' }), - {} - ) - - expect(response.status).toBe(502) - }) - - it('normalizes, deduplicates, and sorts up to 1,000 unique record types', async () => { - const items = Array.from({ length: 1_000 }, (_, index) => ({ - name: `record_${String(999 - index).padStart(4, '0')}`, - })) - items.push({ name: 'record_0000' }, { name: 'record_0999' }) - mockListRecordTypes.mockResolvedValueOnce(success({ items })) - - const response = await POST(request(RECORD_TYPES_BODY), {}) - const body = await json(response) - - expect(response.status).toBe(200) - expect(body.objects).toHaveLength(1_000) - expect((body.objects as { id: string }[]).at(0)?.id).toBe('record_0000') - expect((body.objects as { id: string }[]).at(-1)?.id).toBe('record_0999') - }) - - it('fails closed instead of returning a partial record-type catalog', async () => { - mockListRecordTypes.mockResolvedValueOnce( - success({ - items: Array.from({ length: 1_001 }, (_, index) => ({ name: `record_${index}` })), - }) - ) - - const response = await POST(request(RECORD_TYPES_BODY), {}) - - expect(response.status).toBe(502) - expect(await json(response)).toEqual({ - error: 'NetSuite returned an invalid object-discovery response.', - }) - }) - - it('fails closed on a malformed provider envelope', async () => { - mockListRecordTypes.mockResolvedValueOnce(success({ items: [{ name: 42 }] })) - - const response = await POST(request(RECORD_TYPES_BODY), {}) - - expect(response.status).toBe(502) - expect(await json(response)).toEqual({ - error: 'NetSuite returned an invalid object-discovery response.', - }) - }) - - it.each([ - ['foreign origin', 'https://evil.example/services/rest/async/v1/job/job-1/task/task-1'], - ['wrong job', `${ORIGIN}/services/rest/async/v1/job/job-2/task/task-1`], - ['query string', `${ORIGIN}/services/rest/async/v1/job/job-1/task/task-1?secret=x`], - ['fragment', `${ORIGIN}/services/rest/async/v1/job/job-1/task/task-1#fragment`], - ['noncanonical encoding', `${ORIGIN}/services/rest/async/v1/job/job%2D1/task/task-1`], - ])('rejects a %s task link', async (_label, href) => { - mockGetAsyncStatus.mockResolvedValueOnce( - success({ items: [{ links: [{ rel: 'self', href }] }] }) - ) - - const response = await POST( - request({ ...RECORD_TYPES_BODY, kind: 'async_tasks', jobId: 'job-1' }), - {} - ) - - expect(response.status).toBe(502) - expect(await json(response)).toEqual({ - error: 'NetSuite returned an invalid object-discovery response.', - }) - }) - - it('maps unexpected discovery failures to the generic route error', async () => { - mockListRecordTypes.mockRejectedValueOnce(new Error('secret provider detail')) - - const response = await POST(request(RECORD_TYPES_BODY), {}) - const body = await json(response) - - expect(response.status).toBe(500) - expect(body.error).toBe('Internal server error') - expect(JSON.stringify(body)).not.toContain('secret provider detail') - }) - - it('rejects malformed task relationships and collections above the provider ceiling', async () => { - const taskItems = Array.from({ length: 101 }, (_, index) => ({ - links: [ - { - rel: 'self', - href: `/services/rest/async/v1/job/job-1/task/task-${index}`, - }, - ], - })) - for (const items of [ - [{ links: [{ rel: 'alternate', href: taskItems[0].links[0].href }] }], - taskItems, - [{ links: 'not-an-array' }], - ]) { - mockGetAsyncStatus.mockResolvedValueOnce(success({ items })) - const response = await POST( - request({ ...RECORD_TYPES_BODY, kind: 'async_tasks', jobId: 'job-1' }), - {} - ) - expect(response.status).toBe(502) - } - }) - - it('applies the async-task ceiling after duplicate task links are removed', async () => { - const href = '/services/rest/async/v1/job/job-1/task/task-1' - mockGetAsyncStatus.mockResolvedValueOnce( - success({ - items: Array.from({ length: 101 }, () => ({ links: [{ rel: 'self', href }] })), - }) - ) - - const response = await POST( - request({ ...RECORD_TYPES_BODY, kind: 'async_tasks', jobId: 'job-1' }), - {} - ) - - expect(response.status).toBe(200) - expect(await json(response)).toEqual({ - objects: [{ id: 'task-1', label: 'task-1', detail: null }], - }) - }) - - it.each([ - [401, 401, true], - [403, 403, undefined], - [404, 400, undefined], - [500, 502, undefined], - [undefined, 502, undefined], - ])( - 'maps provider status %s without reflecting its error', - async (providerStatus, status, authRequired) => { - mockListRecordTypes.mockResolvedValueOnce(failure(providerStatus)) - - const response = await POST(request(RECORD_TYPES_BODY), {}) - const body = await json(response) - - expect(response.status).toBe(status) - expect(body.authRequired).toBe(authRequired) - expect(JSON.stringify(body)).not.toContain('provider secret') - } - ) - - it.each([ - [new TokenServiceAccountValidationError('invalid_credentials', 401), 401, true], - [new TokenServiceAccountValidationError('provider_unavailable', 502), 502, undefined], - [null, 401, true], - ])( - 'maps credential resolution failures without exposing details', - async (error, status, authRequired) => { - if (error === null) { - mockResolveCredentialAccessToken.mockResolvedValueOnce(null) - } else { - mockResolveCredentialAccessToken.mockRejectedValueOnce(error) - } - - const response = await POST(request(RECORD_TYPES_BODY), {}) - const body = await json(response) - - expect(response.status).toBe(status) - expect(body.authRequired).toBe(authRequired) - } - ) - - it.each([ - ['missing access token', { instanceUrl: ORIGIN }], - ['missing instance URL', { accessToken: 'short-lived-token' }], - ])('rejects a resolved credential with %s', async (_label, token) => { - mockResolveCredentialAccessToken.mockResolvedValueOnce(token) - - const response = await POST(request(RECORD_TYPES_BODY), {}) - - expect(response.status).toBe(401) - expect(await json(response)).toMatchObject({ authRequired: true }) - expect(mockListRecordTypes).not.toHaveBeenCalled() - }) - - it('returns 499 when the caller cancels after provider dispatch', async () => { - const controller = new AbortController() - mockListRecordTypes.mockImplementationOnce(async () => { - controller.abort() - return success({ items: [{ name: 'customer' }] }) - }) - - const response = await POST(request(RECORD_TYPES_BODY, controller.signal), {}) - - expect(response.status).toBe(499) - }) -}) diff --git a/apps/sim/app/api/tools/netsuite/objects/route.ts b/apps/sim/app/api/tools/netsuite/objects/route.ts deleted file mode 100644 index b6183e13a28..00000000000 --- a/apps/sim/app/api/tools/netsuite/objects/route.ts +++ /dev/null @@ -1,355 +0,0 @@ -import { createLogger } from '@sim/logger' -import { isPlainRecord } from '@sim/utils/object' -import { type NextRequest, NextResponse } from 'next/server' -import { - type NetSuiteObjectsSelectorBody, - netsuiteObjectsSelectorContract, -} from '@/lib/api/contracts/selectors/netsuite' -import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' -import { authorizeCredentialUse } from '@/lib/auth/credential-access' -import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { NETSUITE_SERVICE_ACCOUNT_PROVIDER_ID } from '@/lib/credentials/client-credential-accounts/descriptors' -import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors' -import { resolveCredentialAccessToken, resolveOAuthAccountId } from '@/lib/oauth/credential-service' -import { netsuiteGetAsyncStatusTool } from '@/tools/netsuite/get_async_status' -import { netsuiteListRecordTypesTool } from '@/tools/netsuite/list_record_types' -import type { NetSuiteAuthParams } from '@/tools/netsuite/types' -import { normalizeSuiteTalkUrl } from '@/tools/netsuite/utils' -import type { ToolResponse } from '@/tools/types' - -const logger = createLogger('NetSuiteObjectsAPI') - -export const dynamic = 'force-dynamic' - -/** - * This session/internal-only metadata route intentionally has no separate rate - * limiter: it reuses read-only NetSuite tools whose deadlines and bounded - * result sets constrain each provider call, matching Snowflake's picker route. - */ -const SELECTOR_REQUEST_MAX_BYTES = 16 * 1024 -const MAX_RECORD_TYPES = 1_000 -const MAX_ASYNC_TASKS = 100 -const MAX_ID_LENGTH = 512 - -interface NetSuiteSelectorObject { - id: string - label: string - detail: string | null -} - -function throwIfAborted(signal: AbortSignal): void { - if (!signal.aborted) return - throw signal.reason instanceof Error - ? signal.reason - : new DOMException('NetSuite selector request was cancelled', 'AbortError') -} - -function requireString(value: unknown, label: string, maxLength: number): string { - if (typeof value !== 'string' || !value.trim()) { - throw new Error(`NetSuite returned an invalid ${label}`) - } - const normalized = value.trim() - if (normalized.length > maxLength) { - throw new Error(`NetSuite returned an oversized ${label}`) - } - return normalized -} - -function requireItems(data: unknown, label: string): Record[] { - if (!isPlainRecord(data) || !Array.isArray(data.items)) { - throw new Error(`NetSuite returned an invalid ${label} response`) - } - if (!data.items.every(isPlainRecord)) { - throw new Error(`NetSuite returned malformed ${label} entries`) - } - return data.items -} - -function dedupeAndSort(objects: NetSuiteSelectorObject[]): NetSuiteSelectorObject[] { - const unique = new Map() - for (const object of objects) { - if (!unique.has(object.id)) unique.set(object.id, object) - } - return [...unique.values()].sort( - (left, right) => left.label.localeCompare(right.label) || left.id.localeCompare(right.id) - ) -} - -function normalizeRecordTypes(data: unknown): NetSuiteSelectorObject[] { - const objects: NetSuiteSelectorObject[] = [] - const names = new Set() - for (const item of requireItems(data, 'record-type catalog')) { - const name = requireString(item.name, 'record type name', MAX_ID_LENGTH) - if (!names.has(name)) { - if (names.size >= MAX_RECORD_TYPES) { - throw new Error('NetSuite returned too many record types') - } - names.add(name) - objects.push({ id: name, label: name, detail: null }) - } - } - return dedupeAndSort(objects) -} - -function taskIdFromHref(href: unknown, origin: string, jobId: string): string { - const hrefValue = requireString(href, 'asynchronous task link', 4_096) - let url: URL - try { - url = new URL(hrefValue, origin) - } catch { - throw new Error('NetSuite returned a malformed task link') - } - if ( - url.protocol !== 'https:' || - url.origin !== origin || - url.username || - url.password || - url.search || - url.hash - ) { - throw new Error('NetSuite returned an unsafe task link') - } - - const match = url.pathname.match(/^\/services\/rest\/async\/v1\/job\/([^/]+)\/task\/([^/]+)$/) - if (!match?.[1] || !match[2]) { - throw new Error('NetSuite returned an unexpected task link') - } - - let linkedJobId: string - let taskId: string - try { - linkedJobId = decodeURIComponent(match[1]) - taskId = decodeURIComponent(match[2]) - } catch { - throw new Error('NetSuite returned a malformed task link') - } - if (linkedJobId !== jobId || !taskId || taskId.length > MAX_ID_LENGTH) { - throw new Error('NetSuite returned a task link outside the requested job') - } - - const canonicalPath = `/services/rest/async/v1/job/${encodeURIComponent(linkedJobId)}/task/${encodeURIComponent(taskId)}` - if ( - url.pathname !== canonicalPath || - (hrefValue !== canonicalPath && hrefValue !== `${origin}${canonicalPath}`) - ) { - throw new Error('NetSuite returned a noncanonical task link') - } - return taskId -} - -function normalizeAsyncTasks( - data: unknown, - instanceUrl: string, - jobId: string -): NetSuiteSelectorObject[] { - const items = requireItems(data, 'asynchronous task collection') - const origin = normalizeSuiteTalkUrl(instanceUrl) - const objects = new Map() - - for (const item of items) { - if (!Array.isArray(item.links) || item.links.length === 0 || !item.links.every(isPlainRecord)) { - throw new Error('NetSuite returned malformed asynchronous task links') - } - // Oracle documents a `self` link per task but never guarantees it is the - // only relationship on the entry, so additional rels are skipped rather - // than failing the whole picker. - const selfLinks = item.links.filter((link) => link.rel === 'self') - if (selfLinks.length === 0) { - throw new Error('NetSuite returned an asynchronous task without a self link') - } - for (const link of selfLinks) { - const id = taskIdFromHref(link.href, origin, jobId) - if (!objects.has(id)) { - if (objects.size >= MAX_ASYNC_TASKS) { - throw new Error('NetSuite returned too many asynchronous tasks') - } - objects.set(id, { id, label: id, detail: null }) - } - } - } - return dedupeAndSort([...objects.values()]) -} - -async function executeDiscoveryTool( - body: NetSuiteObjectsSelectorBody, - auth: NetSuiteAuthParams, - signal: AbortSignal -): Promise { - throwIfAborted(signal) - switch (body.kind) { - case 'record_types': { - const execute = netsuiteListRecordTypesTool.directExecution - if (!execute) throw new Error('NetSuite record-type tool is not executable') - return execute(auth, signal) - } - case 'async_tasks': { - const execute = netsuiteGetAsyncStatusTool.directExecution - if (!execute) throw new Error('NetSuite asynchronous-status tool is not executable') - return execute({ ...auth, jobId: body.jobId, view: 'tasks' }, signal) - } - } -} - -function failedDiscoveryResponse(result: ToolResponse): NextResponse { - const providerStatus = - typeof result.output.status === 'number' && Number.isInteger(result.output.status) - ? result.output.status - : 0 - if (providerStatus === 401) { - return NextResponse.json( - { - error: 'NetSuite rejected this credential. Reconnect it and try again.', - authRequired: true, - }, - { status: 401 } - ) - } - if (providerStatus === 403) { - return NextResponse.json( - { error: 'NetSuite denied access to object discovery for this credential.' }, - { status: 403 } - ) - } - if (providerStatus >= 400 && providerStatus < 500) { - return NextResponse.json( - { error: 'NetSuite could not list objects for this request.' }, - { status: 400 } - ) - } - return NextResponse.json({ error: 'NetSuite object discovery failed.' }, { status: 502 }) -} - -function credentialFailureResponse(error: unknown): NextResponse { - if (error instanceof TokenServiceAccountValidationError) { - if (error.code !== 'provider_unavailable') { - return NextResponse.json( - { - error: 'Could not resolve the NetSuite credential. Reconnect it and try again.', - authRequired: true, - }, - { status: 401 } - ) - } - return NextResponse.json( - { error: 'The NetSuite credential service is temporarily unavailable.' }, - { status: 502 } - ) - } - return NextResponse.json( - { - error: 'Could not resolve the NetSuite credential. Reconnect it and try again.', - authRequired: true, - }, - { status: 401 } - ) -} - -/** - * Lists the bounded NetSuite objects used by the block's record-type and - * asynchronous-task pickers. Like Snowflake's selector endpoint, this - * route owns authentication, credential resolution, provider access, and - * response normalization directly; short-lived tokens never reach the client. - */ -export const POST = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - - const caller = await checkSessionOrInternalAuth(request, { requireWorkflowId: true }) - if (!caller.success || !caller.userId) { - return NextResponse.json({ error: caller.error || 'Authentication required' }, { status: 401 }) - } - - const parsed = await parseRequest( - netsuiteObjectsSelectorContract, - request, - {}, - { - maxBodyBytes: SELECTOR_REQUEST_MAX_BYTES, - validationErrorResponse: (error) => - NextResponse.json( - { error: getValidationErrorMessage(error, 'Invalid request') }, - { status: 400 } - ), - } - ) - if (!parsed.success) return parsed.response - const body = parsed.data.body - const { credential, workflowId, kind } = body - - const authorization = await authorizeCredentialUse(request, { - credentialId: credential, - workflowId, - callerUserId: caller.userId, - }) - if (!authorization.ok || !authorization.credentialOwnerUserId) { - return NextResponse.json({ error: authorization.error || 'Unauthorized' }, { status: 403 }) - } - - const resolvedCredentialId = authorization.resolvedCredentialId ?? credential - const resolvedCredential = await resolveOAuthAccountId(resolvedCredentialId) - if ( - authorization.credentialType !== 'service_account' || - resolvedCredential?.credentialType !== 'service_account' || - resolvedCredential.providerId !== NETSUITE_SERVICE_ACCOUNT_PROVIDER_ID - ) { - return NextResponse.json( - { error: 'Select a NetSuite client-credentials service account.' }, - { status: 400 } - ) - } - - throwIfAborted(request.signal) - let token - try { - token = await resolveCredentialAccessToken( - resolvedCredentialId, - authorization.credentialOwnerUserId, - requestId - ) - } catch (error) { - throwIfAborted(request.signal) - logger.warn('Failed to resolve NetSuite selector credential', { - credentialId: resolvedCredentialId, - kind, - errorType: error instanceof Error ? error.name : 'unknown', - }) - if (error instanceof TokenServiceAccountValidationError) { - return credentialFailureResponse(error) - } - throw error - } - throwIfAborted(request.signal) - if (!token?.accessToken || !token.instanceUrl) { - return credentialFailureResponse(null) - } - - const auth: NetSuiteAuthParams = { - oauthCredential: resolvedCredentialId, - accessToken: token.accessToken, - instanceUrl: token.instanceUrl, - } - - const result: ToolResponse = await executeDiscoveryTool(body, auth, request.signal) - throwIfAborted(request.signal) - if (!result.success) return failedDiscoveryResponse(result) - - try { - const data = result.output.data as unknown - const objects = - body.kind === 'record_types' - ? normalizeRecordTypes(data) - : normalizeAsyncTasks(data, token.instanceUrl, body.jobId) - return NextResponse.json({ objects }) - } catch (error) { - logger.error('NetSuite selector response was invalid', { - credentialId: resolvedCredentialId, - kind, - errorType: error instanceof Error ? error.name : 'unknown', - }) - return NextResponse.json( - { error: 'NetSuite returned an invalid object-discovery response.' }, - { status: 502 } - ) - } -}) diff --git a/apps/sim/app/api/tools/notion/databases/route.ts b/apps/sim/app/api/tools/notion/databases/route.ts deleted file mode 100644 index 966ac49fb51..00000000000 --- a/apps/sim/app/api/tools/notion/databases/route.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import { type NextRequest, NextResponse } from 'next/server' -import { notionDatabasesSelectorContract } from '@/lib/api/contracts/selectors' -import { parseRequest } from '@/lib/api/server' -import { authorizeCredentialUse } from '@/lib/auth/credential-access' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service' -import { extractTitleFromItem } from '@/tools/notion/utils' - -const logger = createLogger('NotionDatabasesAPI') - -export const dynamic = 'force-dynamic' - -const NOTION_PAGE_SIZE = 100 - -/** - * Notion's `POST /v1/search` returns at most `page_size` results per call and - * exposes `has_more`/`next_cursor` for pagination. This caps the number of - * pages drained so a tenant with a very large workspace cannot make this route - * loop unbounded. With `NOTION_PAGE_SIZE` of 100 this covers up to 2,000 items. - */ -const MAX_DATABASE_PAGES = 20 - -export const POST = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - try { - const parsed = await parseRequest(notionDatabasesSelectorContract, request, {}) - if (!parsed.success) return parsed.response - const { credential, workflowId } = parsed.data.body - - const authz = await authorizeCredentialUse(request, { - credentialId: credential, - workflowId: workflowId || undefined, - }) - if (!authz.ok || !authz.credentialOwnerUserId) { - return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) - } - - const accessToken = await refreshAccessTokenIfNeeded( - credential, - authz.credentialOwnerUserId, - requestId - ) - if (!accessToken) { - logger.error('Failed to get access token', { - credentialId: credential, - userId: authz.credentialOwnerUserId, - }) - return NextResponse.json( - { error: 'Could not retrieve access token', authRequired: true }, - { status: 401 } - ) - } - - const results: Record[] = [] - let startCursor: string | undefined - - for (let page = 0; page < MAX_DATABASE_PAGES; page++) { - const response = await fetch('https://api.notion.com/v1/search', { - method: 'POST', - headers: { - Authorization: `Bearer ${accessToken}`, - 'Content-Type': 'application/json', - 'Notion-Version': '2022-06-28', - }, - body: JSON.stringify({ - filter: { value: 'database', property: 'object' }, - page_size: NOTION_PAGE_SIZE, - ...(startCursor ? { start_cursor: startCursor } : {}), - }), - }) - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})) - logger.error('Failed to fetch Notion databases', { - status: response.status, - error: errorData, - }) - return NextResponse.json( - { error: 'Failed to fetch Notion databases', details: errorData }, - { status: response.status } - ) - } - - const data = await response.json() - if (Array.isArray(data.results)) { - results.push(...(data.results as Record[])) - } - - if (!data.has_more || !data.next_cursor) { - break - } - startCursor = data.next_cursor as string - - if (page === MAX_DATABASE_PAGES - 1) { - logger.warn('Notion databases search hit pagination cap; results may be incomplete', { - maxPages: MAX_DATABASE_PAGES, - fetched: results.length, - }) - } - } - - const databases = results.map((db) => ({ - id: db.id as string, - name: extractTitleFromItem(db), - })) - - return NextResponse.json({ databases }) - } catch (error) { - logger.error('Error processing Notion databases request:', error) - return NextResponse.json( - { error: 'Failed to retrieve Notion databases', details: getErrorMessage(error) }, - { status: 500 } - ) - } -}) diff --git a/apps/sim/app/api/tools/notion/pages/route.ts b/apps/sim/app/api/tools/notion/pages/route.ts deleted file mode 100644 index 4a0f486e495..00000000000 --- a/apps/sim/app/api/tools/notion/pages/route.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import { type NextRequest, NextResponse } from 'next/server' -import { notionPagesSelectorContract } from '@/lib/api/contracts/selectors' -import { parseRequest } from '@/lib/api/server' -import { authorizeCredentialUse } from '@/lib/auth/credential-access' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service' -import { extractTitleFromItem } from '@/tools/notion/utils' - -const logger = createLogger('NotionPagesAPI') - -export const dynamic = 'force-dynamic' - -const NOTION_PAGE_SIZE = 100 - -/** - * Notion's `POST /v1/search` returns at most `page_size` results per call and - * exposes `has_more`/`next_cursor` for pagination. This caps the number of - * pages drained so a tenant with a very large workspace cannot make this route - * loop unbounded. With `NOTION_PAGE_SIZE` of 100 this covers up to 2,000 items. - */ -const MAX_NOTION_PAGES = 20 - -export const POST = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - try { - const parsed = await parseRequest(notionPagesSelectorContract, request, {}) - if (!parsed.success) return parsed.response - const { credential, workflowId } = parsed.data.body - - const authz = await authorizeCredentialUse(request, { - credentialId: credential, - workflowId: workflowId || undefined, - }) - if (!authz.ok || !authz.credentialOwnerUserId) { - return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) - } - - const accessToken = await refreshAccessTokenIfNeeded( - credential, - authz.credentialOwnerUserId, - requestId - ) - if (!accessToken) { - logger.error('Failed to get access token', { - credentialId: credential, - userId: authz.credentialOwnerUserId, - }) - return NextResponse.json( - { error: 'Could not retrieve access token', authRequired: true }, - { status: 401 } - ) - } - - const results: Record[] = [] - let startCursor: string | undefined - - for (let page = 0; page < MAX_NOTION_PAGES; page++) { - const response = await fetch('https://api.notion.com/v1/search', { - method: 'POST', - headers: { - Authorization: `Bearer ${accessToken}`, - 'Content-Type': 'application/json', - 'Notion-Version': '2022-06-28', - }, - body: JSON.stringify({ - filter: { value: 'page', property: 'object' }, - page_size: NOTION_PAGE_SIZE, - ...(startCursor ? { start_cursor: startCursor } : {}), - }), - }) - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})) - logger.error('Failed to fetch Notion pages', { - status: response.status, - error: errorData, - }) - return NextResponse.json( - { error: 'Failed to fetch Notion pages', details: errorData }, - { status: response.status } - ) - } - - const data = await response.json() - if (Array.isArray(data.results)) { - results.push(...(data.results as Record[])) - } - - if (!data.has_more || !data.next_cursor) { - break - } - startCursor = data.next_cursor as string - - if (page === MAX_NOTION_PAGES - 1) { - logger.warn('Notion pages search hit pagination cap; results may be incomplete', { - maxPages: MAX_NOTION_PAGES, - fetched: results.length, - }) - } - } - - const pages = results.map((page) => ({ - id: page.id as string, - name: extractTitleFromItem(page), - })) - - return NextResponse.json({ pages }) - } catch (error) { - logger.error('Error processing Notion pages request:', error) - return NextResponse.json( - { error: 'Failed to retrieve Notion pages', details: getErrorMessage(error) }, - { status: 500 } - ) - } -}) diff --git a/apps/sim/app/api/tools/onedrive/files/route.ts b/apps/sim/app/api/tools/onedrive/files/route.ts deleted file mode 100644 index 992ecd16875..00000000000 --- a/apps/sim/app/api/tools/onedrive/files/route.ts +++ /dev/null @@ -1,169 +0,0 @@ -import { createLogger } from '@sim/logger' -import { generateId } from '@sim/utils/id' -import { type NextRequest, NextResponse } from 'next/server' -import { onedriveFilesQuerySchema } from '@/lib/api/contracts/selectors/microsoft' -import { getValidationErrorMessage } from '@/lib/api/server' -import { authorizeCredentialUse } from '@/lib/auth/credential-access' -import { validateMicrosoftGraphId } from '@/lib/core/security/input-validation' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service' -import type { MicrosoftGraphDriveItem } from '@/tools/onedrive/types' -import { assertGraphNextPageUrl, getGraphNextPageUrl } from '@/tools/sharepoint/utils' - -export const dynamic = 'force-dynamic' - -const logger = createLogger('OneDriveFilesAPI') - -/** - * Microsoft Graph paginates drive item collections via the `@odata.nextLink` - * absolute URL in the response body. Request the largest page (`$top` caps at - * 999) and drain following nextLink, bounded by a page cap. - * See https://learn.microsoft.com/en-us/graph/paging - */ -const ONEDRIVE_FILES_PAGE_SIZE = 999 -const MAX_ONEDRIVE_FILES_PAGES = 20 - -/** - * Get files (not folders) from Microsoft OneDrive - */ -export const GET = withRouteHandler(async (request: NextRequest) => { - const requestId = generateId().slice(0, 8) - logger.info(`[${requestId}] OneDrive files request received`) - - try { - const { searchParams } = new URL(request.url) - const validation = onedriveFilesQuerySchema.safeParse({ - credentialId: searchParams.get('credentialId') ?? '', - query: searchParams.get('query') ?? undefined, - }) - if (!validation.success) { - logger.warn(`[${requestId}] Invalid files request data`, { errors: validation.error.issues }) - return NextResponse.json( - { error: getValidationErrorMessage(validation.error, 'Invalid request') }, - { status: 400 } - ) - } - const { credentialId } = validation.data - const query = validation.data.query ?? '' - - const credentialIdValidation = validateMicrosoftGraphId(credentialId, 'credentialId') - if (!credentialIdValidation.isValid) { - logger.warn(`[${requestId}] Invalid credential ID`, { error: credentialIdValidation.error }) - return NextResponse.json({ error: credentialIdValidation.error }, { status: 400 }) - } - - logger.info(`[${requestId}] Fetching credential`, { credentialId }) - - const credAccess = await authorizeCredentialUse(request, { - credentialId, - requireWorkflowIdForInternal: false, - }) - if (!credAccess.ok || !credAccess.credentialOwnerUserId) { - logger.warn(`[${requestId}] Credential access denied`, { error: credAccess.error }) - return NextResponse.json({ error: credAccess.error || 'Unauthorized' }, { status: 401 }) - } - - const accessToken = await refreshAccessTokenIfNeeded( - credentialId, - credAccess.credentialOwnerUserId, - requestId - ) - if (!accessToken) { - logger.error(`[${requestId}] Failed to obtain valid access token`) - return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 }) - } - - // $filter is unsupported on the /children endpoint; use search when a query is present - let url: string - if (query) { - const searchParams_new = new URLSearchParams() - searchParams_new.append( - '$select', - 'id,name,file,webUrl,size,createdDateTime,lastModifiedDateTime,createdBy,thumbnails' - ) - searchParams_new.append('$top', String(ONEDRIVE_FILES_PAGE_SIZE)) - url = `https://graph.microsoft.com/v1.0/me/drive/root/search(q='${encodeURIComponent(query)}')?${searchParams_new.toString()}` - } else { - const searchParams_new = new URLSearchParams() - searchParams_new.append( - '$select', - 'id,name,file,folder,webUrl,size,createdDateTime,lastModifiedDateTime,createdBy,thumbnails' - ) - searchParams_new.append('$top', String(ONEDRIVE_FILES_PAGE_SIZE)) - url = `https://graph.microsoft.com/v1.0/me/drive/root/children?${searchParams_new.toString()}` - } - - logger.info(`[${requestId}] Fetching files from Microsoft Graph`, { url }) - - const rawItems: MicrosoftGraphDriveItem[] = [] - let nextUrl: string | undefined = url - - for (let page = 0; page < MAX_ONEDRIVE_FILES_PAGES && nextUrl; page++) { - const response = await fetch(nextUrl, { - headers: { - Authorization: `Bearer ${accessToken}`, - }, - }) - - if (!response.ok) { - const errorData = await response - .json() - .catch(() => ({ error: { message: 'Unknown error' } })) - logger.error(`[${requestId}] Microsoft Graph API error`, { - status: response.status, - error: errorData.error?.message || 'Failed to fetch files from OneDrive', - }) - return NextResponse.json( - { error: errorData.error?.message || 'Failed to fetch files from OneDrive' }, - { status: response.status } - ) - } - - const data = await response.json() - rawItems.push(...((data.value as MicrosoftGraphDriveItem[]) || [])) - - const nextLink = getGraphNextPageUrl(data) - nextUrl = nextLink ? assertGraphNextPageUrl(nextLink) : undefined - - if (nextUrl && page === MAX_ONEDRIVE_FILES_PAGES - 1) { - logger.warn(`[${requestId}] OneDrive files hit pagination cap; list may be incomplete`, { - pages: MAX_ONEDRIVE_FILES_PAGES, - collected: rawItems.length, - }) - } - } - - logger.info(`[${requestId}] Received ${rawItems.length} items from Microsoft Graph`) - - const files = rawItems - .filter((item: MicrosoftGraphDriveItem) => !!item.file && !item.folder) - .map((file: MicrosoftGraphDriveItem) => ({ - id: file.id, - name: file.name, - mimeType: file.file?.mimeType || 'application/octet-stream', - iconLink: file.thumbnails?.[0]?.small?.url, - webViewLink: file.webUrl, - thumbnailLink: file.thumbnails?.[0]?.medium?.url, - createdTime: file.createdDateTime, - modifiedTime: file.lastModifiedDateTime, - size: file.size?.toString(), - owners: file.createdBy - ? [ - { - displayName: file.createdBy.user?.displayName || 'Unknown', - emailAddress: file.createdBy.user?.email || '', - }, - ] - : [], - })) - - logger.info(`[${requestId}] Returning ${files.length} files`, { - totalItems: rawItems.length, - }) - - return NextResponse.json({ files }, { status: 200 }) - } catch (error) { - logger.error(`[${requestId}] Error fetching files from OneDrive`, error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } -}) diff --git a/apps/sim/app/api/tools/onedrive/folder/route.ts b/apps/sim/app/api/tools/onedrive/folder/route.ts deleted file mode 100644 index df3d192ad9c..00000000000 --- a/apps/sim/app/api/tools/onedrive/folder/route.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { createLogger } from '@sim/logger' -import { generateId } from '@sim/utils/id' -import { type NextRequest, NextResponse } from 'next/server' -import { onedriveFolderQuerySchema } from '@/lib/api/contracts/selectors/microsoft' -import { getValidationErrorMessage } from '@/lib/api/server' -import { authorizeCredentialUse } from '@/lib/auth/credential-access' -import { validateMicrosoftGraphId } from '@/lib/core/security/input-validation' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service' - -export const dynamic = 'force-dynamic' - -const logger = createLogger('OneDriveFolderAPI') - -export const GET = withRouteHandler(async (request: NextRequest) => { - const requestId = generateId().slice(0, 8) - - try { - const { searchParams } = new URL(request.url) - const validation = onedriveFolderQuerySchema.safeParse({ - credentialId: searchParams.get('credentialId') ?? '', - fileId: searchParams.get('fileId') ?? '', - }) - if (!validation.success) { - return NextResponse.json( - { error: getValidationErrorMessage(validation.error, 'Invalid request') }, - { status: 400 } - ) - } - const { credentialId, fileId } = validation.data - - const fileIdValidation = validateMicrosoftGraphId(fileId, 'fileId') - if (!fileIdValidation.isValid) { - return NextResponse.json({ error: fileIdValidation.error }, { status: 400 }) - } - - const credAccess = await authorizeCredentialUse(request, { - credentialId, - requireWorkflowIdForInternal: false, - }) - if (!credAccess.ok || !credAccess.credentialOwnerUserId) { - logger.warn(`[${requestId}] Credential access denied`, { error: credAccess.error }) - return NextResponse.json({ error: credAccess.error || 'Unauthorized' }, { status: 401 }) - } - - const accessToken = await refreshAccessTokenIfNeeded( - credentialId, - credAccess.credentialOwnerUserId, - requestId - ) - if (!accessToken) { - return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 }) - } - - const response = await fetch( - `https://graph.microsoft.com/v1.0/me/drive/items/${fileId}?$select=id,name,folder,webUrl,createdDateTime,lastModifiedDateTime`, - { - headers: { - Authorization: `Bearer ${accessToken}`, - }, - } - ) - - if (!response.ok) { - const errorData = await response.json().catch(() => ({ error: { message: 'Unknown error' } })) - return NextResponse.json( - { error: errorData.error?.message || 'Failed to fetch folder from OneDrive' }, - { status: response.status } - ) - } - - const folder = await response.json() - - const transformedFolder = { - id: folder.id, - name: folder.name, - mimeType: 'application/vnd.microsoft.graph.folder', - webViewLink: folder.webUrl, - createdTime: folder.createdDateTime, - modifiedTime: folder.lastModifiedDateTime, - } - - return NextResponse.json({ file: transformedFolder }, { status: 200 }) - } catch (error) { - logger.error(`[${requestId}] Error fetching folder from OneDrive`, error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } -}) diff --git a/apps/sim/app/api/tools/onedrive/folders/route.ts b/apps/sim/app/api/tools/onedrive/folders/route.ts deleted file mode 100644 index 9ac17d6d594..00000000000 --- a/apps/sim/app/api/tools/onedrive/folders/route.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { createLogger } from '@sim/logger' -import { generateId } from '@sim/utils/id' -import { type NextRequest, NextResponse } from 'next/server' -import { onedriveFoldersQuerySchema } from '@/lib/api/contracts/selectors/microsoft' -import { getValidationErrorMessage } from '@/lib/api/server' -import { authorizeCredentialUse } from '@/lib/auth/credential-access' -import { validateMicrosoftGraphId } from '@/lib/core/security/input-validation' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service' -import type { MicrosoftGraphDriveItem } from '@/tools/onedrive/types' -import { assertGraphNextPageUrl, getGraphNextPageUrl } from '@/tools/sharepoint/utils' - -export const dynamic = 'force-dynamic' - -const logger = createLogger('OneDriveFoldersAPI') - -/** - * Microsoft Graph paginates drive item collections via the `@odata.nextLink` - * absolute URL in the response body. Request the largest page (`$top` caps at - * 999) and drain following nextLink, bounded by a page cap. - * See https://learn.microsoft.com/en-us/graph/paging - */ -const ONEDRIVE_FOLDERS_PAGE_SIZE = 999 -const MAX_ONEDRIVE_FOLDERS_PAGES = 20 - -/** - * Get folders from Microsoft OneDrive - */ -export const GET = withRouteHandler(async (request: NextRequest) => { - const requestId = generateId().slice(0, 8) - - try { - const { searchParams } = new URL(request.url) - const validation = onedriveFoldersQuerySchema.safeParse({ - credentialId: searchParams.get('credentialId') ?? '', - query: searchParams.get('query') ?? undefined, - driveId: searchParams.get('driveId') ?? undefined, - }) - if (!validation.success) { - logger.warn(`[${requestId}] Invalid folders request data`, { - errors: validation.error.issues, - }) - return NextResponse.json( - { error: getValidationErrorMessage(validation.error, 'Invalid request') }, - { status: 400 } - ) - } - const { credentialId, driveId } = validation.data - const query = validation.data.query ?? '' - - const credentialIdValidation = validateMicrosoftGraphId(credentialId, 'credentialId') - if (!credentialIdValidation.isValid) { - logger.warn(`[${requestId}] Invalid credential ID`, { error: credentialIdValidation.error }) - return NextResponse.json({ error: credentialIdValidation.error }, { status: 400 }) - } - - const authz = await authorizeCredentialUse(request, { - credentialId, - requireWorkflowIdForInternal: false, - }) - if (!authz.ok || !authz.credentialOwnerUserId || !authz.resolvedCredentialId) { - return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) - } - - const accessToken = await refreshAccessTokenIfNeeded( - credentialId, - authz.credentialOwnerUserId, - requestId - ) - if (!accessToken) { - return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 }) - } - - // Scope to the requested drive so a SharePoint-targeted block does not list - // the signed-in user's personal OneDrive folders instead. - let drivePath = 'me/drive' - if (driveId) { - const driveIdValidation = validateMicrosoftGraphId(driveId, 'driveId') - if (!driveIdValidation.isValid) { - logger.warn(`[${requestId}] Invalid drive ID`, { error: driveIdValidation.error }) - return NextResponse.json({ error: driveIdValidation.error }, { status: 400 }) - } - drivePath = `drives/${driveId}` - } - - let url = `https://graph.microsoft.com/v1.0/${drivePath}/root/children?$filter=folder ne null&$select=id,name,folder,webUrl,createdDateTime,lastModifiedDateTime&$top=${ONEDRIVE_FOLDERS_PAGE_SIZE}` - - if (query) { - url += `&$search="${encodeURIComponent(query)}"` - } - - const rawItems: MicrosoftGraphDriveItem[] = [] - let nextUrl: string | undefined = url - - for (let page = 0; page < MAX_ONEDRIVE_FOLDERS_PAGES && nextUrl; page++) { - const response = await fetch(nextUrl, { - headers: { - Authorization: `Bearer ${accessToken}`, - }, - }) - - if (!response.ok) { - const errorData = await response - .json() - .catch(() => ({ error: { message: 'Unknown error' } })) - return NextResponse.json( - { error: errorData.error?.message || 'Failed to fetch folders from OneDrive' }, - { status: response.status } - ) - } - - const data = await response.json() - rawItems.push(...((data.value as MicrosoftGraphDriveItem[]) || [])) - - const nextLink = getGraphNextPageUrl(data) - nextUrl = nextLink ? assertGraphNextPageUrl(nextLink) : undefined - - if (nextUrl && page === MAX_ONEDRIVE_FOLDERS_PAGES - 1) { - logger.warn(`[${requestId}] OneDrive folders hit pagination cap; list may be incomplete`, { - pages: MAX_ONEDRIVE_FOLDERS_PAGES, - collected: rawItems.length, - }) - } - } - - const folders = rawItems - .filter((item: MicrosoftGraphDriveItem) => item.folder) - .map((folder: MicrosoftGraphDriveItem) => ({ - id: folder.id, - name: folder.name, - mimeType: 'application/vnd.microsoft.graph.folder', - webViewLink: folder.webUrl, - createdTime: folder.createdDateTime, - modifiedTime: folder.lastModifiedDateTime, - })) - - return NextResponse.json({ files: folders }, { status: 200 }) - } catch (error) { - logger.error(`[${requestId}] Error fetching folders from OneDrive`, error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } -}) diff --git a/apps/sim/app/api/tools/outlook/calendars/route.ts b/apps/sim/app/api/tools/outlook/calendars/route.ts deleted file mode 100644 index 4227222c9e0..00000000000 --- a/apps/sim/app/api/tools/outlook/calendars/route.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { createLogger } from '@sim/logger' -import { toError } from '@sim/utils/errors' -import { type NextRequest, NextResponse } from 'next/server' -import { outlookCalendarsSelectorContract } from '@/lib/api/contracts/selectors/microsoft' -import { parseRequest } from '@/lib/api/server' -import { authorizeCredentialUse } from '@/lib/auth/credential-access' -import { validateAlphanumericId } from '@/lib/core/security/input-validation' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service' -import { assertGraphNextPageUrl, getGraphNextPageUrl } from '@/tools/sharepoint/utils' - -export const dynamic = 'force-dynamic' - -const logger = createLogger('OutlookCalendarsAPI') - -/** - * Microsoft Graph paginates `calendars` via the `@odata.nextLink` absolute URL in the - * response body. Bound the drain so a pathological account can't loop unbounded. - * @see https://learn.microsoft.com/en-us/graph/api/user-list-calendars - */ -const OUTLOOK_CALENDARS_PAGE_SIZE = 100 -const MAX_OUTLOOK_CALENDARS_PAGES = 10 - -interface OutlookCalendar { - id: string - name: string - canEdit?: boolean - owner?: { name?: string; address?: string } -} - -export const GET = withRouteHandler(async (request: NextRequest) => { - try { - const parsed = await parseRequest(outlookCalendarsSelectorContract, request, {}) - if (!parsed.success) return parsed.response - const { credentialId } = parsed.data.query - - const credentialIdValidation = validateAlphanumericId(credentialId, 'credentialId') - if (!credentialIdValidation.isValid) { - logger.warn('Invalid credentialId format', { error: credentialIdValidation.error }) - return NextResponse.json({ error: credentialIdValidation.error }, { status: 400 }) - } - - try { - const credAccess = await authorizeCredentialUse(request, { - credentialId, - requireWorkflowIdForInternal: false, - }) - if (!credAccess.ok || !credAccess.credentialOwnerUserId) { - logger.warn('Credential access denied', { error: credAccess.error }) - return NextResponse.json( - { error: credAccess.error || 'Authentication required' }, - { status: 401 } - ) - } - - const accessToken = await refreshAccessTokenIfNeeded( - credentialId, - credAccess.credentialOwnerUserId, - generateRequestId() - ) - - if (!accessToken) { - logger.error('Failed to get access token', { - credentialId, - userId: credAccess.credentialOwnerUserId, - }) - return NextResponse.json( - { error: 'Could not retrieve access token', authRequired: true }, - { status: 401 } - ) - } - - const calendars: OutlookCalendar[] = [] - let nextUrl: string | undefined = - `https://graph.microsoft.com/v1.0/me/calendars?$top=${OUTLOOK_CALENDARS_PAGE_SIZE}` - - for (let page = 0; page < MAX_OUTLOOK_CALENDARS_PAGES && nextUrl; page++) { - const response = await fetch(nextUrl, { - method: 'GET', - headers: { - Authorization: `Bearer ${accessToken}`, - 'Content-Type': 'application/json', - }, - }) - - if (!response.ok) { - const errorData = await response.json() - logger.error('Microsoft Graph API error getting calendars', { - status: response.status, - error: errorData, - endpoint: nextUrl, - }) - - if (response.status === 401) { - return NextResponse.json( - { - error: 'Authentication failed. Please reconnect your Outlook account.', - authRequired: true, - }, - { status: 401 } - ) - } - - throw new Error(`Microsoft Graph API error: ${JSON.stringify(errorData)}`) - } - - const data = await response.json() - calendars.push(...((data.value as OutlookCalendar[]) || [])) - - const nextLink = getGraphNextPageUrl(data) - nextUrl = nextLink ? assertGraphNextPageUrl(nextLink) : undefined - - if (nextUrl && page === MAX_OUTLOOK_CALENDARS_PAGES - 1) { - logger.warn('Outlook calendars hit pagination cap; calendar list may be incomplete', { - pages: MAX_OUTLOOK_CALENDARS_PAGES, - collected: calendars.length, - }) - } - } - - return NextResponse.json({ - calendars: calendars.map((calendar) => ({ - id: calendar.id, - name: calendar.name, - type: 'calendar', - canEdit: calendar.canEdit ?? false, - ownerAddress: calendar.owner?.address ?? null, - })), - }) - } catch (innerError) { - logger.error('Error during API requests:', innerError) - - const errorMessage = toError(innerError).message - if ( - errorMessage.includes('auth') || - errorMessage.includes('token') || - errorMessage.includes('unauthorized') || - errorMessage.includes('unauthenticated') - ) { - return NextResponse.json( - { - error: 'Authentication failed. Please reconnect your Outlook account.', - authRequired: true, - details: errorMessage, - }, - { status: 401 } - ) - } - - throw innerError - } - } catch (error) { - logger.error('Error processing Outlook calendars request:', error) - return NextResponse.json( - { - error: 'Failed to retrieve Outlook calendars', - details: toError(error).message, - }, - { status: 500 } - ) - } -}) diff --git a/apps/sim/app/api/tools/outlook/folders/route.ts b/apps/sim/app/api/tools/outlook/folders/route.ts deleted file mode 100644 index 1a6721b703b..00000000000 --- a/apps/sim/app/api/tools/outlook/folders/route.ts +++ /dev/null @@ -1,169 +0,0 @@ -import { createLogger } from '@sim/logger' -import { toError } from '@sim/utils/errors' -import { type NextRequest, NextResponse } from 'next/server' -import { outlookFoldersSelectorContract } from '@/lib/api/contracts/selectors/microsoft' -import { parseRequest } from '@/lib/api/server' -import { authorizeCredentialUse } from '@/lib/auth/credential-access' -import { validateAlphanumericId } from '@/lib/core/security/input-validation' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service' -import { assertGraphNextPageUrl, getGraphNextPageUrl } from '@/tools/sharepoint/utils' - -export const dynamic = 'force-dynamic' - -const logger = createLogger('OutlookFoldersAPI') - -/** - * Microsoft Graph paginates `mailFolders` via the `@odata.nextLink` absolute - * URL in the response body (default page size is ~10). Bound the drain so a - * pathological account can't loop unbounded; `$top` is capped at 999 by Graph. - * See https://learn.microsoft.com/en-us/graph/paging - */ -const OUTLOOK_FOLDERS_PAGE_SIZE = 999 -const MAX_OUTLOOK_FOLDERS_PAGES = 20 - -interface OutlookFolder { - id: string - displayName: string - totalItemCount?: number - unreadItemCount?: number -} - -export const GET = withRouteHandler(async (request: NextRequest) => { - try { - const parsed = await parseRequest(outlookFoldersSelectorContract, request, {}) - if (!parsed.success) return parsed.response - const { credentialId } = parsed.data.query - - const credentialIdValidation = validateAlphanumericId(credentialId, 'credentialId') - if (!credentialIdValidation.isValid) { - logger.warn('Invalid credentialId format', { error: credentialIdValidation.error }) - return NextResponse.json({ error: credentialIdValidation.error }, { status: 400 }) - } - - try { - const credAccess = await authorizeCredentialUse(request, { - credentialId, - requireWorkflowIdForInternal: false, - }) - if (!credAccess.ok || !credAccess.credentialOwnerUserId) { - logger.warn('Credential access denied', { error: credAccess.error }) - return NextResponse.json( - { error: credAccess.error || 'Authentication required' }, - { status: 401 } - ) - } - - const accessToken = await refreshAccessTokenIfNeeded( - credentialId, - credAccess.credentialOwnerUserId, - generateRequestId() - ) - - if (!accessToken) { - logger.error('Failed to get access token', { - credentialId, - userId: credAccess.credentialOwnerUserId, - }) - return NextResponse.json( - { - error: 'Could not retrieve access token', - authRequired: true, - }, - { status: 401 } - ) - } - - const folders: OutlookFolder[] = [] - let nextUrl: string | undefined = - `https://graph.microsoft.com/v1.0/me/mailFolders?$top=${OUTLOOK_FOLDERS_PAGE_SIZE}` - - for (let page = 0; page < MAX_OUTLOOK_FOLDERS_PAGES && nextUrl; page++) { - const response = await fetch(nextUrl, { - method: 'GET', - headers: { - Authorization: `Bearer ${accessToken}`, - 'Content-Type': 'application/json', - }, - }) - - if (!response.ok) { - const errorData = await response.json() - logger.error('Microsoft Graph API error getting folders', { - status: response.status, - error: errorData, - endpoint: nextUrl, - }) - - if (response.status === 401) { - return NextResponse.json( - { - error: 'Authentication failed. Please reconnect your Outlook account.', - authRequired: true, - }, - { status: 401 } - ) - } - - throw new Error(`Microsoft Graph API error: ${JSON.stringify(errorData)}`) - } - - const data = await response.json() - folders.push(...((data.value as OutlookFolder[]) || [])) - - const nextLink = getGraphNextPageUrl(data) - nextUrl = nextLink ? assertGraphNextPageUrl(nextLink) : undefined - - if (nextUrl && page === MAX_OUTLOOK_FOLDERS_PAGES - 1) { - logger.warn('Outlook mailFolders hit pagination cap; folder list may be incomplete', { - pages: MAX_OUTLOOK_FOLDERS_PAGES, - collected: folders.length, - }) - } - } - - const transformedFolders = folders.map((folder: OutlookFolder) => ({ - id: folder.id, - name: folder.displayName, - type: 'folder', - messagesTotal: folder.totalItemCount || 0, - messagesUnread: folder.unreadItemCount || 0, - })) - - return NextResponse.json({ - folders: transformedFolders, - }) - } catch (innerError) { - logger.error('Error during API requests:', innerError) - - const errorMessage = toError(innerError).message - if ( - errorMessage.includes('auth') || - errorMessage.includes('token') || - errorMessage.includes('unauthorized') || - errorMessage.includes('unauthenticated') - ) { - return NextResponse.json( - { - error: 'Authentication failed. Please reconnect your Outlook account.', - authRequired: true, - details: errorMessage, - }, - { status: 401 } - ) - } - - throw innerError - } - } catch (error) { - logger.error('Error processing Outlook folders request:', error) - return NextResponse.json( - { - error: 'Failed to retrieve Outlook folders', - details: (error as Error).message, - }, - { status: 500 } - ) - } -}) diff --git a/apps/sim/app/api/tools/pipedrive/pipelines/route.ts b/apps/sim/app/api/tools/pipedrive/pipelines/route.ts deleted file mode 100644 index be03aa5bde7..00000000000 --- a/apps/sim/app/api/tools/pipedrive/pipelines/route.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' -import { pipedrivePipelinesSelectorContract } from '@/lib/api/contracts/selectors' -import { parseRequest } from '@/lib/api/server' -import { authorizeCredentialUse } from '@/lib/auth/credential-access' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { resolveCredentialAccessToken } from '@/lib/oauth/credential-service' -import { getPipedriveAuthHeaders } from '@/tools/pipedrive/utils' - -const logger = createLogger('PipedrivePipelinesAPI') - -export const dynamic = 'force-dynamic' - -const PIPEDRIVE_PAGE_LIMIT = 500 -const PIPEDRIVE_MAX_PIPELINES_PAGES = 50 - -interface PipedrivePipeline { - id: number - name: string -} - -interface PipedrivePipelinesPage { - data?: PipedrivePipeline[] - additional_data?: { - pagination?: { - more_items_in_collection?: boolean - next_start?: number - } - } -} - -/** - * Lists all Pipedrive pipelines using v1 offset pagination (`start`/`limit`), - * following `additional_data.pagination.next_start` while - * `more_items_in_collection` is true so the full set is returned. Bounded by - * `PIPEDRIVE_MAX_PIPELINES_PAGES`; logs a warning rather than silently dropping - * pipelines when the cap is hit. - */ -async function fetchAllPipelines( - authHeaders: Record -): Promise { - const pipelines: PipedrivePipeline[] = [] - let start = 0 - - for (let page = 0; page < PIPEDRIVE_MAX_PIPELINES_PAGES; page++) { - const url = new URL('https://api.pipedrive.com/v1/pipelines') - url.searchParams.set('start', String(start)) - url.searchParams.set('limit', String(PIPEDRIVE_PAGE_LIMIT)) - - const response = await fetch(url.toString(), { - headers: authHeaders, - }) - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})) - throw new PipedriveFetchError(response.status, errorData) - } - - const data = (await response.json()) as PipedrivePipelinesPage - if (Array.isArray(data.data)) { - pipelines.push(...data.data) - } - - const pagination = data.additional_data?.pagination - if (!pagination?.more_items_in_collection || typeof pagination.next_start !== 'number') { - return pipelines - } - start = pagination.next_start - - if (page === PIPEDRIVE_MAX_PIPELINES_PAGES - 1) { - logger.warn( - 'Pipedrive pipelines listing hit pagination cap; pipeline list may be incomplete', - { - pages: PIPEDRIVE_MAX_PIPELINES_PAGES, - } - ) - } - } - - return pipelines -} - -class PipedriveFetchError extends Error { - constructor( - readonly status: number, - readonly details: unknown - ) { - super('Failed to fetch Pipedrive pipelines') - this.name = 'PipedriveFetchError' - } -} - -export const POST = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - try { - const parsed = await parseRequest(pipedrivePipelinesSelectorContract, request, {}) - if (!parsed.success) return parsed.response - const { credential, workflowId } = parsed.data.body - - const authz = await authorizeCredentialUse(request, { - credentialId: credential, - workflowId, - }) - if (!authz.ok || !authz.credentialOwnerUserId) { - return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) - } - - const tokenResult = await resolveCredentialAccessToken( - credential, - authz.credentialOwnerUserId, - requestId - ) - if (!tokenResult) { - logger.error('Failed to get access token', { - credentialId: credential, - userId: authz.credentialOwnerUserId, - }) - return NextResponse.json( - { error: 'Could not retrieve access token', authRequired: true }, - { status: 401 } - ) - } - - let allPipelines: PipedrivePipeline[] - try { - allPipelines = await fetchAllPipelines(getPipedriveAuthHeaders(tokenResult)) - } catch (error) { - if (error instanceof PipedriveFetchError) { - logger.error('Failed to fetch Pipedrive pipelines', { - status: error.status, - error: error.details, - }) - return NextResponse.json( - { error: 'Failed to fetch Pipedrive pipelines', details: error.details }, - { status: error.status } - ) - } - throw error - } - - const pipelines = allPipelines.map((pipeline) => ({ - id: String(pipeline.id), - name: pipeline.name, - })) - - return NextResponse.json({ pipelines }) - } catch (error) { - logger.error('Error processing Pipedrive pipelines request:', error) - return NextResponse.json( - { error: 'Failed to retrieve Pipedrive pipelines', details: (error as Error).message }, - { status: 500 } - ) - } -}) diff --git a/apps/sim/app/api/tools/sharepoint/lists/route.ts b/apps/sim/app/api/tools/sharepoint/lists/route.ts deleted file mode 100644 index 43dc8ae95c9..00000000000 --- a/apps/sim/app/api/tools/sharepoint/lists/route.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' -import { sharepointListsSelectorContract } from '@/lib/api/contracts/selectors' -import { parseRequest } from '@/lib/api/server' -import { authorizeCredentialUse } from '@/lib/auth/credential-access' -import { validateSharePointSiteId } from '@/lib/core/security/input-validation' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service' -import { assertGraphNextPageUrl, getGraphNextPageUrl } from '@/tools/sharepoint/utils' - -export const dynamic = 'force-dynamic' - -const logger = createLogger('SharePointListsAPI') - -/** - * Upper bound on Microsoft Graph pages drained when listing SharePoint lists. - * Each page returns up to `$top=999` lists, so this caps the result set at - * roughly 10k lists while preventing an unbounded server-side loop. - */ -const MAX_LISTS_PAGES = 10 - -interface SharePointList { - id: string - displayName: string - description?: string - webUrl?: string - list?: { - hidden?: boolean - } -} - -export const POST = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - - try { - const parsed = await parseRequest(sharepointListsSelectorContract, request, {}) - if (!parsed.success) { - logger.warn(`[${requestId}] Invalid lists request data`) - return parsed.response - } - const { credential, workflowId, siteId } = parsed.data.body - - const siteIdValidation = validateSharePointSiteId(siteId) - if (!siteIdValidation.isValid) { - logger.error(`[${requestId}] Invalid siteId: ${siteIdValidation.error}`) - return NextResponse.json({ error: siteIdValidation.error }, { status: 400 }) - } - - const authz = await authorizeCredentialUse(request, { - credentialId: credential, - workflowId, - }) - if (!authz.ok || !authz.credentialOwnerUserId) { - return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) - } - - const accessToken = await refreshAccessTokenIfNeeded( - credential, - authz.credentialOwnerUserId, - requestId - ) - if (!accessToken) { - logger.error(`[${requestId}] Failed to obtain valid access token`) - return NextResponse.json( - { error: 'Failed to obtain valid access token', authRequired: true }, - { status: 401 } - ) - } - - let nextUrl: string | undefined = - `https://graph.microsoft.com/v1.0/sites/${siteIdValidation.sanitized}/lists?$select=id,displayName,description,webUrl&$expand=list($select=hidden)&$top=999` - - const rawLists: SharePointList[] = [] - for (let page = 0; page < MAX_LISTS_PAGES && nextUrl; page++) { - const response = await fetch(nextUrl, { - headers: { - Authorization: `Bearer ${accessToken}`, - }, - }) - - if (!response.ok) { - const errorData = await response - .json() - .catch(() => ({ error: { message: 'Unknown error' } })) - return NextResponse.json( - { error: errorData.error?.message || 'Failed to fetch lists from SharePoint' }, - { status: response.status } - ) - } - - const data = await response.json() - if (Array.isArray(data.value)) { - rawLists.push(...data.value) - } - - const nextLink = getGraphNextPageUrl(data) - nextUrl = nextLink ? assertGraphNextPageUrl(nextLink) : undefined - if (nextUrl && page === MAX_LISTS_PAGES - 1) { - logger.warn( - `[${requestId}] SharePoint lists pagination hit ${MAX_LISTS_PAGES}-page cap; result may be incomplete` - ) - } - } - - const lists = rawLists - .filter((list: SharePointList) => list.list?.hidden !== true) - .map((list: SharePointList) => ({ - id: list.id, - displayName: list.displayName, - })) - - logger.info(`[${requestId}] Successfully fetched ${lists.length} SharePoint lists`) - return NextResponse.json({ lists }, { status: 200 }) - } catch (error) { - logger.error(`[${requestId}] Error fetching lists from SharePoint`, error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } -}) diff --git a/apps/sim/app/api/tools/sharepoint/site/route.ts b/apps/sim/app/api/tools/sharepoint/site/route.ts deleted file mode 100644 index 4dc2b508917..00000000000 --- a/apps/sim/app/api/tools/sharepoint/site/route.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { createLogger } from '@sim/logger' -import { generateId } from '@sim/utils/id' -import { type NextRequest, NextResponse } from 'next/server' -import { sharepointSiteQuerySchema } from '@/lib/api/contracts/selectors/sharepoint' -import { getValidationErrorMessage } from '@/lib/api/server' -import { authorizeCredentialUse } from '@/lib/auth/credential-access' -import { validateMicrosoftGraphId } from '@/lib/core/security/input-validation' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service' - -export const dynamic = 'force-dynamic' - -const logger = createLogger('SharePointSiteAPI') - -export const GET = withRouteHandler(async (request: NextRequest) => { - const requestId = generateId().slice(0, 8) - - try { - const { searchParams } = new URL(request.url) - const validation = sharepointSiteQuerySchema.safeParse({ - credentialId: searchParams.get('credentialId') ?? '', - siteId: searchParams.get('siteId') ?? '', - }) - if (!validation.success) { - return NextResponse.json( - { error: getValidationErrorMessage(validation.error, 'Invalid request') }, - { status: 400 } - ) - } - const { credentialId, siteId } = validation.data - - const siteIdValidation = validateMicrosoftGraphId(siteId, 'siteId') - if (!siteIdValidation.isValid) { - return NextResponse.json({ error: siteIdValidation.error }, { status: 400 }) - } - - const authz = await authorizeCredentialUse(request, { credentialId }) - if (!authz.ok || !authz.credentialOwnerUserId || !authz.resolvedCredentialId) { - return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) - } - - const accessToken = await refreshAccessTokenIfNeeded( - authz.resolvedCredentialId, - authz.credentialOwnerUserId, - requestId - ) - if (!accessToken) { - return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 }) - } - - let endpoint: string - if (siteId === 'root') { - endpoint = 'sites/root' - } else if (siteId.includes(':')) { - endpoint = `sites/${siteId}` - } else if (siteId.includes('groups/')) { - endpoint = siteId - } else { - endpoint = `sites/${siteId}` - } - - const response = await fetch( - `https://graph.microsoft.com/v1.0/${endpoint}?$select=id,name,displayName,webUrl,createdDateTime,lastModifiedDateTime`, - { - headers: { - Authorization: `Bearer ${accessToken}`, - }, - } - ) - - if (!response.ok) { - const errorData = await response.json().catch(() => ({ error: { message: 'Unknown error' } })) - return NextResponse.json( - { error: errorData.error?.message || 'Failed to fetch site from SharePoint' }, - { status: response.status } - ) - } - - const site = await response.json() - - const transformedSite = { - id: site.id, - name: site.displayName || site.name, - mimeType: 'application/vnd.microsoft.graph.site', - webViewLink: site.webUrl, - createdTime: site.createdDateTime, - modifiedTime: site.lastModifiedDateTime, - } - - logger.info(`[${requestId}] Successfully fetched SharePoint site: ${transformedSite.name}`) - return NextResponse.json({ site: transformedSite }, { status: 200 }) - } catch (error) { - logger.error(`[${requestId}] Error fetching site from SharePoint`, error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } -}) diff --git a/apps/sim/app/api/tools/sharepoint/sites/route.ts b/apps/sim/app/api/tools/sharepoint/sites/route.ts deleted file mode 100644 index 64bdd3684d5..00000000000 --- a/apps/sim/app/api/tools/sharepoint/sites/route.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' -import { sharepointSitesSelectorContract } from '@/lib/api/contracts/selectors' -import { parseRequest } from '@/lib/api/server' -import { authorizeCredentialUse } from '@/lib/auth/credential-access' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service' -import type { SharepointSite } from '@/tools/sharepoint/types' -import { assertGraphNextPageUrl, getGraphNextPageUrl } from '@/tools/sharepoint/utils' - -export const dynamic = 'force-dynamic' - -const logger = createLogger('SharePointSitesAPI') - -/** - * Upper bound on Microsoft Graph pages drained when listing SharePoint sites. - * Each page returns up to `$top=999` sites, so this caps the result set at - * roughly 10k sites while preventing an unbounded server-side loop. - */ -const MAX_SITES_PAGES = 10 - -export const POST = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - - try { - const parsed = await parseRequest(sharepointSitesSelectorContract, request, {}) - if (!parsed.success) { - logger.warn(`[${requestId}] Invalid sites request data`) - return parsed.response - } - const { credential, workflowId, query } = parsed.data.body - - const authz = await authorizeCredentialUse(request, { - credentialId: credential, - workflowId, - }) - if (!authz.ok || !authz.credentialOwnerUserId) { - return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) - } - - const accessToken = await refreshAccessTokenIfNeeded( - credential, - authz.credentialOwnerUserId, - requestId - ) - if (!accessToken) { - logger.error(`[${requestId}] Failed to obtain valid access token`) - return NextResponse.json( - { error: 'Failed to obtain valid access token', authRequired: true }, - { status: 401 } - ) - } - - const searchQuery = query || '*' - let nextUrl: string | undefined = - `https://graph.microsoft.com/v1.0/sites?search=${encodeURIComponent(searchQuery)}&$select=id,name,displayName,webUrl,createdDateTime,lastModifiedDateTime&$top=999` - - const rawSites: SharepointSite[] = [] - for (let page = 0; page < MAX_SITES_PAGES && nextUrl; page++) { - const response = await fetch(nextUrl, { - headers: { - Authorization: `Bearer ${accessToken}`, - }, - }) - - if (!response.ok) { - const errorData = await response - .json() - .catch(() => ({ error: { message: 'Unknown error' } })) - return NextResponse.json( - { error: errorData.error?.message || 'Failed to fetch sites from SharePoint' }, - { status: response.status } - ) - } - - const data = await response.json() - if (Array.isArray(data.value)) { - rawSites.push(...data.value) - } - - const nextLink = getGraphNextPageUrl(data) - nextUrl = nextLink ? assertGraphNextPageUrl(nextLink) : undefined - if (nextUrl && page === MAX_SITES_PAGES - 1) { - logger.warn( - `[${requestId}] SharePoint sites pagination hit ${MAX_SITES_PAGES}-page cap; result may be incomplete` - ) - } - } - - const sites = rawSites.map((site: SharepointSite) => ({ - id: site.id, - name: site.displayName || site.name, - mimeType: 'application/vnd.microsoft.graph.site', - webViewLink: site.webUrl, - createdTime: site.createdDateTime, - modifiedTime: site.lastModifiedDateTime, - })) - - logger.info(`[${requestId}] Successfully fetched ${sites.length} SharePoint sites`) - return NextResponse.json({ files: sites }, { status: 200 }) - } catch (error) { - logger.error(`[${requestId}] Error fetching sites from SharePoint`, error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } -}) diff --git a/apps/sim/app/api/tools/slack/channels/route.ts b/apps/sim/app/api/tools/slack/channels/route.ts deleted file mode 100644 index 3648f70e4fd..00000000000 --- a/apps/sim/app/api/tools/slack/channels/route.ts +++ /dev/null @@ -1,319 +0,0 @@ -import { db } from '@sim/db' -import { account } from '@sim/db/schema' -import { createLogger } from '@sim/logger' -import { eq } from 'drizzle-orm' -import { type NextRequest, NextResponse } from 'next/server' -import { slackChannelsSelectorContract } from '@/lib/api/contracts/selectors/slack' -import { parseRequest } from '@/lib/api/server' -import { authorizeCredentialUse } from '@/lib/auth/credential-access' -import { validateAlphanumericId } from '@/lib/core/security/input-validation' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service' - -export const dynamic = 'force-dynamic' - -const logger = createLogger('SlackChannelsAPI') - -interface SlackChannel { - id: string - name: string - is_private: boolean - is_archived: boolean - is_member: boolean -} - -/** - * Extracts the installing user's Slack id from credentials connected after the - * privacy fix, which `auth.ts` tags with a `usr_` marker - * (`${teamId}-usr_${installerUserId}-${uuid}`). Legacy credentials encode the - * bot id with no marker and return null, so the caller keeps the existing - * `is_member` filter — no regression. - */ -const SCOPED_USER_ID_PATTERN = - /-usr_([UW][A-Z0-9]+)-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i - -function parseScopedSlackUserId(accountId: string): string | null { - const match = SCOPED_USER_ID_PATTERN.exec(accountId) - if (match) return match[1] - // Marker present but unparseable — surface it rather than silently falling - // back to the bot `is_member` filter and bypassing the privacy scope. - if (accountId.includes('-usr_')) { - logger.warn('Slack accountId carries usr_ marker but did not parse; using is_member fallback', { - accountId, - }) - } - return null -} - -export const POST = withRouteHandler(async (request: NextRequest) => { - try { - const requestId = generateRequestId() - const parsed = await parseRequest(slackChannelsSelectorContract, request, {}) - if (!parsed.success) { - logger.error('Missing credential in request') - return parsed.response - } - const { credential, workflowId } = parsed.data.body - - let accessToken: string - let isBotToken = false - let scopedUserId: string | null = null - - if (credential.startsWith('xoxb-')) { - accessToken = credential - isBotToken = true - logger.info('Using direct bot token for Slack API') - } else { - const authz = await authorizeCredentialUse(request, { - credentialId: credential, - workflowId: workflowId ?? undefined, - }) - if (!authz.ok || !authz.credentialOwnerUserId) { - return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) - } - const resolvedToken = await refreshAccessTokenIfNeeded( - credential, - authz.credentialOwnerUserId, - requestId - ) - if (!resolvedToken) { - logger.error('Failed to get access token', { - credentialId: credential, - userId: authz.credentialOwnerUserId, - }) - return NextResponse.json( - { - error: 'Could not retrieve access token', - authRequired: true, - }, - { status: 401 } - ) - } - accessToken = resolvedToken - - // resolvedCredentialId is an account.id only for OAuth credentials - // (the service_account path returns a credential.id). - if (authz.credentialType === 'oauth' && authz.resolvedCredentialId) { - logger.info('Using OAuth token for Slack API') - const [accountRow] = await db - .select({ accountId: account.accountId }) - .from(account) - .where(eq(account.id, authz.resolvedCredentialId)) - .limit(1) - if (accountRow) { - scopedUserId = parseScopedSlackUserId(accountRow.accountId) - } - } else { - // A custom-bot service_account credential resolves to a bot token with - // no scoped user; treat it like a direct bot token so the private -> - // public channel fallback applies. - isBotToken = true - logger.info('Using custom bot token for Slack API') - } - } - - let data: SlackConversationsResult - try { - data = await fetchSlackChannels(accessToken, true) - if (data.truncated) { - logger.warn('conversations.list hit pagination cap; channel list may be incomplete') - } - logger.info('Successfully fetched channels including private channels') - } catch (error) { - if (isBotToken) { - logger.warn( - 'Failed to fetch private channels with bot token, falling back to public channels only:', - (error as Error).message - ) - try { - data = await fetchSlackChannels(accessToken, false) - logger.info('Successfully fetched public channels only') - } catch (fallbackError) { - logger.error('Failed to fetch channels even with public-only fallback:', fallbackError) - return NextResponse.json( - { error: `Slack API error: ${(fallbackError as Error).message}` }, - { status: 400 } - ) - } - } else { - logger.error('Slack API error with OAuth token:', error) - return NextResponse.json( - { error: `Slack API error: ${(error as Error).message}` }, - { status: 400 } - ) - } - } - - /** - * Slack Marketplace privacy: a private channel may only be shown to a user - * whose own Slack account is a member, even when the bot has been invited. - * `users.conversations?user=` returns the channels the bot AND that user - * share, giving us the allowed set. Public channels are never restricted. - * Without a scoped user id (legacy credentials), fall back to bot membership. - */ - let allowedPrivateChannelIds: Set | null = null - if (scopedUserId) { - try { - const userPrivate = await fetchUserPrivateChannels(accessToken, scopedUserId) - allowedPrivateChannelIds = new Set(userPrivate.channels.map((c) => c.id)) - if (userPrivate.truncated) { - logger.warn( - 'users.conversations hit pagination cap; some private channels the user belongs to may be hidden', - { scopedUserId } - ) - } - logger.info('Scoped private channels to installing user membership', { - scopedUserId, - allowedCount: allowedPrivateChannelIds.size, - }) - } catch (scopeError) { - // Fail closed: if membership can't be verified, hide all private channels. - logger.warn('Failed to scope private channels to user, hiding all private channels', { - error: (scopeError as Error).message, - }) - allowedPrivateChannelIds = new Set() - } - } - - const channels = (data.channels || []) - .filter((channel: SlackChannel) => { - if (channel.is_archived) return false - - if (channel.is_private) { - if (allowedPrivateChannelIds) { - return allowedPrivateChannelIds.has(channel.id) - } - return channel.is_member - } - - return true - }) - .filter((channel: SlackChannel) => { - const validation = validateAlphanumericId(channel.id, 'channelId', 50) - - if (!validation.isValid) { - logger.warn('Invalid channel ID received from Slack API', { - channelId: channel.id, - channelName: channel.name, - error: validation.error, - }) - return false - } - - if (!/^[CDG][A-Z0-9]+$/i.test(channel.id)) { - logger.warn('Channel ID does not match Slack format', { - channelId: channel.id, - channelName: channel.name, - }) - return false - } - - return true - }) - .map((channel: SlackChannel) => ({ - id: channel.id, - name: channel.name, - isPrivate: channel.is_private, - })) - - logger.info(`Successfully fetched ${channels.length} Slack channels`, { - total: data.channels?.length || 0, - private: channels.filter((c: { isPrivate: boolean }) => c.isPrivate).length, - public: channels.filter((c: { isPrivate: boolean }) => !c.isPrivate).length, - tokenType: isBotToken ? 'bot_token' : 'oauth', - userScoped: !!scopedUserId, - }) - return NextResponse.json({ channels }) - } catch (error) { - logger.error('Error processing Slack channels request:', error) - return NextResponse.json( - { error: 'Failed to retrieve Slack channels', details: (error as Error).message }, - { status: 500 } - ) - } -}) - -const SLACK_PAGE_LIMIT = 200 -const SLACK_MAX_PAGES = 10 - -interface SlackConversationsResult { - channels: SlackChannel[] - truncated: boolean -} - -/** - * Lists Slack conversations, following `response_metadata.next_cursor` so the - * full set is returned. Bounded by `SLACK_MAX_PAGES`; sets `truncated` rather - * than silently dropping channels when the cap is hit. - */ -async function fetchAllConversations( - method: 'conversations.list' | 'users.conversations', - accessToken: string, - params: Record -): Promise { - const channels: SlackChannel[] = [] - let cursor: string | undefined - let truncated = false - - for (let page = 0; page < SLACK_MAX_PAGES; page++) { - const url = new URL(`https://slack.com/api/${method}`) - for (const [key, value] of Object.entries(params)) { - url.searchParams.append(key, value) - } - url.searchParams.append('limit', String(SLACK_PAGE_LIMIT)) - if (cursor) { - url.searchParams.append('cursor', cursor) - } - - const response = await fetch(url.toString(), { - method: 'GET', - headers: { Authorization: `Bearer ${accessToken}` }, - }) - - if (!response.ok) { - throw new Error(`Slack API error: ${response.status} ${response.statusText}`) - } - - const data = await response.json() - - if (!data.ok) { - throw new Error(data.error || `Failed to fetch ${method}`) - } - - if (Array.isArray(data.channels)) { - channels.push(...data.channels) - } - - cursor = data.response_metadata?.next_cursor?.trim() || undefined - if (!cursor) { - return { channels, truncated } - } - if (page === SLACK_MAX_PAGES - 1) { - truncated = true - } - } - - return { channels, truncated } -} - -async function fetchSlackChannels( - accessToken: string, - includePrivate = true -): Promise { - return fetchAllConversations('conversations.list', accessToken, { - types: includePrivate ? 'public_channel,private_channel' : 'public_channel', - exclude_archived: 'true', - }) -} - -async function fetchUserPrivateChannels( - accessToken: string, - userId: string -): Promise { - return fetchAllConversations('users.conversations', accessToken, { - user: userId, - types: 'private_channel', - exclude_archived: 'true', - }) -} diff --git a/apps/sim/app/api/tools/slack/users/route.ts b/apps/sim/app/api/tools/slack/users/route.ts deleted file mode 100644 index 6d0ae67e31a..00000000000 --- a/apps/sim/app/api/tools/slack/users/route.ts +++ /dev/null @@ -1,197 +0,0 @@ -import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' -import { slackUsersListOrDetailContract } from '@/lib/api/contracts/selectors/slack' -import { parseRequest } from '@/lib/api/server' -import { authorizeCredentialUse } from '@/lib/auth/credential-access' -import { validateAlphanumericId } from '@/lib/core/security/input-validation' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service' - -export const dynamic = 'force-dynamic' - -const logger = createLogger('SlackUsersAPI') - -const SLACK_PAGE_LIMIT = 200 -const SLACK_MAX_USER_PAGES = 10 - -interface SlackUser { - id: string - name: string - real_name: string - deleted: boolean - is_bot: boolean -} - -interface SlackUsersResult { - members: SlackUser[] - truncated: boolean -} - -export const POST = withRouteHandler(async (request: NextRequest) => { - try { - const requestId = generateRequestId() - const parsed = await parseRequest(slackUsersListOrDetailContract, request, {}) - if (!parsed.success) { - logger.error('Missing credential in request') - return parsed.response - } - const { credential, workflowId, userId } = parsed.data.body - - if (userId !== undefined && userId !== null) { - const validation = validateAlphanumericId(userId, 'userId', 100) - if (!validation.isValid) { - logger.warn('Invalid Slack user ID', { userId, error: validation.error }) - return NextResponse.json({ error: validation.error }, { status: 400 }) - } - } - - let accessToken: string - const isBotToken = credential.startsWith('xoxb-') - - if (isBotToken) { - accessToken = credential - logger.info('Using direct bot token for Slack API') - } else { - const authz = await authorizeCredentialUse(request, { - credentialId: credential, - workflowId, - }) - if (!authz.ok || !authz.credentialOwnerUserId) { - return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) - } - const resolvedToken = await refreshAccessTokenIfNeeded( - credential, - authz.credentialOwnerUserId, - requestId - ) - if (!resolvedToken) { - logger.error('Failed to get access token', { - credentialId: credential, - userId: authz.credentialOwnerUserId, - }) - return NextResponse.json( - { - error: 'Could not retrieve access token', - authRequired: true, - }, - { status: 401 } - ) - } - accessToken = resolvedToken - logger.info('Using OAuth token for Slack API') - } - - if (userId) { - const userData = await fetchSlackUser(accessToken, userId) - const user = { - id: userData.user.id, - name: userData.user.name, - real_name: userData.user.real_name || userData.user.name, - } - logger.info(`Successfully fetched Slack user: ${userId}`) - return NextResponse.json({ user }) - } - - const data = await fetchSlackUsers(accessToken) - if (data.truncated) { - logger.warn('users.list hit pagination cap; user list may be incomplete') - } - - const users = (data.members || []) - .filter((user: SlackUser) => !user.deleted && !user.is_bot) - .map((user: SlackUser) => ({ - id: user.id, - name: user.name, - real_name: user.real_name || user.name, - })) - - logger.info(`Successfully fetched ${users.length} Slack users`, { - total: data.members?.length || 0, - tokenType: isBotToken ? 'bot_token' : 'oauth', - }) - return NextResponse.json({ users }) - } catch (error) { - logger.error('Error processing Slack users request:', error) - return NextResponse.json( - { error: 'Failed to retrieve Slack users', details: (error as Error).message }, - { status: 500 } - ) - } -}) - -async function fetchSlackUser(accessToken: string, userId: string) { - const url = new URL('https://slack.com/api/users.info') - url.searchParams.append('user', userId) - - const response = await fetch(url.toString(), { - method: 'GET', - headers: { - Authorization: `Bearer ${accessToken}`, - 'Content-Type': 'application/json', - }, - }) - - if (!response.ok) { - throw new Error(`Slack API error: ${response.status} ${response.statusText}`) - } - - const data = await response.json() - - if (!data.ok) { - throw new Error(data.error || 'Failed to fetch user') - } - - return data -} - -/** - * Lists Slack workspace members, following `response_metadata.next_cursor` so - * the full set is returned. Bounded by `SLACK_MAX_USER_PAGES`; sets `truncated` - * rather than silently dropping members when the cap is hit. - */ -async function fetchSlackUsers(accessToken: string): Promise { - const members: SlackUser[] = [] - let cursor: string | undefined - let truncated = false - - for (let page = 0; page < SLACK_MAX_USER_PAGES; page++) { - const url = new URL('https://slack.com/api/users.list') - url.searchParams.append('limit', String(SLACK_PAGE_LIMIT)) - if (cursor) { - url.searchParams.append('cursor', cursor) - } - - const response = await fetch(url.toString(), { - method: 'GET', - headers: { - Authorization: `Bearer ${accessToken}`, - 'Content-Type': 'application/json', - }, - }) - - if (!response.ok) { - throw new Error(`Slack API error: ${response.status} ${response.statusText}`) - } - - const data = await response.json() - - if (!data.ok) { - throw new Error(data.error || 'Failed to fetch users') - } - - if (Array.isArray(data.members)) { - members.push(...data.members) - } - - cursor = data.response_metadata?.next_cursor?.trim() || undefined - if (!cursor) { - return { members, truncated } - } - if (page === SLACK_MAX_USER_PAGES - 1) { - truncated = true - } - } - - return { members, truncated } -} diff --git a/apps/sim/app/api/tools/snowflake/objects/route.ts b/apps/sim/app/api/tools/snowflake/objects/route.ts deleted file mode 100644 index 0b3ab32e084..00000000000 --- a/apps/sim/app/api/tools/snowflake/objects/route.ts +++ /dev/null @@ -1,193 +0,0 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import { type NextRequest, NextResponse } from 'next/server' -import { snowflakeObjectsSelectorContract } from '@/lib/api/contracts/selectors/snowflake' -import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' -import { authorizeCredentialUse } from '@/lib/auth/credential-access' -import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { resolveCredentialAccessToken } from '@/lib/oauth/credential-service' -import { buildSelectorStatement } from '@/tools/snowflake/sql' -import { - buildSnowflakeAuthHeaders, - normalizeSnowflakeHost, - readSnowflakeResult, -} from '@/tools/snowflake/utils' - -const logger = createLogger('SnowflakeObjectsAPI') - -export const dynamic = 'force-dynamic' - -/** Rows a single picker may pull back. Bounded in SQL, not after the fact. */ -const SELECTOR_ROW_LIMIT = 1000 - -/** Seconds Snowflake may spend on a picker statement before giving up. */ -const SELECTOR_TIMEOUT_SECONDS = 20 - -/** - * HTTP-level abort. `SELECTOR_TIMEOUT_SECONDS` only bounds Snowflake's own - * execution; without this a stalled socket would pin the request until the - * runtime's socket wall. - */ -const SELECTOR_FETCH_TIMEOUT_MS = (SELECTOR_TIMEOUT_SECONDS + 10) * 1000 - -interface SnowflakeObject { - name: string - detail: string | null -} - -/** - * Parses the JSON array `CURRENT_AVAILABLE_ROLES()` returns — a single row - * holding a string like `["PUBLIC","ANALYST"]`. - */ -function parseAvailableRoles(cellValue: string | null | undefined): SnowflakeObject[] { - if (!cellValue) return [] - let parsed: unknown - try { - parsed = JSON.parse(cellValue) - } catch { - logger.warn('CURRENT_AVAILABLE_ROLES returned a non-JSON payload') - return [] - } - if (!Array.isArray(parsed)) return [] - return parsed - .filter((role): role is string => typeof role === 'string' && role.length > 0) - .sort((a, b) => a.localeCompare(b)) - .map((role) => ({ name: role, detail: null })) -} - -/** - * POST /api/tools/snowflake/objects - * - * Enumerates Snowflake objects for the editor's pickers (databases, schemas, - * tables, warehouses, roles, file formats, procedures) using the selected - * programmatic-access-token credential. Every statement is - * metadata-only, so no warehouse is required and nothing is billed for - * compute. The token never reaches the browser — the credential id is - * resolved server-side on each call. - */ -export const POST = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - - // Authenticate the caller before touching the body: contract validation must - // never run for an unauthenticated request. This reads headers and the - // session only, so it is safe ahead of parsing. - const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: true }) - if (!auth.success || !auth.userId) { - return NextResponse.json({ error: auth.error || 'Authentication required' }, { status: 401 }) - } - - const parsed = await parseRequest( - snowflakeObjectsSelectorContract, - request, - {}, - { - validationErrorResponse: (error) => { - const path = error.issues.at(0)?.path[0] - const message = - path === 'credential' - ? 'Credential is required' - : getValidationErrorMessage(error, 'Invalid request') - logger.error(`Validation failed for Snowflake objects request: ${message}`) - return NextResponse.json({ error: message }, { status: 400 }) - }, - } - ) - if (!parsed.success) return parsed.response - const { credential, workflowId, kind, database, schema } = parsed.data.body - - const authz = await authorizeCredentialUse(request, { - credentialId: credential, - workflowId, - callerUserId: auth.userId, - }) - if (!authz.ok || !authz.credentialOwnerUserId) { - return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) - } - - const token = await resolveCredentialAccessToken( - credential, - authz.credentialOwnerUserId, - requestId - ) - if (!token?.accessToken || !token.domain) { - logger.error('Failed to resolve Snowflake credential', { credentialId: credential, kind }) - return NextResponse.json( - { error: 'Could not resolve the Snowflake credential', authRequired: true }, - { status: 401 } - ) - } - - let baseUrl: string - let statement: string - try { - baseUrl = normalizeSnowflakeHost(token.domain) - statement = buildSelectorStatement(kind, { database, schema }, SELECTOR_ROW_LIMIT).statement - } catch (error) { - return NextResponse.json({ error: getErrorMessage(error, 'Invalid request') }, { status: 400 }) - } - - let upstreamStatus = 0 - try { - const response = await fetch(`${baseUrl}/api/v2/statements`, { - method: 'POST', - headers: buildSnowflakeAuthHeaders(token.accessToken), - body: JSON.stringify({ - statement, - timeout: SELECTOR_TIMEOUT_SECONDS, - parameters: { rows_per_resultset: SELECTOR_ROW_LIMIT }, - }), - signal: AbortSignal.any([request.signal, AbortSignal.timeout(SELECTOR_FETCH_TIMEOUT_MS)]), - }) - - // A rejected credential must tell the picker to reconnect rather than read - // as an outage or a bad request. 403 belongs here alongside 401: Snowflake - // uses it for a network-policy rejection and for a disabled SQL API, which - // is why the credential validator also treats it as a credential problem. - if (response.status === 401 || response.status === 403) { - logger.warn('Snowflake rejected the stored credential', { credentialId: credential, kind }) - return NextResponse.json( - { - error: - 'Snowflake rejected this credential. Check that it has not expired and that a network policy allows Sim to reach the account, then reconnect it.', - authRequired: true, - }, - { status: 401 } - ) - } - - // A remaining 4xx names something wrong with the request itself (unknown - // object, malformed statement); only a 5xx or an unreadable body is a - // gateway failure. - upstreamStatus = response.status - const result = await readSnowflakeResult(response) - // A metadata-only statement completes synchronously; a 202 means Snowflake - // deferred it, and returning an empty list would read as "no objects". - if (!result.result) { - logger.warn('Snowflake deferred a picker statement', { kind, status: result.status }) - return NextResponse.json( - { error: 'Snowflake did not return the object list in time. Try again in a moment.' }, - { status: 502 } - ) - } - const rows = result.result.rows - - const objects: SnowflakeObject[] = - kind === 'roles' - ? parseAvailableRoles(rows[0]?.[0]) - : rows.flatMap((row) => { - const name = row[0] - if (typeof name !== 'string' || !name) return [] - return [{ name, detail: typeof row[1] === 'string' ? row[1] : null }] - }) - - return NextResponse.json({ objects }) - } catch (error) { - logger.error('Failed to list Snowflake objects', { kind, error }) - return NextResponse.json( - { error: getErrorMessage(error, 'Failed to list Snowflake objects') }, - { status: upstreamStatus >= 400 && upstreamStatus < 500 ? 400 : 502 } - ) - } -}) diff --git a/apps/sim/app/api/tools/trello/boards/route.ts b/apps/sim/app/api/tools/trello/boards/route.ts deleted file mode 100644 index 8ce07d0500f..00000000000 --- a/apps/sim/app/api/tools/trello/boards/route.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' -import { trelloBoardsSelectorContract } from '@/lib/api/contracts/selectors' -import { parseRequest } from '@/lib/api/server' -import { authorizeCredentialUse } from '@/lib/auth/credential-access' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service' - -const logger = createLogger('TrelloBoardsAPI') - -export const dynamic = 'force-dynamic' - -export const POST = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - try { - const apiKey = process.env.TRELLO_API_KEY - if (!apiKey) { - logger.error('Trello API key not configured') - return NextResponse.json({ error: 'Trello API key not configured' }, { status: 500 }) - } - const parsed = await parseRequest(trelloBoardsSelectorContract, request, {}) - if (!parsed.success) return parsed.response - const { credential, workflowId } = parsed.data.body - - const authz = await authorizeCredentialUse(request, { - credentialId: credential, - workflowId, - }) - if (!authz.ok || !authz.credentialOwnerUserId) { - return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) - } - - const accessToken = await refreshAccessTokenIfNeeded( - credential, - authz.credentialOwnerUserId, - requestId - ) - if (!accessToken) { - logger.error('Failed to get access token', { - credentialId: credential, - userId: authz.credentialOwnerUserId, - }) - return NextResponse.json( - { error: 'Could not retrieve access token', authRequired: true }, - { status: 401 } - ) - } - - const response = await fetch( - `https://api.trello.com/1/members/me/boards?key=${apiKey}&token=${accessToken}&fields=id,name,closed`, - { - headers: { - Accept: 'application/json', - }, - } - ) - - if (!response.ok) { - const errorData = await response.json().catch(() => null) - logger.error('Failed to fetch Trello boards', { - status: response.status, - error: errorData, - }) - return NextResponse.json( - { error: 'Failed to fetch Trello boards', details: errorData }, - { status: response.status } - ) - } - - 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) { - logger.error('Error processing Trello boards request:', error) - return NextResponse.json( - { error: 'Failed to retrieve Trello boards', details: (error as Error).message }, - { status: 500 } - ) - } -}) diff --git a/apps/sim/app/api/tools/wealthbox/item/route.ts b/apps/sim/app/api/tools/wealthbox/item/route.ts deleted file mode 100644 index da8ad62b91f..00000000000 --- a/apps/sim/app/api/tools/wealthbox/item/route.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' -import { wealthboxItemContract } from '@/lib/api/contracts/selectors/wealthbox' -import { parseRequest } from '@/lib/api/server' -import { authorizeCredentialUse } from '@/lib/auth/credential-access' -import { validatePathSegment } from '@/lib/core/security/input-validation' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service' - -export const dynamic = 'force-dynamic' - -const logger = createLogger('WealthboxItemAPI') - -export const GET = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - - try { - const parsed = await parseRequest(wealthboxItemContract, request, {}) - if (!parsed.success) return parsed.response - const { credentialId, itemId, type } = parsed.data.query - - const itemIdValidation = validatePathSegment(itemId, { - paramName: 'itemId', - maxLength: 100, - allowHyphens: true, - allowUnderscores: true, - allowDots: false, - }) - if (!itemIdValidation.isValid) { - logger.warn(`[${requestId}] Invalid itemId format: ${itemId}`) - return NextResponse.json({ error: itemIdValidation.error }, { status: 400 }) - } - - const credentialIdValidation = validatePathSegment(credentialId, { - paramName: 'credentialId', - maxLength: 100, - allowHyphens: true, - allowUnderscores: true, - allowDots: false, - }) - if (!credentialIdValidation.isValid) { - logger.warn(`[${requestId}] Invalid credentialId format: ${credentialId}`) - return NextResponse.json({ error: credentialIdValidation.error }, { status: 400 }) - } - - const credAccess = await authorizeCredentialUse(request, { - credentialId, - requireWorkflowIdForInternal: false, - }) - if (!credAccess.ok || !credAccess.credentialOwnerUserId) { - logger.warn(`[${requestId}] Credential access denied`, { error: credAccess.error }) - return NextResponse.json({ error: credAccess.error || 'Unauthorized' }, { status: 401 }) - } - - const accessToken = await refreshAccessTokenIfNeeded( - credentialId, - credAccess.credentialOwnerUserId, - requestId - ) - - if (!accessToken) { - logger.error(`[${requestId}] Failed to obtain valid access token`) - return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 }) - } - - const endpoints = { - note: 'notes', - contact: 'contacts', - task: 'tasks', - } - const endpoint = endpoints[type as keyof typeof endpoints] - - logger.info(`[${requestId}] Fetching ${type} ${itemId} from Wealthbox`) - - const response = await fetch(`https://api.crmworkspace.com/v1/${endpoint}/${itemId}`, { - headers: { - Authorization: `Bearer ${accessToken}`, - 'Content-Type': 'application/json', - }, - }) - - if (!response.ok) { - const errorText = await response.text() - logger.error( - `[${requestId}] Wealthbox API error: ${response.status} ${response.statusText}`, - { - error: errorText, - endpoint, - itemId, - } - ) - - if (response.status === 404) { - return NextResponse.json({ error: 'Item not found' }, { status: 404 }) - } - - return NextResponse.json( - { error: `Failed to fetch ${type} from Wealthbox` }, - { status: response.status } - ) - } - - const data = (await response.json()) as Record - - const firstName = typeof data.first_name === 'string' ? data.first_name : '' - const lastName = typeof data.last_name === 'string' ? data.last_name : '' - const item = { - id: data.id?.toString() || itemId, - name: - (typeof data.content === 'string' && data.content) || - (typeof data.name === 'string' && data.name) || - `${firstName} ${lastName}`.trim() || - `${type} ${data.id}`, - type, - content: typeof data.content === 'string' ? data.content : '', - createdAt: typeof data.created_at === 'string' ? data.created_at : '', - updatedAt: typeof data.updated_at === 'string' ? data.updated_at : '', - } - - logger.info(`[${requestId}] Successfully fetched ${type} ${itemId} from Wealthbox`) - - return NextResponse.json({ item }, { status: 200 }) - } catch (error) { - logger.error(`[${requestId}] Error fetching Wealthbox item`, error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } -}) diff --git a/apps/sim/app/api/tools/wealthbox/items/route.ts b/apps/sim/app/api/tools/wealthbox/items/route.ts deleted file mode 100644 index 98223382e94..00000000000 --- a/apps/sim/app/api/tools/wealthbox/items/route.ts +++ /dev/null @@ -1,203 +0,0 @@ -import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' -import { wealthboxItemsSelectorContract } from '@/lib/api/contracts/selectors/wealthbox' -import { parseRequest } from '@/lib/api/server' -import { authorizeCredentialUse } from '@/lib/auth/credential-access' -import { validatePathSegment } from '@/lib/core/security/input-validation' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service' - -export const dynamic = 'force-dynamic' - -const logger = createLogger('WealthboxItemsAPI') - -/** - * Wealthbox `GET /v1/contacts` paginates with `?page=` / `?per_page=`, starting - * at page 1. Wealthbox documents no `per_page` maximum and its contacts response - * carries no pagination `meta`, so termination relies on the short-page check: - * we stop once a page returns fewer items than `WEALTHBOX_PAGE_SIZE` (the - * `meta.total_pages` / `meta.current_page` check is a defensive fallback for if - * Wealthbox ever adds that block). Bounded by `MAX_WEALTHBOX_PAGES` so a runaway - * response can't loop forever. - */ -const WEALTHBOX_PAGE_SIZE = 50 -const MAX_WEALTHBOX_PAGES = 50 - -interface WealthboxItem { - id: string - name: string - type: string - content: string - createdAt: string - updatedAt: string -} - -interface WealthboxContactsPage { - contacts?: Array> - meta?: { - total_count?: number - total_pages?: number - current_page?: number - } -} - -/** - * Get items (notes, contacts, tasks) from Wealthbox - */ -export const GET = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - - try { - const parsed = await parseRequest(wealthboxItemsSelectorContract, request, {}) - if (!parsed.success) return parsed.response - const { credentialId, type } = parsed.data.query - const query = parsed.data.query.query ?? '' - - const credentialIdValidation = validatePathSegment(credentialId, { - paramName: 'credentialId', - maxLength: 100, - allowHyphens: true, - allowUnderscores: true, - allowDots: false, - }) - if (!credentialIdValidation.isValid) { - logger.warn(`[${requestId}] Invalid credentialId format: ${credentialId}`) - return NextResponse.json({ error: credentialIdValidation.error }, { status: 400 }) - } - - const authz = await authorizeCredentialUse(request, { - credentialId, - requireWorkflowIdForInternal: false, - }) - if (!authz.ok || !authz.credentialOwnerUserId) { - return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) - } - - const accessToken = await refreshAccessTokenIfNeeded( - credentialId, - authz.credentialOwnerUserId, - requestId - ) - - if (!accessToken) { - logger.error(`[${requestId}] Failed to obtain valid access token`) - return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 }) - } - - const endpoints = { - contact: 'contacts', - } - const endpoint = endpoints[type as keyof typeof endpoints] - - logger.info(`[${requestId}] Fetching ${type}s from Wealthbox`, { - endpoint, - hasQuery: !!query.trim(), - }) - - const allContacts: Array> = [] - let page = 1 - - for (; page <= MAX_WEALTHBOX_PAGES; page++) { - const url = new URL(`https://api.crmworkspace.com/v1/${endpoint}`) - url.searchParams.set('per_page', String(WEALTHBOX_PAGE_SIZE)) - url.searchParams.set('page', String(page)) - - const response = await fetch(url.toString(), { - headers: { - Authorization: `Bearer ${accessToken}`, - 'Content-Type': 'application/json', - }, - }) - - if (!response.ok) { - const errorText = await response.text() - logger.error( - `[${requestId}] Wealthbox API error: ${response.status} ${response.statusText}`, - { - error: errorText, - endpoint, - url: url.toString(), - } - ) - return NextResponse.json( - { error: `Failed to fetch ${type}s from Wealthbox` }, - { status: response.status } - ) - } - - const data = (await response.json()) as WealthboxContactsPage - - const contacts = data.contacts || [] - if (!Array.isArray(contacts)) { - logger.warn(`[${requestId}] Contacts is not an array`, { - contacts, - dataType: typeof contacts, - }) - break - } - - allContacts.push(...contacts) - - const totalPages = data.meta?.total_pages - const currentPage = data.meta?.current_page ?? page - const reachedLastByMeta = - typeof totalPages === 'number' && totalPages > 0 && currentPage >= totalPages - const reachedLastByCount = contacts.length < WEALTHBOX_PAGE_SIZE - - if (reachedLastByMeta || reachedLastByCount) { - break - } - - if (page === MAX_WEALTHBOX_PAGES) { - logger.warn( - `[${requestId}] Wealthbox pagination hit MAX_WEALTHBOX_PAGES cap; contact list may be incomplete`, - { endpoint, maxPages: MAX_WEALTHBOX_PAGES } - ) - } - } - - logger.info(`[${requestId}] Wealthbox API drained`, { - type, - pagesFetched: Math.min(page, MAX_WEALTHBOX_PAGES), - totalContacts: allContacts.length, - }) - - let items: WealthboxItem[] = [] - - if (type === 'contact') { - items = allContacts.map((item) => { - const firstName = typeof item.first_name === 'string' ? item.first_name : '' - const lastName = typeof item.last_name === 'string' ? item.last_name : '' - return { - id: item.id?.toString() || '', - name: `${firstName} ${lastName}`.trim() || `Contact ${item.id ?? ''}`, - type: 'contact', - content: - typeof item.background_information === 'string' ? item.background_information : '', - createdAt: typeof item.created_at === 'string' ? item.created_at : '', - updatedAt: typeof item.updated_at === 'string' ? item.updated_at : '', - } - }) - } - - if (query.trim()) { - const searchTerm = query.trim().toLowerCase() - items = items.filter( - (item) => - item.name.toLowerCase().includes(searchTerm) || - item.content.toLowerCase().includes(searchTerm) - ) - } - - logger.info(`[${requestId}] Successfully fetched ${items.length} ${type}s from Wealthbox`, { - totalItems: items.length, - hasSearchQuery: !!query.trim(), - }) - - return NextResponse.json({ items }, { status: 200 }) - } catch (error) { - logger.error(`[${requestId}] Error fetching Wealthbox items`, error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } -}) diff --git a/apps/sim/app/api/tools/webflow/collections/route.ts b/apps/sim/app/api/tools/webflow/collections/route.ts deleted file mode 100644 index 4fd56b91f58..00000000000 --- a/apps/sim/app/api/tools/webflow/collections/route.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' -import { webflowCollectionsSelectorContract } from '@/lib/api/contracts/selectors' -import { parseRequest } from '@/lib/api/server' -import { authorizeCredentialUse } from '@/lib/auth/credential-access' -import { validateAlphanumericId } from '@/lib/core/security/input-validation' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service' - -const logger = createLogger('WebflowCollectionsAPI') - -export const dynamic = 'force-dynamic' - -interface WebflowCollection { - id: string - displayName?: string - slug?: string -} - -export const POST = withRouteHandler(async (request: NextRequest) => { - try { - const requestId = generateRequestId() - const parsed = await parseRequest(webflowCollectionsSelectorContract, request, {}) - if (!parsed.success) return parsed.response - const { credential, workflowId, siteId } = parsed.data.body - - const siteIdValidation = validateAlphanumericId(siteId, 'siteId') - if (!siteIdValidation.isValid) { - logger.error('Invalid siteId', { error: siteIdValidation.error }) - return NextResponse.json({ error: siteIdValidation.error }, { status: 400 }) - } - - const authz = await authorizeCredentialUse(request, { - credentialId: credential, - workflowId, - }) - if (!authz.ok || !authz.credentialOwnerUserId) { - return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) - } - - const accessToken = await refreshAccessTokenIfNeeded( - credential, - authz.credentialOwnerUserId, - requestId - ) - if (!accessToken) { - logger.error('Failed to get access token', { - credentialId: credential, - userId: authz.credentialOwnerUserId, - }) - return NextResponse.json( - { - error: 'Could not retrieve access token', - authRequired: true, - }, - { status: 401 } - ) - } - - const response = await fetch(`https://api.webflow.com/v2/sites/${siteId}/collections`, { - headers: { - Authorization: `Bearer ${accessToken}`, - accept: 'application/json', - }, - }) - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})) - logger.error('Failed to fetch Webflow collections', { - status: response.status, - error: errorData, - siteId, - }) - return NextResponse.json( - { error: 'Failed to fetch Webflow collections', details: errorData }, - { status: response.status } - ) - } - - const data = (await response.json()) as { collections?: WebflowCollection[] } - const collections = data.collections || [] - - const formattedCollections = collections.map((collection) => ({ - id: collection.id, - name: collection.displayName || collection.slug || collection.id, - })) - - return NextResponse.json({ collections: formattedCollections }) - } catch (error) { - logger.error('Error processing Webflow collections request:', error) - return NextResponse.json( - { error: 'Failed to retrieve Webflow collections', details: (error as Error).message }, - { status: 500 } - ) - } -}) diff --git a/apps/sim/app/api/tools/webflow/items/route.ts b/apps/sim/app/api/tools/webflow/items/route.ts deleted file mode 100644 index 3a363c5eacc..00000000000 --- a/apps/sim/app/api/tools/webflow/items/route.ts +++ /dev/null @@ -1,177 +0,0 @@ -import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' -import { webflowItemsSelectorContract } from '@/lib/api/contracts/selectors' -import { parseRequest } from '@/lib/api/server' -import { authorizeCredentialUse } from '@/lib/auth/credential-access' -import { validateAlphanumericId } from '@/lib/core/security/input-validation' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service' - -const logger = createLogger('WebflowItemsAPI') - -export const dynamic = 'force-dynamic' - -const WEBFLOW_PAGE_LIMIT = 100 -const WEBFLOW_MAX_ITEMS_PAGES = 50 - -interface WebflowItem { - id: string - fieldData?: { - name?: string - title?: string - slug?: string - } -} - -interface WebflowItemsPage { - items?: WebflowItem[] - pagination?: { - total?: number - limit?: number - offset?: number - } -} - -/** - * Lists all items in a Webflow collection using `offset`/`limit` pagination - * (limit capped at 100), advancing the numeric `offset` until the accumulated - * count reaches `pagination.total` so the full set is returned. Bounded by - * `WEBFLOW_MAX_ITEMS_PAGES`; logs a warning rather than silently dropping items - * when the cap is hit. - */ -async function fetchAllItems(accessToken: string, collectionId: string): Promise { - const items: WebflowItem[] = [] - let offset = 0 - - for (let page = 0; page < WEBFLOW_MAX_ITEMS_PAGES; page++) { - const url = new URL(`https://api.webflow.com/v2/collections/${collectionId}/items`) - url.searchParams.set('limit', String(WEBFLOW_PAGE_LIMIT)) - url.searchParams.set('offset', String(offset)) - - const response = await fetch(url.toString(), { - headers: { - Authorization: `Bearer ${accessToken}`, - accept: 'application/json', - }, - }) - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})) - throw new WebflowFetchError(response.status, errorData) - } - - const data = (await response.json()) as WebflowItemsPage - const pageItems = data.items || [] - items.push(...pageItems) - - const total = data.pagination?.total - offset += pageItems.length - if (pageItems.length === 0 || (typeof total === 'number' && items.length >= total)) { - return items - } - - if (page === WEBFLOW_MAX_ITEMS_PAGES - 1) { - logger.warn('Webflow items listing hit pagination cap; item list may be incomplete', { - collectionId, - pages: WEBFLOW_MAX_ITEMS_PAGES, - }) - } - } - - return items -} - -class WebflowFetchError extends Error { - constructor( - readonly status: number, - readonly details: unknown - ) { - super('Failed to fetch Webflow items') - this.name = 'WebflowFetchError' - } -} - -export const POST = withRouteHandler(async (request: NextRequest) => { - try { - const requestId = generateRequestId() - const parsed = await parseRequest(webflowItemsSelectorContract, request, {}) - if (!parsed.success) return parsed.response - const { credential, workflowId, collectionId, search } = parsed.data.body - - const collectionIdValidation = validateAlphanumericId(collectionId, 'collectionId') - if (!collectionIdValidation.isValid) { - logger.error('Invalid collectionId', { error: collectionIdValidation.error }) - return NextResponse.json({ error: collectionIdValidation.error }, { status: 400 }) - } - - const authz = await authorizeCredentialUse(request, { - credentialId: credential, - workflowId, - }) - if (!authz.ok || !authz.credentialOwnerUserId) { - return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) - } - - const accessToken = await refreshAccessTokenIfNeeded( - credential, - authz.credentialOwnerUserId, - requestId - ) - if (!accessToken) { - logger.error('Failed to get access token', { - credentialId: credential, - userId: authz.credentialOwnerUserId, - }) - return NextResponse.json( - { - error: 'Could not retrieve access token', - authRequired: true, - }, - { status: 401 } - ) - } - - let items: WebflowItem[] - try { - items = await fetchAllItems(accessToken, collectionId) - } catch (error) { - if (error instanceof WebflowFetchError) { - logger.error('Failed to fetch Webflow items', { - status: error.status, - error: error.details, - collectionId, - }) - return NextResponse.json( - { error: 'Failed to fetch Webflow items', details: error.details }, - { status: error.status } - ) - } - throw error - } - - let formattedItems = items.map((item) => { - const fieldData = item.fieldData || {} - const name = fieldData.name || fieldData.title || fieldData.slug || item.id - return { - id: item.id, - name, - } - }) - - if (search) { - const searchLower = search.toLowerCase() - formattedItems = formattedItems.filter((item: { id: string; name: string }) => - item.name.toLowerCase().includes(searchLower) - ) - } - - return NextResponse.json({ items: formattedItems }) - } catch (error) { - logger.error('Error processing Webflow items request:', error) - return NextResponse.json( - { error: 'Failed to retrieve Webflow items', details: (error as Error).message }, - { status: 500 } - ) - } -}) diff --git a/apps/sim/app/api/tools/webflow/sites/route.ts b/apps/sim/app/api/tools/webflow/sites/route.ts deleted file mode 100644 index 4aab5b999b2..00000000000 --- a/apps/sim/app/api/tools/webflow/sites/route.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' -import { webflowSitesSelectorContract } from '@/lib/api/contracts/selectors' -import { parseRequest } from '@/lib/api/server' -import { authorizeCredentialUse } from '@/lib/auth/credential-access' -import { validateAlphanumericId } from '@/lib/core/security/input-validation' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service' - -const logger = createLogger('WebflowSitesAPI') - -export const dynamic = 'force-dynamic' - -interface WebflowSite { - id: string - displayName?: string - shortName?: string -} - -export const POST = withRouteHandler(async (request: NextRequest) => { - try { - const requestId = generateRequestId() - const parsed = await parseRequest(webflowSitesSelectorContract, request, {}) - if (!parsed.success) return parsed.response - const { credential, workflowId, siteId } = parsed.data.body - - if (siteId) { - const siteIdValidation = validateAlphanumericId(siteId, 'siteId') - if (!siteIdValidation.isValid) { - logger.error('Invalid siteId', { error: siteIdValidation.error }) - return NextResponse.json({ error: siteIdValidation.error }, { status: 400 }) - } - } - - const authz = await authorizeCredentialUse(request, { - credentialId: credential, - workflowId, - }) - if (!authz.ok || !authz.credentialOwnerUserId) { - return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) - } - - const accessToken = await refreshAccessTokenIfNeeded( - credential, - authz.credentialOwnerUserId, - requestId - ) - if (!accessToken) { - logger.error('Failed to get access token', { - credentialId: credential, - userId: authz.credentialOwnerUserId, - }) - return NextResponse.json( - { - error: 'Could not retrieve access token', - authRequired: true, - }, - { status: 401 } - ) - } - - const url = siteId - ? `https://api.webflow.com/v2/sites/${siteId}` - : 'https://api.webflow.com/v2/sites' - - const response = await fetch(url, { - headers: { - Authorization: `Bearer ${accessToken}`, - accept: 'application/json', - }, - }) - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})) - logger.error('Failed to fetch Webflow sites', { - status: response.status, - error: errorData, - siteId: siteId || 'all', - }) - return NextResponse.json( - { error: 'Failed to fetch Webflow sites', details: errorData }, - { status: response.status } - ) - } - - const data = (await response.json()) as WebflowSite | { sites?: WebflowSite[] } - - let sites: WebflowSite[] - if (siteId) { - sites = [data as WebflowSite] - } else { - sites = 'sites' in data ? data.sites || [] : [] - } - - const formattedSites = sites.map((site) => ({ - id: site.id, - name: site.displayName || site.shortName || site.id, - })) - - return NextResponse.json({ sites: formattedSites }) - } catch (error) { - logger.error('Error processing Webflow sites request:', error) - return NextResponse.json( - { error: 'Failed to retrieve Webflow sites', details: (error as Error).message }, - { status: 500 } - ) - } -}) diff --git a/apps/sim/app/api/tools/zoho_desk/agents/route.ts b/apps/sim/app/api/tools/zoho_desk/agents/route.ts deleted file mode 100644 index 40aef00acad..00000000000 --- a/apps/sim/app/api/tools/zoho_desk/agents/route.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import { type NextRequest, NextResponse } from 'next/server' -import { zohoDeskAgentsSelectorContract } from '@/lib/api/contracts/selectors' -import { parseRequest } from '@/lib/api/server' -import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { resolveZohoDeskSelectorCredential } from '@/app/api/tools/zoho_desk/selector-credential' -import { assertZohoUrl } from '@/tools/zoho_desk/host-allowlist' -import { buildZohoDeskHeaders, getZohoDeskErrorMessage } from '@/tools/zoho_desk/utils' - -export const dynamic = 'force-dynamic' - -const logger = createLogger('ZohoDeskAgentsAPI') - -/** - * `GET /api/v1/agents` is index-paginated exactly like `/departments`: `from` is - * an index offset and `limit` caps at 200 (default 10). A short page means the - * list is exhausted. The page cap bounds the drain so a provider that keeps - * returning full pages cannot loop forever. - */ -const AGENT_PAGE_SIZE = 200 -const MAX_AGENT_PAGES = 20 - -/** - * Only active agents can own a ticket, so a disabled or deleted agent in the - * picker would only produce an assignment the API rejects. - */ -const AGENT_STATUS = 'ACTIVE' - -interface ZohoAgent { - id?: string | number - name?: string - firstName?: string - lastName?: string - emailId?: string -} - -/** - * Zoho returns `name` for most agents but leaves it (and `firstName`) empty on - * some rows, so fall back through the name parts and finally the email before - * showing a bare numeric id the user cannot recognize. - */ -function getAgentLabel(agent: ZohoAgent): string { - if (agent.name?.trim()) return agent.name.trim() - const fullName = [agent.firstName, agent.lastName] - .map((part) => part?.trim()) - .filter(Boolean) - .join(' ') - if (fullName) return fullName - return agent.emailId?.trim() || String(agent.id) -} - -/** Backs the `zoho_desk.agents` selector. */ -export const POST = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - - const parsed = await parseRequest(zohoDeskAgentsSelectorContract, request, {}) - if (!parsed.success) return parsed.response - const { credential, workflowId, orgId } = parsed.data.body - - const resolved = await resolveZohoDeskSelectorCredential(request, { - credentialId: credential, - workflowId, - requestId, - }) - if (!resolved.ok) return resolved.response - const { accessToken, apiBase } = resolved.credential - - const headers = buildZohoDeskHeaders({ accessToken, orgId }) - const agents: Array<{ id: string; name: string }> = [] - // Zoho's docs disagree on whether `from` is 0- or 1-based (pagination section - // says "range 0-4999, default 0"; listing examples read as 1-based). Deduping - // by id is correct under both, so the drain never yields a repeated agent. - const seenIds = new Set() - - try { - for (let page = 0; page < MAX_AGENT_PAGES; page++) { - let agentsUrl: URL - try { - agentsUrl = assertZohoUrl(`${apiBase}/agents`) - } catch { - return NextResponse.json( - { error: 'Credential resolved to a non-Zoho host' }, - { status: 400 } - ) - } - agentsUrl.searchParams.set('from', String(page * AGENT_PAGE_SIZE)) - agentsUrl.searchParams.set('limit', String(AGENT_PAGE_SIZE)) - agentsUrl.searchParams.set('status', AGENT_STATUS) - - // Same rationale as the organizations/departments/attachment routes: pin - // the resolved IP, block private/reserved hops, and drop the token if a - // Zoho-side redirect leaves the original origin. - const response = await secureFetchWithValidation(agentsUrl.toString(), { - method: 'GET', - headers, - timeout: 15_000, - stripAuthOnRedirect: true, - }) - - const body: { data?: unknown } = await response - .json() - .then((json) => (json && typeof json === 'object' ? (json as { data?: unknown }) : {})) - .catch(() => ({})) - - // Zoho answers 204 with no body once the offset runs past the last agent, - // which is a successful end-of-list, not an error. - if (response.status === 204) break - - if (!response.ok) { - const message = getZohoDeskErrorMessage( - body, - `Failed to list agents (HTTP ${response.status})` - ) - logger.warn('Failed to list Zoho Desk agents', { status: response.status, message }) - return NextResponse.json( - { error: message }, - { status: response.status >= 400 && response.status < 500 ? response.status : 502 } - ) - } - - const pageItems = Array.isArray(body.data) ? (body.data as ZohoAgent[]) : [] - for (const agent of pageItems) { - if (agent.id === undefined || agent.id === null) continue - const id = String(agent.id) - if (seenIds.has(id)) continue - seenIds.add(id) - agents.push({ id, name: getAgentLabel(agent) }) - } - - if (pageItems.length < AGENT_PAGE_SIZE) break - if (page === MAX_AGENT_PAGES - 1) { - logger.warn('Zoho Desk agents listing hit the page cap; list may be incomplete', { - pages: MAX_AGENT_PAGES, - }) - } - } - - return NextResponse.json({ agents }) - } catch (error) { - const message = getErrorMessage(error, 'Failed to list agents') - logger.error('Error listing Zoho Desk agents', { error: message }) - return NextResponse.json({ error: message }, { status: 502 }) - } -}) diff --git a/apps/sim/app/api/tools/zoho_desk/departments/route.ts b/apps/sim/app/api/tools/zoho_desk/departments/route.ts deleted file mode 100644 index 3edb135d314..00000000000 --- a/apps/sim/app/api/tools/zoho_desk/departments/route.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import { type NextRequest, NextResponse } from 'next/server' -import { zohoDeskDepartmentsSelectorContract } from '@/lib/api/contracts/selectors' -import { parseRequest } from '@/lib/api/server' -import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { resolveZohoDeskSelectorCredential } from '@/app/api/tools/zoho_desk/selector-credential' -import { assertZohoUrl } from '@/tools/zoho_desk/host-allowlist' -import { buildZohoDeskHeaders, getZohoDeskErrorMessage } from '@/tools/zoho_desk/utils' - -export const dynamic = 'force-dynamic' - -const logger = createLogger('ZohoDeskDepartmentsAPI') - -/** - * `GET /api/v1/departments` is index-paginated with `limit` capped at 200 - * (default 10). A short page means the list is exhausted. - * - * Zoho's docs contradict themselves on whether `from` is 0- or 1-based: the - * pagination section documents "range 0-4999, default 0", while the listing - * examples read "from=5 and limit=50 retrieves records 5 to 54" (1-based). Under - * the 1-based reading, stepping by exactly PAGE_SIZE re-fetches the boundary - * record. Rather than guess a base we cannot confirm without a live tenant, the - * accumulator dedupes by id, which is correct under BOTH readings — the worst - * case is one redundant record per page boundary, never a duplicate entry or a - * skipped one. - * - * The page cap bounds the drain so a provider that keeps returning full pages - * cannot loop forever — 20 x 200 covers any realistic Desk portal and keeps the - * maximum `from` inside Zoho's documented 4999 ceiling. - */ -const DEPARTMENT_PAGE_SIZE = 200 -const MAX_DEPARTMENT_PAGES = 20 - -interface ZohoDepartment { - id?: string | number - name?: string - nameInCustomerPortal?: string -} - -/** Backs the `zoho_desk.departments` selector. */ -export const POST = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - - const parsed = await parseRequest(zohoDeskDepartmentsSelectorContract, request, {}) - if (!parsed.success) return parsed.response - const { credential, workflowId, orgId } = parsed.data.body - - const resolved = await resolveZohoDeskSelectorCredential(request, { - credentialId: credential, - workflowId, - requestId, - }) - if (!resolved.ok) return resolved.response - const { accessToken, apiBase } = resolved.credential - - const headers = buildZohoDeskHeaders({ accessToken, orgId }) - const departments: Array<{ id: string; name: string }> = [] - const seenIds = new Set() - - try { - for (let page = 0; page < MAX_DEPARTMENT_PAGES; page++) { - let departmentsUrl: URL - try { - departmentsUrl = assertZohoUrl(`${apiBase}/departments`) - } catch { - return NextResponse.json( - { error: 'Credential resolved to a non-Zoho host' }, - { status: 400 } - ) - } - departmentsUrl.searchParams.set('from', String(page * DEPARTMENT_PAGE_SIZE)) - departmentsUrl.searchParams.set('limit', String(DEPARTMENT_PAGE_SIZE)) - - // Same rationale as the organizations/attachment routes: pin the resolved - // IP, block private/reserved hops, and drop the token if a Zoho-side - // redirect leaves the original origin. - const response = await secureFetchWithValidation(departmentsUrl.toString(), { - method: 'GET', - headers, - timeout: 15_000, - stripAuthOnRedirect: true, - }) - - const body: { data?: unknown } = await response - .json() - .then((json) => (json && typeof json === 'object' ? (json as { data?: unknown }) : {})) - .catch(() => ({})) - - // Zoho answers 204 with no body once the offset runs past the last - // department, which is a successful end-of-list, not an error. - if (response.status === 204) break - - if (!response.ok) { - const message = getZohoDeskErrorMessage( - body, - `Failed to list departments (HTTP ${response.status})` - ) - logger.warn('Failed to list Zoho Desk departments', { status: response.status, message }) - return NextResponse.json( - { error: message }, - { status: response.status >= 400 && response.status < 500 ? response.status : 502 } - ) - } - - const pageItems = Array.isArray(body.data) ? (body.data as ZohoDepartment[]) : [] - for (const department of pageItems) { - if (department.id === undefined || department.id === null) continue - const id = String(department.id) - // Dedupe: see the pagination note above — a 1-based `from` would repeat - // the boundary record on every page after the first. - if (seenIds.has(id)) continue - seenIds.add(id) - departments.push({ - id, - name: department.name || department.nameInCustomerPortal || String(department.id), - }) - } - - if (pageItems.length < DEPARTMENT_PAGE_SIZE) break - if (page === MAX_DEPARTMENT_PAGES - 1) { - logger.warn('Zoho Desk departments listing hit the page cap; list may be incomplete', { - pages: MAX_DEPARTMENT_PAGES, - }) - } - } - - return NextResponse.json({ departments }) - } catch (error) { - const message = getErrorMessage(error, 'Failed to list departments') - logger.error('Error listing Zoho Desk departments', { error: message }) - return NextResponse.json({ error: message }, { status: 502 }) - } -}) diff --git a/apps/sim/app/api/tools/zoho_desk/organizations/route.ts b/apps/sim/app/api/tools/zoho_desk/organizations/route.ts deleted file mode 100644 index 907d7030d94..00000000000 --- a/apps/sim/app/api/tools/zoho_desk/organizations/route.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import { type NextRequest, NextResponse } from 'next/server' -import { zohoDeskOrganizationsSelectorContract } from '@/lib/api/contracts/selectors' -import { parseRequest } from '@/lib/api/server' -import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { resolveZohoDeskSelectorCredential } from '@/app/api/tools/zoho_desk/selector-credential' -import { assertZohoUrl } from '@/tools/zoho_desk/host-allowlist' -import { getZohoDeskErrorMessage } from '@/tools/zoho_desk/utils' - -export const dynamic = 'force-dynamic' - -const logger = createLogger('ZohoDeskOrganizationsAPI') - -interface ZohoOrganization { - id?: string | number - companyName?: string - portalName?: string -} - -/** Backs the `zoho_desk.organizations` selector. */ -export const POST = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - - const parsed = await parseRequest(zohoDeskOrganizationsSelectorContract, request, {}) - if (!parsed.success) return parsed.response - const { credential, workflowId } = parsed.data.body - - const resolved = await resolveZohoDeskSelectorCredential(request, { - credentialId: credential, - workflowId, - requestId, - }) - if (!resolved.ok) return resolved.response - const { accessToken, apiBase } = resolved.credential - - // apiBase is already anchored by getZohoDeskApiBase; assert again so the URL - // that finally receives the OAuth token is validated at the point of use. - let organizationsUrl: URL - try { - organizationsUrl = assertZohoUrl(`${apiBase}/organizations`) - // Deliberately sends NO query parameters. Zoho's `/organizations` doc block - // lists none at all - not `from`, not `limit` - and its own sample is a bare - // GET. An earlier revision passed `limit=200` by extrapolating from - // /departments and /agents, but the other siblings (/tickets, /contacts, - // /comments) cap at 100 and Zoho answers an out-of-range value with 422 - // INVALID_DATA. Since `orgId` gates every tool and both other selectors, a - // 422 here would make the whole integration unreachable except through the - // manual field - a far worse failure than the known downside of sending - // nothing, which is Zoho's default page size (10 portals). - } catch { - return NextResponse.json({ error: 'Credential resolved to a non-Zoho host' }, { status: 400 }) - } - - try { - // The organizations endpoint is the one Desk call that does not require an - // orgId header, so it can bootstrap the organization selector before a - // portal has been chosen. - // Mirrors the attachment route: the initial host is allowlisted, but a - // Zoho-side redirect would otherwise be followed with the OAuth token - // attached and no IP pinning. secureFetchWithValidation pins the resolved - // IP, blocks private/reserved targets on every hop, and drops the token if - // a redirect leaves the original origin. - const response = await secureFetchWithValidation(organizationsUrl.toString(), { - method: 'GET', - headers: { - Authorization: `Zoho-oauthtoken ${accessToken}`, - 'Content-Type': 'application/json', - }, - timeout: 15_000, - stripAuthOnRedirect: true, - }) - - // secureFetchWithValidation types the body as `unknown`; Zoho wraps the list - // in `{ data: [...] }`, which is narrowed below before use. - const data: { data?: unknown } = await response - .json() - .then((body) => (body && typeof body === 'object' ? (body as { data?: unknown }) : {})) - .catch(() => ({})) - if (!response.ok) { - // Surface the failure instead of returning an empty 200, which would make - // the org dropdown silently render empty on an auth/connectivity error. - const message = getZohoDeskErrorMessage( - data, - `Failed to list organizations (HTTP ${response.status})` - ) - logger.warn('Failed to list Zoho Desk organizations', { status: response.status, message }) - return NextResponse.json( - { error: message }, - { status: response.status >= 400 && response.status < 500 ? response.status : 502 } - ) - } - - const organizations = (Array.isArray(data.data) ? (data.data as ZohoOrganization[]) : []) - .filter((org) => org.id !== undefined && org.id !== null) - .map((org) => ({ - id: String(org.id), - name: org.companyName || org.portalName || String(org.id), - })) - - return NextResponse.json({ organizations }) - } catch (error) { - const message = getErrorMessage(error, 'Failed to list organizations') - logger.error('Error listing Zoho Desk organizations', { error: message }) - return NextResponse.json({ error: message }, { status: 502 }) - } -}) diff --git a/apps/sim/app/api/tools/zoho_desk/selector-credential.ts b/apps/sim/app/api/tools/zoho_desk/selector-credential.ts deleted file mode 100644 index 9d59d67ffdd..00000000000 --- a/apps/sim/app/api/tools/zoho_desk/selector-credential.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { db } from '@sim/db' -import { account } from '@sim/db/schema' -import { createLogger } from '@sim/logger' -import { eq } from 'drizzle-orm' -import { type NextRequest, NextResponse } from 'next/server' -import { authorizeCredentialUse } from '@/lib/auth/credential-access' -import { resolveCredentialAccessToken, resolveOAuthAccountId } from '@/lib/oauth/credential-service' -import { extractZohoDeskBaseFromScope } from '@/tools/zoho_desk/host-allowlist' -import { getZohoDeskApiBase } from '@/tools/zoho_desk/utils' - -const logger = createLogger('ZohoDeskSelectorCredential') - -interface ResolvedZohoDeskCredential { - accessToken: string - /** Desk REST base including the `/api/v1` suffix, anchored to the Zoho apex allowlist. */ - apiBase: string -} - -type ResolveResult = - | { ok: true; credential: ResolvedZohoDeskCredential } - | { ok: false; response: NextResponse } - -/** - * Resolve a Zoho Desk credential id into an access token plus the data-center - * Desk REST base, for the selector routes. - * - * Both credential kinds are covered by one path: - * - `zoho-desk-service-account` (client credentials): the minter returns the - * data center's Desk base as `apiDomain` on every mint, so it comes straight - * off the token result. - * - OAuth connection: the token exchange persists the derived Desk base in the - * credential's scope string, so it is read back from the `account` row. - * - * The token never leaves the server — unlike the previous combobox, which - * fetched it into the browser before posting it back. - */ -export async function resolveZohoDeskSelectorCredential( - request: NextRequest, - params: { credentialId: string; workflowId?: string; requestId: string } -): Promise { - const { credentialId, workflowId, requestId } = params - - const authz = await authorizeCredentialUse(request, { credentialId, workflowId }) - if (!authz.ok || !authz.credentialOwnerUserId) { - return { - ok: false, - response: NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }), - } - } - - const tokenResult = await resolveCredentialAccessToken( - credentialId, - authz.credentialOwnerUserId, - requestId - ) - if (!tokenResult?.accessToken) { - logger.error('Failed to get Zoho Desk access token', { credentialId }) - return { - ok: false, - response: NextResponse.json( - { error: 'Could not retrieve access token', authRequired: true }, - { status: 401 } - ), - } - } - - // Service-account mints carry `apiDomain`; an OAuth connection stores the same - // value on its account row instead. Falling through to `undefined` lets - // getZohoDeskApiBase apply the US default rather than guessing a host. - const apiDomain = tokenResult.apiDomain ?? (await readOAuthApiDomain(credentialId)) - - return { - ok: true, - credential: { - accessToken: tokenResult.accessToken, - apiBase: getZohoDeskApiBase({ apiDomain }), - }, - } -} - -async function readOAuthApiDomain(credentialId: string): Promise { - try { - const resolved = await resolveOAuthAccountId(credentialId) - if (!resolved?.accountId) return undefined - const [row] = await db - .select({ scope: account.scope }) - .from(account) - .where(eq(account.id, resolved.accountId)) - .limit(1) - return extractZohoDeskBaseFromScope(row?.scope) - } catch (error) { - logger.warn('Failed to resolve Zoho Desk data center from credential', { error }) - return undefined - } -} diff --git a/apps/sim/app/api/tools/zoom/meetings/route.ts b/apps/sim/app/api/tools/zoom/meetings/route.ts deleted file mode 100644 index 48ba158659e..00000000000 --- a/apps/sim/app/api/tools/zoom/meetings/route.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' -import { zoomMeetingsSelectorContract } from '@/lib/api/contracts/selectors' -import { parseRequest } from '@/lib/api/server' -import { authorizeCredentialUse } from '@/lib/auth/credential-access' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { refreshAccessTokenIfNeeded } from '@/lib/oauth/credential-service' - -const logger = createLogger('ZoomMeetingsAPI') - -export const dynamic = 'force-dynamic' - -/** - * Zoom `GET /v2/users/me/meetings` returns `next_page_token`, which is passed - * back as `?next_page_token=` until it comes back as an empty string. `page_size` - * max is 300. Bounded by `MAX_ZOOM_PAGES` so a runaway response can't loop forever. - */ -const ZOOM_PAGE_SIZE = 300 -const MAX_ZOOM_PAGES = 50 - -interface ZoomMeeting { - id: number - topic: string -} - -interface ZoomMeetingsPage { - meetings?: ZoomMeeting[] - next_page_token?: string -} - -export const POST = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - try { - const parsed = await parseRequest(zoomMeetingsSelectorContract, request, {}) - if (!parsed.success) return parsed.response - const { credential, workflowId } = parsed.data.body - - const authz = await authorizeCredentialUse(request, { - credentialId: credential, - workflowId, - }) - if (!authz.ok || !authz.credentialOwnerUserId) { - return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 }) - } - - const accessToken = await refreshAccessTokenIfNeeded( - credential, - authz.credentialOwnerUserId, - requestId - ) - if (!accessToken) { - logger.error('Failed to get access token', { - credentialId: credential, - userId: authz.credentialOwnerUserId, - }) - return NextResponse.json( - { error: 'Could not retrieve access token', authRequired: true }, - { status: 401 } - ) - } - - const allMeetings: ZoomMeeting[] = [] - let nextPageToken = '' - - for (let page = 0; page < MAX_ZOOM_PAGES; page++) { - const url = new URL('https://api.zoom.us/v2/users/me/meetings') - url.searchParams.set('page_size', String(ZOOM_PAGE_SIZE)) - url.searchParams.set('type', 'scheduled') - if (nextPageToken) { - url.searchParams.set('next_page_token', nextPageToken) - } - - const response = await fetch(url.toString(), { - headers: { - Authorization: `Bearer ${accessToken}`, - 'Content-Type': 'application/json', - }, - }) - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})) - logger.error('Failed to fetch Zoom meetings', { - status: response.status, - error: errorData, - }) - return NextResponse.json( - { error: 'Failed to fetch Zoom meetings', details: errorData }, - { status: response.status } - ) - } - - const data = (await response.json()) as ZoomMeetingsPage - if (Array.isArray(data.meetings)) { - allMeetings.push(...data.meetings) - } - - nextPageToken = data.next_page_token?.trim() || '' - if (!nextPageToken) { - break - } - - if (page === MAX_ZOOM_PAGES - 1) { - logger.warn( - 'Zoom meetings pagination hit MAX_ZOOM_PAGES cap; meeting list may be incomplete', - { - maxPages: MAX_ZOOM_PAGES, - } - ) - } - } - - const meetings = allMeetings.map((meeting) => ({ - id: String(meeting.id), - name: meeting.topic, - })) - - return NextResponse.json({ meetings }) - } catch (error) { - logger.error('Error processing Zoom meetings request:', error) - return NextResponse.json( - { error: 'Failed to retrieve Zoom meetings', details: (error as Error).message }, - { status: 500 } - ) - } -}) diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-config-fields/connector-config-fields.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-config-fields/connector-config-fields.tsx index 901c72ea064..07ff925b5a1 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-config-fields/connector-config-fields.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-config-fields/connector-config-fields.tsx @@ -2,13 +2,13 @@ import { Button, ChipCombobox, ChipInput, ChipModalField, Tooltip } from '@sim/emcn' import { ArrowLeftRight, CircleInfo } from '@sim/emcn/icons' +import type { SelectorKey } from '@/lib/selectors/manifest' import { ConnectorSelectorField } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/connector-selector-field' import type { ConfigFieldMap, ConfigFieldValue, } from '@/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-config-fields' import type { ConnectorConfigField, ConnectorMeta } from '@/connectors/types' -import type { SelectorKey } from '@/hooks/selectors/types' export interface ConnectorConfigFieldsProps { /** Registry definition whose `configFields` drive the rendered rows. */ diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-selector-field/connector-selector-field.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-selector-field/connector-selector-field.tsx index 4252b6ec357..9a5bb7a7f22 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-selector-field/connector-selector-field.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-selector-field/connector-selector-field.tsx @@ -3,21 +3,22 @@ import { useMemo, useState } from 'react' import { ChipCombobox, type ComboboxOption } from '@sim/emcn' import { Loader } from '@sim/emcn/icons' +import { useParams } from 'next/navigation' +import { projectSelectorContext } from '@/lib/selectors/context' +import { getSelectorManifestEntry, type SelectorKey } from '@/lib/selectors/manifest' +import type { SelectorContext } from '@/lib/selectors/types' import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state' -import { SELECTOR_CONTEXT_FIELDS } from '@/lib/workflows/subblocks/context' import { getDependsOnFields } from '@/lib/workflows/subblocks/dependencies' import type { ConfigFieldMap, ConfigFieldValue, } from '@/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-config-fields' import type { ConnectorConfigField } from '@/connectors/types' -import { getSelectorDefinition } from '@/hooks/selectors/registry' -import type { SelectorContext, SelectorKey } from '@/hooks/selectors/types' import { useSelectorOptionDetail, useSelectorOptionDetails, useSelectorOptions, -} from '@/hooks/selectors/use-selector-query' +} from '@/hooks/queries/selectors' import { useDebounce } from '@/hooks/use-debounce' interface ConnectorSelectorFieldProps { @@ -41,33 +42,33 @@ export function ConnectorSelectorField({ canonicalModes, disabled, }: ConnectorSelectorFieldProps) { + const { workspaceId } = useParams<{ workspaceId: string }>() const isMulti = Boolean(field.multi) const [searchTerm, setSearchTerm] = useState('') const context = useMemo(() => { - const ctx: SelectorContext = {} - if (credentialId) ctx.oauthCredential = credentialId - if (field.mimeType) ctx.mimeType = field.mimeType + const candidate: Record = {} + if (credentialId) candidate.oauthCredential = credentialId + if (field.mimeType) candidate.mimeType = field.mimeType const fieldsById = new Map(configFields.map((f) => [f.id, f])) for (const depFieldId of getDependsOnFields(field.dependsOn)) { const depField = fieldsById.get(depFieldId) const canonicalId = depField?.canonicalParamId ?? depFieldId const depValue = resolveDepValue(depFieldId, configFields, canonicalModes, sourceConfig) - if (depValue && SELECTOR_CONTEXT_FIELDS.has(canonicalId as keyof SelectorContext)) { - ctx[canonicalId as keyof SelectorContext] = depValue - } + if (depValue) candidate[canonicalId] = depValue } - return ctx + return projectSelectorContext(field.selectorKey, candidate) }, [credentialId, field.mimeType, field.dependsOn, sourceConfig, configFields, canonicalModes]) const depsResolved = useMemo(() => { if (!field.dependsOn) return true - const deps = Array.isArray(field.dependsOn) ? field.dependsOn : (field.dependsOn.all ?? []) - return deps.every((depId) => + const all = Array.isArray(field.dependsOn) ? field.dependsOn : (field.dependsOn.all ?? []) + const any = Array.isArray(field.dependsOn) ? [] : (field.dependsOn.any ?? []) + const hasValue = (depId: string) => Boolean(resolveDepValue(depId, configFields, canonicalModes, sourceConfig)?.trim()) - ) + return all.every(hasValue) && (any.length === 0 || any.some(hasValue)) }, [field.dependsOn, sourceConfig, configFields, canonicalModes]) const isEnabled = !disabled && !!credentialId && depsResolved @@ -80,14 +81,15 @@ export function ConnectorSelectorField({ error, } = useSelectorOptions(field.selectorKey, { context, + scope: { kind: 'workspace', workspaceId }, enabled: isEnabled, + surfaceId: `connector:${field.id}`, }) /** * Label every selected value, including values restored from saved config that no - * in-session search would have resolved. Queries are keyed on `context`, so a label - * can never outlive the context that produced it, and they share keys with the - * speculative lookup below so an already-resolved id costs no extra request. + * in-session search would have resolved. Opaque revisions bind each label request to + * the active context without placing credential or dependency values in its query key. */ const singleValue = Array.isArray(value) ? value[0] : value const selectedIds = useMemo( @@ -96,7 +98,9 @@ export function ConnectorSelectorField({ ) const selectedOptions = useSelectorOptionDetails(field.selectorKey, { context, + scope: { kind: 'workspace', workspaceId }, detailIds: isEnabled ? selectedIds : [], + surfaceId: `connector:${field.id}`, }) /** @@ -107,12 +111,14 @@ export function ConnectorSelectorField({ * implementations resolve a record by id, where a partial keystroke is a guaranteed * failed upstream request rather than an empty result. */ - const resolvesUnknownIds = Boolean(getSelectorDefinition(field.selectorKey).resolvesUnknownIds) + const resolvesUnknownIds = getSelectorManifestEntry(field.selectorKey).resolvesUnknownIds const debouncedSearch = useDebounce(searchTerm.trim(), SEARCH_DEBOUNCE_MS) const { data: searchedOption } = useSelectorOptionDetail(field.selectorKey, { context, + scope: { kind: 'workspace', workspaceId }, detailId: resolvesUnknownIds && isEnabled && debouncedSearch.length > 0 ? debouncedSearch : undefined, + surfaceId: `connector:${field.id}`, }) const emptyMessage = getEmptyMessage(field.title.toLowerCase(), { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx index 06f14637f4e..091c42eaeb8 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx @@ -2,6 +2,7 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Combobox, type ComboboxOption, cn } from '@sim/emcn' import { Plus } from '@sim/emcn/icons' import { useReactFlow } from 'reactflow' +import type { SelectorKey } from '@/lib/selectors/manifest' import { getDependsOnFields } from '@/lib/workflows/subblocks/dependencies' import { SandboxCreateModal } from '@/app/workspace/[workspaceId]/settings/components/sandboxes/components/sandbox-create-modal' import type { SandboxLanguage } from '@/app/workspace/[workspaceId]/settings/components/sandboxes/utils' @@ -14,7 +15,6 @@ import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/c import { useActiveSearchTarget } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/providers/active-search-target-provider' import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes' import type { SubBlockConfig } from '@/blocks/types' -import type { SelectorKey } from '@/hooks/selectors/types' import { usePermissionConfig } from '@/hooks/use-permission-config' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useSubBlockStore } from '@/stores/workflows/subblock/store' @@ -148,6 +148,7 @@ export const ComboBox = memo(function ComboBox({ refetch: refetchOptions, } = useFetchedOptions({ blockId, + subBlockId, dependsOnFields, selectorKey, selectorExcludeSelf, diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx index 016ece3cf95..8faa147531b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx @@ -6,6 +6,7 @@ import { NO_DENIED_OPERATIONS, OPERATION_SUBBLOCK_ID, } from '@/lib/permission-groups/operation-access' +import type { SelectorKey } from '@/lib/selectors/manifest' import { getDependsOnFields } from '@/lib/workflows/subblocks/dependencies' import { staleSelectionOptions } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/stale-selections' import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text' @@ -16,7 +17,6 @@ import { useActiveSearchTarget } from '@/app/workspace/[workspaceId]/w/[workflow import { getBlock } from '@/blocks/registry' import type { SubBlockConfig } from '@/blocks/types' import { ResponseBlockHandler } from '@/executor/handlers/response/response-handler' -import type { SelectorKey } from '@/hooks/selectors/types' import { useOperationAccess } from '@/hooks/use-operation-access' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useSubBlockStore } from '@/stores/workflows/subblock/store' @@ -166,6 +166,7 @@ export const Dropdown = memo(function Dropdown({ refetch: refetchOptions, } = useFetchedOptions({ blockId, + subBlockId, dependsOnFields, selectorKey, selectorExcludeSelf, diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox.tsx index faf46bb6b1a..6cc551ea0b0 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox.tsx @@ -2,6 +2,7 @@ import type React from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Button, Combobox as EditableCombobox } from '@sim/emcn' import { X } from '@sim/emcn/icons' +import type { SelectorKey } from '@/lib/selectors/manifest' import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state' import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text' import { SubBlockInputController } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/sub-block-input-controller' @@ -9,19 +10,19 @@ import { getWorkflowSearchLabelHighlight } from '@/app/workspace/[workspaceId]/w import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value' import { useActiveSearchTarget } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/providers/active-search-target-provider' import type { SubBlockConfig } from '@/blocks/types' -import type { SelectorContext, SelectorKey } from '@/hooks/selectors/types' import { + type SelectorClientContext, useSelectorOptionDetail, useSelectorOptionMap, useSelectorOptions, -} from '@/hooks/selectors/use-selector-query' +} from '@/hooks/queries/selectors' import { useDebounce } from '@/hooks/use-debounce' interface SelectorComboboxProps { blockId: string subBlock: SubBlockConfig selectorKey: SelectorKey - selectorContext: SelectorContext + selectorContext: SelectorClientContext disabled?: boolean isPreview?: boolean previewValue?: string | null @@ -77,6 +78,7 @@ export function SelectorCombobox({ const trimmedSearch = searchTerm.trim() const debouncedSearch = useDebounce(trimmedSearch, SEARCH_DEBOUNCE_MS) const activeSearch = trimmedSearch === '' ? '' : debouncedSearch + const surfaceId = `${blockId}:${subBlock.id}` const { data: options = [], isLoading, @@ -85,10 +87,14 @@ export function SelectorCombobox({ } = useSelectorOptions(selectorKey, { context: selectorContext, search: allowSearch ? activeSearch : undefined, + enabled: !disabled, + surfaceId, }) const { data: detailOption } = useSelectorOptionDetail(selectorKey, { context: selectorContext, detailId: activeValue, + enabled: !disabled, + surfaceId, }) const optionMap = useSelectorOptionMap(options, detailOption ?? undefined) const hasMissingOption = diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-input/selector-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-input/selector-input.tsx index 332ca77b014..6f1ea112c87 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-input/selector-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-input/selector-input.tsx @@ -6,11 +6,14 @@ import { SelectorCombobox } from '@/app/workspace/[workspaceId]/w/[workflowId]/c import { useSelectorSetup } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-selector-setup' import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value' import type { SubBlockConfig } from '@/blocks/types' -import type { SelectorContext } from '@/hooks/selectors/types' +import type { SelectorClientContext } from '@/hooks/queries/selectors' import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow' export interface SelectorOverrides { - transformContext?: (context: SelectorContext, deps: Record) => SelectorContext + transformContext?: ( + context: SelectorClientContext, + deps: Record + ) => SelectorClientContext getDefaultValue?: (subBlock: SubBlockConfig) => string | null } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/workflow-selector/workflow-selector-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/workflow-selector/workflow-selector-input.tsx index c92fe8dc311..5a07598efcd 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/workflow-selector/workflow-selector-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/workflow-selector/workflow-selector-input.tsx @@ -5,7 +5,7 @@ import { useParams } from 'next/navigation' import { DELETED_WORKFLOW_LABEL } from '@/lib/workflows/workflow-labels' import { SelectorCombobox } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox' import type { SubBlockConfig } from '@/blocks/types' -import type { SelectorContext } from '@/hooks/selectors/types' +import type { SelectorClientContext } from '@/hooks/queries/selectors' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' interface WorkflowSelectorInputProps { @@ -26,7 +26,7 @@ export function WorkflowSelectorInput({ const { workspaceId } = useParams<{ workspaceId: string }>() const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId) - const context: SelectorContext = useMemo( + const context: SelectorClientContext = useMemo( () => ({ workspaceId, excludeWorkflowId: activeWorkflowId ?? undefined, diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate.ts index 142b45339ac..1f034a62272 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate.ts @@ -142,5 +142,8 @@ export function useDependsOnGate( finalDisabled, dependencyValues: dependencyValuesMap, canonicalIndex, + contextConfigs: blockConfig?.subBlocks ?? [], + canonicalModeOverrides, + triggerSurface, } } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-fetched-options.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-fetched-options.ts index cfe952819bc..1050c8920ce 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-fetched-options.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-fetched-options.ts @@ -1,15 +1,15 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +'use client' + +import { useMemo } from 'react' import { getErrorMessage } from '@sim/utils/errors' -import { isEqual } from 'es-toolkit' -import { useStoreWithEqualityFn } from 'zustand/traditional' -import { - buildSelectorContextFromBlock, - getSelectorContextSubBlocks, -} from '@/lib/workflows/subblocks/context' -import { buildCanonicalIndex, resolveDependencyValue } from '@/lib/workflows/subblocks/visibility' +import type { SelectorKey } from '@/lib/selectors/manifest' +import { buildSelectorContextFromBlock } from '@/lib/workflows/subblocks/context' import { getBlock } from '@/blocks/registry' -import { getSelectorDefinition, loadAllSelectorOptions } from '@/hooks/selectors/registry' -import type { SelectorKey } from '@/hooks/selectors/types' +import { + type SelectorClientContext, + useSelectorOptionDetail, + useSelectorOptions, +} from '@/hooks/queries/selectors' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useSubBlockStore } from '@/stores/workflows/subblock/store' import { useWorkflowStore } from '@/stores/workflows/workflow/store' @@ -19,57 +19,28 @@ export interface FetchedOption { id: string } -/** An option the control already knows about, static or previously fetched. */ type LocalOption = string | { id: string } interface UseFetchedOptionsProps { blockId: string - /** Sibling subblock ids this list is scoped by; a change refetches. */ + subBlockId: string dependsOnFields: string[] - /** - * The registered selector supplying this control's options. - * - * This is the ONLY way a sub-block loads a remote list. A selector is parameterized by an - * explicit {@link SelectorContext} built from the block's own values, so the same definition - * serves the canvas, the fork sync modal, and any future surface. The alternative that used - * to live here — a per-block `fetchOptions(blockId)` reading the live store — could only ever - * work on the canvas, and was in every case a duplicate of a selector that already existed. - */ selectorKey?: SelectorKey - /** Drop the hosting workflow from the list — see `SubBlockConfig.selectorExcludeSelf`. */ selectorExcludeSelf?: boolean isPreview: boolean disabled: boolean - /** - * The stored value whose label needs resolving before the full list loads. - * Multi-select controls pass `null` — there is no single label to hydrate. - */ valueToHydrate: string | null | undefined - /** Options already resolvable without a fetch, so hydration can skip one. */ localOptions: readonly LocalOption[] } export interface UseFetchedOptionsResult { fetchedOptions: FetchedOption[] - /** - * Whether this control loads its options remotely at all. Controls use it to decide whether - * `fetchedOptions` or the static `options` array is authoritative — a question they used to - * answer by testing the `fetchOptions` prop, which stops being true once the source is a - * `selectorKey` instead. - */ isDynamic: boolean isLoadingOptions: boolean - /** - * Whether `fetchedOptions` is the result of a completed, successful fetch for the current - * dependency values — an EMPTY list from such a fetch is authoritative (the source really has - * nothing), unlike the empty list held before the first fetch or after a failed one. - */ hasLoadedOptions: boolean fetchError: string | null hydratedOption: FetchedOption | null - /** Stored id an authoritative lookup confirmed no longer exists. */ missingOptionId: string | null - /** Fetches now, bypassing the once-per-dependency-set guard. For open handlers. */ refetch: () => void } @@ -78,17 +49,13 @@ function hasLocalOption(options: readonly LocalOption[], id: string): boolean { } /** - * Owns the async-option lifecycle shared by the Dropdown and ComboBox subblock - * controls: fetching the list, clearing and refetching it when the fields it - * depends on change, and hydrating a stored value's label before the list loads. - * - * This exists as one hook because the two controls previously carried the same - * ~115 lines twice and drifted: a fix that added a `hasFetched` guard to both - * added the matching reset to only one, leaving every dependent Dropdown unable - * to refetch after its dependency changed. + * Adapts ordinary Dropdown/ComboBox fields to the shared React Query selector facade. The + * context builder keeps active canonical values and exact environment references intact; no + * provider or environment resolution happens in the browser. */ export function useFetchedOptions({ blockId, + subBlockId, dependsOnFields, selectorKey, selectorExcludeSelf, @@ -97,250 +64,75 @@ export function useFetchedOptions({ valueToHydrate, localOptions, }: UseFetchedOptionsProps): UseFetchedOptionsResult { - const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId) - const workspaceId = useWorkflowRegistry((s) => s.hydration.workspaceId) - const blockState = useWorkflowStore((state) => state.blocks[blockId]) - const blockConfig = blockState?.type ? getBlock(blockState.type) : null - const canonicalModeOverrides = blockState?.data?.canonicalModes - - const dependencyValues = useStoreWithEqualityFn( - useSubBlockStore, - useCallback( - (state) => { - if (dependsOnFields.length === 0 || !activeWorkflowId) return [] - const workflowValues = state.workflowValues[activeWorkflowId] || {} - const blockValues = workflowValues[blockId] || {} - const contextConfigs = getSelectorContextSubBlocks( - blockConfig?.subBlocks || [], - blockValues, - blockState?.triggerMode - ) - const canonicalIndex = buildCanonicalIndex(contextConfigs) - return dependsOnFields.map((depKey) => - resolveDependencyValue(depKey, blockValues, canonicalIndex, canonicalModeOverrides) - ) - }, - [ - dependsOnFields, - activeWorkflowId, - blockId, - blockConfig?.subBlocks, - blockState?.triggerMode, - canonicalModeOverrides, - ] - ), - isEqual + const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId) + const workspaceId = useWorkflowRegistry((state) => state.hydration.workspaceId) + const block = useWorkflowStore((state) => state.blocks[blockId]) + const liveValues = useSubBlockStore((state) => + activeWorkflowId ? state.workflowValues[activeWorkflowId]?.[blockId] : undefined ) + const effectiveKey = selectorKey ?? 'workspace.triggerTypes' - /** - * The block's live sub-block values merged over its persisted ones — the shape - * `buildSelectorContextFromBlock` reads. Resolved at call time rather than memoized so a - * selector always fetches against what the user has actually chosen, not a stale snapshot. - */ - const readSelectorContext = useCallback(() => { - const block = useWorkflowStore.getState().blocks[blockId] - if (!block?.type) return null - const live = activeWorkflowId - ? (useSubBlockStore.getState().workflowValues[activeWorkflowId]?.[blockId] ?? {}) - : {} + const context = useMemo(() => { + if (!selectorKey || !block?.type) return {} const merged: Record = { ...(block.subBlocks ?? {}) } - for (const [id, value] of Object.entries(live)) merged[id] = { ...merged[id], value } - const context = buildSelectorContextFromBlock(block.type, merged, { - workflowId: activeWorkflowId ?? undefined, - workspaceId: workspaceId ?? undefined, + for (const [id, value] of Object.entries(liveValues ?? {})) { + merged[id] = { ...merged[id], value } + } + const selectorConfig = getBlock(block.type)?.subBlocks.find( + (candidate) => candidate.id === subBlockId + ) + const projected = buildSelectorContextFromBlock(block.type, merged, { + selectorKey, + dependsOn: dependsOnFields, canonicalModes: block.data?.canonicalModes, triggerMode: block.triggerMode, + staticContext: { + ...(selectorConfig?.mimeType ? { mimeType: selectorConfig.mimeType } : {}), + ...(selectorExcludeSelf && activeWorkflowId ? { excludeWorkflowId: activeWorkflowId } : {}), + }, }) - if (selectorExcludeSelf && activeWorkflowId) context.excludeWorkflowId = activeWorkflowId - return context - }, [blockId, activeWorkflowId, workspaceId, selectorExcludeSelf]) - - const selectorDefinition = selectorKey ? getSelectorDefinition(selectorKey) : undefined - - /** - * A selector-backed control reuses this hook's whole lifecycle by presenting the registry - * through the same two function shapes the props already describe — so there is one fetch - * path, not a second system running alongside it. - * - * Memoized on `dependencyValues` so a changed parent (a newly picked credential) yields a - * new identity and the scope reset below refetches, exactly as it does for a prop fetcher. - */ - const fetchOptions = useMemo(() => { - if (!selectorDefinition) return undefined - const definition = selectorDefinition - return async (): Promise => { - const context = readSelectorContext() - if (!context) return [] - const args = { key: definition.key, context } - // The selector's own readiness gate: an unset credential yields an empty list rather - // than an error, which is how every other selector-backed control already behaves. - if (definition.enabled && !definition.enabled(args)) return [] - // Shared with search/replace and value resolution, so a paginated selector drains the - // same bounded way here as everywhere else instead of silently showing one page. - const options = await loadAllSelectorOptions(definition, args) - return options.map((option) => ({ id: option.id, label: option.label })) - } - // eslint-disable-next-line react-hooks/exhaustive-deps -- dependencyValues is the refetch scope - }, [selectorDefinition, readSelectorContext, dependencyValues]) - - /** Label hydration for a stored id, from the same definition. */ - const fetchOptionById = useMemo(() => { - const definition = selectorDefinition - const fetchById = definition?.fetchById - if (!definition || !fetchById) return undefined - return async (_blockId: string, optionId: string, signal?: AbortSignal) => { - const context = readSelectorContext() - if (!context) return null - const option = await fetchById({ key: definition.key, context, detailId: optionId, signal }) - return option ? { id: option.id, label: option.label } : null - } - }, [selectorDefinition, readSelectorContext]) - - const [fetchedOptions, setFetchedOptions] = useState([]) - const [hasLoadedOptions, setHasLoadedOptions] = useState(false) - const [isLoadingOptions, setIsLoadingOptions] = useState(false) - const [fetchError, setFetchError] = useState(null) - const [hydratedOption, setHydratedOption] = useState(null) - const [missingOptionId, setMissingOptionId] = useState(null) - const [hydrationRevision, setHydrationRevision] = useState(0) - const hydratedRevisionRef = useRef<{ id: string; revision: number } | null>(null) - const fetchRequestIdRef = useRef(0) - - const previousFetchScopeRef = useRef('') - /** - * Whether a fetch has already been attempted for the current dependency values. - * "Have we fetched?" cannot be inferred from `fetchedOptions.length === 0` — a - * fetcher that legitimately returns no options (a workspace with no sandboxes, - * no credential selected) leaves the length at 0 while the loading flag flips - * back to false, re-satisfying the effect's guards and spinning it forever. - */ - const hasFetchedRef = useRef(false) - - const runFetch = useCallback(async () => { - if (!fetchOptions || isPreview || disabled) return - - const requestId = ++fetchRequestIdRef.current - setIsLoadingOptions(true) - setFetchError(null) - try { - const options = await fetchOptions() - if (requestId !== fetchRequestIdRef.current) return - setFetchedOptions(options) - setHasLoadedOptions(true) - } catch (error) { - if (requestId !== fetchRequestIdRef.current) return - setFetchError(getErrorMessage(error, 'Failed to fetch options')) - setFetchedOptions([]) - setHasLoadedOptions(false) - } finally { - if (requestId === fetchRequestIdRef.current) { - setIsLoadingOptions(false) - } - } - }, [fetchOptions, blockId, isPreview, disabled]) - - useEffect(() => { - if (!fetchOptions) return - - const current = JSON.stringify([workspaceId, dependencyValues]) - const previous = previousFetchScopeRef.current - if (previous && current !== previous) { - fetchRequestIdRef.current += 1 - setFetchedOptions([]) - setHasLoadedOptions(false) - setIsLoadingOptions(false) - setHydratedOption(null) - setMissingOptionId(null) - hydratedRevisionRef.current = null - // Both flags are what gate the fetch effect below, so both have to clear - // with the list: a stale error would block every future refetch, and a - // stale `hasFetched` would stop the new dependency values ever loading. - setFetchError(null) - hasFetchedRef.current = false - } - previousFetchScopeRef.current = current - }, [dependencyValues, fetchOptions, workspaceId]) - - useEffect(() => { - if ( - fetchOptions && - !isPreview && - !disabled && - !hasFetchedRef.current && - !isLoadingOptions && - !fetchError - ) { - hasFetchedRef.current = true - void runFetch() - } - // eslint-disable-next-line react-hooks/exhaustive-deps -- runFetch deps already covered above - }, [fetchOptions, isPreview, disabled, isLoadingOptions, fetchError, dependencyValues]) - - useEffect(() => { - if (!fetchOptionById || isPreview || disabled) return - if (!valueToHydrate) return - - // An expression rather than a real id — there is nothing to look up. - if (valueToHydrate.startsWith('<') || valueToHydrate.includes('{{')) return - - if ( - hydratedOption?.id === valueToHydrate && - hydratedRevisionRef.current?.id === valueToHydrate && - hydratedRevisionRef.current.revision === hydrationRevision - ) { - return - } - if (hasLocalOption(fetchedOptions, valueToHydrate)) return - if (hasLocalOption(localOptions, valueToHydrate)) return - - let isActive = true - fetchOptionById(blockId, valueToHydrate) - .then((option) => { - if (isActive) { - hydratedRevisionRef.current = option - ? { id: valueToHydrate, revision: hydrationRevision } - : null - setHydratedOption(option) - setMissingOptionId(option ? null : valueToHydrate) - } - }) - .catch(() => { - if (isActive) { - setHydratedOption(null) - setMissingOptionId(null) - } - }) - - return () => { - isActive = false + return { + ...projected, + ...(activeWorkflowId ? { workflowId: activeWorkflowId } : {}), + ...(workspaceId ? { workspaceId } : {}), } }, [ - fetchOptionById, - valueToHydrate, - blockId, - isPreview, - disabled, - fetchedOptions, - localOptions, - hydratedOption?.id, - hydrationRevision, + activeWorkflowId, + block, + dependsOnFields, + liveValues, + selectorExcludeSelf, + selectorKey, + subBlockId, workspaceId, ]) - const refetch = useCallback(() => { - hasFetchedRef.current = true - setHydrationRevision((revision) => revision + 1) - void runFetch() - }, [runFetch]) + const enabled = Boolean(selectorKey) && !isPreview && !disabled + const surfaceId = `${blockId}:${subBlockId}` + const list = useSelectorOptions(effectiveKey, { context, enabled, surfaceId }) + const hydrate = Boolean( + valueToHydrate && + !valueToHydrate.startsWith('<') && + !hasLocalOption(localOptions, valueToHydrate) + ) + const detail = useSelectorOptionDetail(effectiveKey, { + context, + detailId: valueToHydrate ?? undefined, + enabled: enabled && hydrate, + surfaceId, + }) return { - fetchedOptions, - isDynamic: Boolean(fetchOptions), - isLoadingOptions, - hasLoadedOptions, - fetchError, - hydratedOption, - missingOptionId, - refetch, + fetchedOptions: list.data ?? [], + isDynamic: Boolean(selectorKey), + isLoadingOptions: list.isLoading, + hasLoadedOptions: list.isSuccess, + fetchError: list.error ? getErrorMessage(list.error, 'Failed to fetch options') : null, + hydratedOption: detail.data ?? null, + missingOptionId: + hydrate && detail.isFetched && !detail.isLoading && detail.data === null + ? (valueToHydrate ?? null) + : null, + refetch: list.refetch, } } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-selector-setup.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-selector-setup.ts index 9e1e101f6f0..eab078ce61e 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-selector-setup.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-selector-setup.ts @@ -2,14 +2,18 @@ import { useMemo } from 'react' import { useParams } from 'next/navigation' -import { SELECTOR_CONTEXT_FIELDS } from '@/lib/workflows/subblocks/context' +import { + buildSelectorContextFromValues, + getSelectorContextSubBlocks, +} from '@/lib/selectors/context' +import type { SelectorKey } from '@/lib/selectors/manifest' import type { SubBlockConfig } from '@/blocks/types' -import { extractEnvVarName, isEnvVarReference, isReference } from '@/executor/constants' -import { usePersonalEnvironment } from '@/hooks/queries/environment' -import type { SelectorContext, SelectorKey } from '@/hooks/selectors/types' +import type { SelectorClientContext } from '@/hooks/queries/selectors' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' +import { useSubBlockStore } from '@/stores/workflows/subblock/store' import { useDependsOnGate } from './use-depends-on-gate' -import { useSubBlockValue } from './use-sub-block-value' + +const EMPTY_SELECTOR_VALUES: Record = {} /** * Resolves all selector configuration from a sub-block's declarative properties. @@ -33,72 +37,61 @@ export function useSelectorSetup( const workflowId = (params?.workflowId as string) || activeWorkflowId || '' const workspaceId = (params?.workspaceId as string) || '' - const { data: envVariables = {} } = usePersonalEnvironment() - - const { finalDisabled, dependencyValues, canonicalIndex } = useDependsOnGate( - blockId, - subBlock, - opts + const { + finalDisabled, + dependencyValues, + canonicalIndex, + contextConfigs, + canonicalModeOverrides, + triggerSurface, + dependsOn, + } = useDependsOnGate(blockId, subBlock, opts) + const liveValues = useSubBlockStore((state) => + activeWorkflowId + ? (state.workflowValues[activeWorkflowId]?.[blockId] ?? EMPTY_SELECTOR_VALUES) + : EMPTY_SELECTOR_VALUES ) + const selectorValues = opts?.previewContextValues ?? liveValues - const [impersonateUserEmail] = useSubBlockValue(blockId, 'impersonateUserEmail') - - const resolvedDependencyValues = useMemo(() => { - const resolved: Record = {} - for (const [key, value] of Object.entries(dependencyValues)) { - if (value === null || value === undefined) { - resolved[key] = value - continue - } - const str = String(value) - if (isEnvVarReference(str)) { - const varName = extractEnvVarName(str) - resolved[key] = envVariables[varName]?.value || undefined - } else { - resolved[key] = value - } - } - return resolved - }, [dependencyValues, envVariables]) - - const selectorContext = useMemo(() => { - const context: SelectorContext = { + const selectorKey = (subBlock.selectorKey ?? null) as SelectorKey | null + const selectorContext = useMemo(() => { + if (!selectorKey) return { workflowId, workspaceId: workspaceId || undefined } + const activeConfigs = getSelectorContextSubBlocks( + contextConfigs, + selectorValues, + triggerSurface + ) + return { + ...buildSelectorContextFromValues({ + selectorKey, + contextConfigs: activeConfigs, + values: selectorValues, + dependsOn, + canonicalIndex, + canonicalModes: canonicalModeOverrides, + staticContext: { mimeType: subBlock.mimeType }, + }), workflowId, workspaceId: workspaceId || undefined, - mimeType: subBlock.mimeType, } - - for (const [depKey, value] of Object.entries(resolvedDependencyValues)) { - if (value === null || value === undefined) continue - const strValue = String(value) - if (!strValue) continue - if (isReference(strValue)) continue - - const canonicalParamId = canonicalIndex.canonicalIdBySubBlockId[depKey] ?? depKey - if (SELECTOR_CONTEXT_FIELDS.has(canonicalParamId as keyof SelectorContext)) { - context[canonicalParamId as keyof SelectorContext] = strValue - } - } - - if (context.oauthCredential && impersonateUserEmail) { - context.impersonateUserEmail = impersonateUserEmail - } - - return context }, [ - resolvedDependencyValues, + selectorKey, + contextConfigs, canonicalIndex, + canonicalModeOverrides, + dependsOn, + selectorValues, workflowId, workspaceId, subBlock.mimeType, - impersonateUserEmail, + triggerSurface, ]) return { - selectorKey: (subBlock.selectorKey ?? null) as SelectorKey | null, + selectorKey, selectorContext, allowSearch: subBlock.selectorAllowSearch ?? true, disabled: finalDisabled || !subBlock.selectorKey, - dependencyValues: resolvedDependencyValues, + dependencyValues, } } diff --git a/apps/sim/blocks/blocks/credential-group.test.ts b/apps/sim/blocks/blocks/credential-group.test.ts deleted file mode 100644 index 81720dc88ee..00000000000 --- a/apps/sim/blocks/blocks/credential-group.test.ts +++ /dev/null @@ -1,155 +0,0 @@ -/** - * @vitest-environment node - */ -import { QueryClient } from '@tanstack/react-query' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { SubBlockConfig } from '@/blocks/types' -import { credentialGroupKeys } from '@/hooks/queries/utils/credential-group-queries' -import { getSelectorDefinition } from '@/hooks/selectors/registry' -import type { SelectorKey } from '@/hooks/selectors/types' - -interface PendingRequest { - resolve: (value: unknown) => void - reject: (reason: unknown) => void -} - -const { requestJsonMock, capturedSignals, pending, getTestQueryClient, setTestQueryClient } = - vi.hoisted(() => { - const capturedSignals: Array = [] - const pending: PendingRequest[] = [] - let client: unknown = null - return { - capturedSignals, - pending, - getTestQueryClient: () => client, - setTestQueryClient: (next: unknown) => { - client = next - }, - requestJsonMock: vi.fn((_contract: unknown, input: { signal?: AbortSignal }) => { - capturedSignals.push(input.signal) - return new Promise((resolve, reject) => { - pending.push({ resolve, reject }) - input.signal?.addEventListener('abort', () => { - reject(new DOMException('The operation was aborted.', 'AbortError')) - }) - }) - }), - } - }) - -vi.mock('@/lib/api/client/request', () => ({ requestJson: requestJsonMock })) - -vi.mock('@/app/_shell/providers/get-query-client', () => ({ - getQueryClient: () => getTestQueryClient(), -})) - -vi.mock('@/stores/workflows/registry/store', () => ({ - useWorkflowRegistry: { - getState: () => ({ hydration: { workspaceId: 'workspace-1' }, activeWorkflowId: null }), - }, -})) - -vi.mock('@/stores/workflows/subblock/store', () => ({ - useSubBlockStore: { getState: () => ({ workflowValues: {} }) }, -})) - -vi.mock('@/stores/workflows/workflow/store', () => ({ - useWorkflowStore: { getState: () => ({ blocks: {} }) }, -})) - -import { CredentialGroupBlock } from '@/blocks/blocks/credential-group' - -const WORKSPACE_ID = 'workspace-1' -const WORKSPACE_LIST_KEY = credentialGroupKeys.list(WORKSPACE_ID) - -const GROUPS = [ - { - id: 'group-1', - name: 'Support accounts', - status: 'active', - options: [], - }, -] - -function getCredentialGroupSubBlock(): SubBlockConfig { - const subBlock = CredentialGroupBlock.subBlocks.find((entry) => entry.id === 'credentialGroup') - if (!subBlock) throw new Error('credentialGroup subBlock is missing') - return subBlock -} - -async function waitForRequestCount(count: number) { - await vi.waitFor(() => expect(capturedSignals.length).toBe(count), { interval: 1, timeout: 1000 }) -} - -describe('credential group dynamic option resolution', () => { - let queryClient: QueryClient - - beforeEach(() => { - capturedSignals.length = 0 - pending.length = 0 - requestJsonMock.mockClear() - queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) - setTestQueryClient(queryClient) - }) - - afterEach(() => { - queryClient.clear() - }) - - it('keeps the shared workspace credential-group list alive when one option resolution is cancelled', async () => { - const definition = getSelectorDefinition( - getCredentialGroupSubBlock().selectorKey as SelectorKey - ) - const context = { workspaceId: WORKSPACE_ID } - const fetchOptionById = (_b: string, id: string, signal?: AbortSignal) => - definition.fetchById?.({ key: definition.key, context, detailId: id, signal }) - const fetchOptions = () => definition.fetchList?.({ key: definition.key, context }) - - const controller = new AbortController() - const optionResolution = Promise.resolve( - fetchOptionById('block-1', 'group-1', controller.signal) - ).catch(() => null) - - await waitForRequestCount(1) - - const sharedListConsumer = fetchOptions() - - controller.abort() - await Promise.resolve() - - pending[0].resolve({ credentialGroups: GROUPS }) - - await expect(sharedListConsumer).resolves.toEqual([ - { label: 'Support accounts', id: 'group-1' }, - ]) - expect(queryClient.getQueryState(WORKSPACE_LIST_KEY)?.status).not.toBe('error') - - await optionResolution - }) - - it('still cancels the underlying request when React Query cancels the shared list query', async () => { - const definition = getSelectorDefinition( - getCredentialGroupSubBlock().selectorKey as SelectorKey - ) - const fetchOptionById = (_b: string, id: string, signal?: AbortSignal) => - definition.fetchById?.({ - key: definition.key, - context: { workspaceId: WORKSPACE_ID }, - detailId: id, - signal, - }) - - const controller = new AbortController() - const optionResolution = Promise.resolve( - fetchOptionById('block-1', 'group-1', controller.signal) - ).catch(() => null) - - await waitForRequestCount(1) - - await queryClient.cancelQueries({ queryKey: WORKSPACE_LIST_KEY }) - - expect(capturedSignals[0]?.aborted).toBe(true) - - await optionResolution - }) -}) diff --git a/apps/sim/blocks/blocks/embeddings.test.ts b/apps/sim/blocks/blocks/embeddings.test.ts index 75e6f0c58de..370c0f21a73 100644 --- a/apps/sim/blocks/blocks/embeddings.test.ts +++ b/apps/sim/blocks/blocks/embeddings.test.ts @@ -1,18 +1,7 @@ /** * @vitest-environment node */ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { getSelectorDefinition } from '@/hooks/selectors/registry' -import type { SelectorKey } from '@/hooks/selectors/types' - -const { mockFetchQuery } = vi.hoisted(() => ({ - mockFetchQuery: vi.fn(), -})) - -vi.mock('@/app/_shell/providers/get-query-client', () => ({ - getQueryClient: () => ({ fetchQuery: mockFetchQuery }), -})) - +import { describe, expect, it } from 'vitest' import { DEFAULT_MODEL_BY_PROVIDER, EMBEDDING_MODELS } from '@/lib/embeddings/catalog' import { DEFAULT_OPENROUTER_EMBEDDING_MODEL } from '@/lib/embeddings/openrouter-models' import { @@ -21,12 +10,6 @@ import { TOOL_ID_BY_PROVIDER, } from '@/blocks/blocks/embeddings' -const OPENROUTER_MODELS = [ - 'openrouter/openai/text-embedding-3-small', - 'openrouter/qwen/qwen3-embedding-8b', - 'openrouter/google/gemini-embedding-001', -] - /** * The block derives its model, task-type, and dimension options from the * catalog, so these assert the derivation still produces what the UI expects: @@ -57,11 +40,6 @@ function conditionModel(subBlock: { condition?: unknown }): string | undefined { } describe('Embeddings block', () => { - beforeEach(() => { - vi.clearAllMocks() - mockFetchQuery.mockResolvedValue({ models: OPENROUTER_MODELS }) - }) - it('offers static catalog models for direct providers', () => { const modelSubBlocks = subBlocksById('model') const offered = new Map() @@ -154,15 +132,12 @@ describe('Embeddings block', () => { ) }) - it('loads every OpenRouter embedding model and maps its dedicated key', async () => { + it('uses the server-backed OpenRouter model selector and maps its dedicated key', () => { const openRouterModels = subBlocksById('model').find( (subBlock) => conditionProvider(subBlock) === 'openrouter' ) expect(openRouterModels?.type).toBe('combobox') - const definition = getSelectorDefinition(openRouterModels?.selectorKey as SelectorKey) - const options = await definition.fetchList?.({ key: definition.key, context: {} }) - expect(optionIds(options)).toEqual(OPENROUTER_MODELS) - expect(mockFetchQuery).toHaveBeenCalledOnce() + expect(openRouterModels?.selectorKey).toBe('providers.openrouterEmbeddingModels') expect( EmbeddingsBlock.tools.config?.params?.({ diff --git a/apps/sim/blocks/blocks/microsoft_excel.ts b/apps/sim/blocks/blocks/microsoft_excel.ts index 5b43ae79b4e..5e687b5489c 100644 --- a/apps/sim/blocks/blocks/microsoft_excel.ts +++ b/apps/sim/blocks/blocks/microsoft_excel.ts @@ -170,7 +170,7 @@ export const MicrosoftExcelBlock: BlockConfig = { requiredScopes: [], mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', placeholder: 'Select a spreadsheet', - dependsOn: ['credential'], + dependsOn: ['credential', 'driveId'], mode: 'basic', }, { diff --git a/apps/sim/blocks/selector-backed-subblocks.test.ts b/apps/sim/blocks/selector-backed-subblocks.test.ts index 1216c970db0..999307fbbfa 100644 --- a/apps/sim/blocks/selector-backed-subblocks.test.ts +++ b/apps/sim/blocks/selector-backed-subblocks.test.ts @@ -5,10 +5,13 @@ import { describe, expect, it, vi } from 'vitest' vi.unmock('@/blocks/registry') +import { + getSelectorManifestEntry, + type SelectorKey, + selectorManifest, +} from '@/lib/selectors/manifest' import { SELECTOR_CONTEXT_FIELDS } from '@/lib/workflows/subblocks/context' import { getAllBlocks } from '@/blocks/registry' -import { getSelectorDefinition } from '@/hooks/selectors/registry' -import type { SelectorKey } from '@/hooks/selectors/types' /** * Guards the two invariants a selector-backed sub-block relies on, both of which broke silently @@ -24,9 +27,7 @@ import type { SelectorKey } from '@/hooks/selectors/types' * `dependsOn` and none belong here. */ const SUB_BLOCK_SOURCED = new Set( - [...SELECTOR_CONTEXT_FIELDS].filter( - (field) => field !== 'workspaceId' && field !== 'workflowId' && field !== 'excludeWorkflowId' - ) + [...SELECTOR_CONTEXT_FIELDS].filter((field) => field !== 'excludeWorkflowId') ) describe('selector-backed sub-blocks', () => { @@ -42,12 +43,11 @@ describe('selector-backed sub-blocks', () => { expect(selectorBacked.length).toBeGreaterThan(50) }) - it('names a selector that is actually registered and can list', () => { + it('names a selector that is present in the exhaustive manifest', () => { for (const { block, sub } of selectorBacked) { - const definition = getSelectorDefinition(sub.selectorKey as SelectorKey) expect( - Boolean(definition.fetchList || definition.fetchPage), - `${block}.${sub.id} points at ${sub.selectorKey}, which can neither list nor page` + Object.hasOwn(selectorManifest, sub.selectorKey), + `${block}.${sub.id} points at unregistered selector ${sub.selectorKey}` ).toBe(true) } }) @@ -63,28 +63,14 @@ describe('selector-backed sub-blocks', () => { } }) - it('declares dependsOn for every sub-block-sourced context field its selector reads', () => { - // A selector's `getQueryKey` names every context field its RESULT depends on, and `enabled` - // names what it is gated on. Both are probed rather than listing fields by hand, which is - // what let `clickup.triggerWorkspaceId` ship without a `dependsOn`. + it('declares dependsOn when a selector reads sub-block-sourced context', () => { + // The manifest is the browser-safe declaration of every context field the result may read. // - // The declaration is what makes the list refetch: `useFetchedOptions` resets its fetch scope - // on `dependsOn` values changing. Without it the list loads once — before the credential is - // picked, or against the old language — and never reloads. + // The declaration gates the request and makes dependency changes advance the opaque query + // revision. Exact optional-source behavior is characterized in the shared context tests. for (const { block, sub } of selectorBacked) { - const definition = getSelectorDefinition(sub.selectorKey as SelectorKey) - const probed = new Set() - const context = new Proxy({} as Record, { - get: (_target, property) => { - if (typeof property === 'string') probed.add(property) - return undefined - }, - }) - const args = { key: definition.key, context } - definition.getQueryKey(args) - definition.enabled?.(args) - - const needed = [...probed].filter((field) => SUB_BLOCK_SOURCED.has(field)) + const manifest = getSelectorManifestEntry(sub.selectorKey as SelectorKey) + const needed = manifest.context.allowed.filter((field) => SUB_BLOCK_SOURCED.has(field)) if (needed.length === 0) continue const dependsOn = sub.dependsOn @@ -99,4 +85,23 @@ describe('selector-backed sub-blocks', () => { ).toBe(true) } }) + + it('declares optional cascade fields whose values change provider enumeration', () => { + const cases = [{ block: 'microsoft_excel', subBlock: 'spreadsheetId', dependency: 'driveId' }] + + for (const expected of cases) { + const match = selectorBacked.find( + ({ block, sub }) => block === expected.block && sub.id === expected.subBlock + ) + expect(match, `${expected.block}.${expected.subBlock} is missing`).toBeDefined() + const dependsOn = match?.sub.dependsOn + const declared = Array.isArray(dependsOn) + ? dependsOn + : [...(dependsOn?.all ?? []), ...(dependsOn?.any ?? [])] + expect( + declared, + `${expected.block}.${expected.subBlock} must send ${expected.dependency}` + ).toContain(expected.dependency) + } + }) }) diff --git a/apps/sim/blocks/types.ts b/apps/sim/blocks/types.ts index 4f792272ffe..758eaa8dde0 100644 --- a/apps/sim/blocks/types.ts +++ b/apps/sim/blocks/types.ts @@ -5,7 +5,7 @@ import type { PrimitiveValueType, SubBlockType, } from '@sim/workflow-types/blocks' -import type { SelectorKey } from '@/hooks/selectors/types' +import type { SelectorKey } from '@/lib/selectors/manifest' import type { ToolResponse } from '@/tools/types' export type { OutputCondition, OutputFieldDefinition, PrimitiveValueType, SubBlockType } diff --git a/apps/sim/connectors/types.ts b/apps/sim/connectors/types.ts index 9ab557d63a8..cc11ee3fd7a 100644 --- a/apps/sim/connectors/types.ts +++ b/apps/sim/connectors/types.ts @@ -1,5 +1,5 @@ import type { OAuthService } from '@/lib/oauth/types' -import type { SelectorKey } from '@/hooks/selectors/types' +import type { SelectorKey } from '@/lib/selectors/manifest' /** * Authentication configuration for a connector. diff --git a/apps/sim/ee/workspace-forking/components/fork-sync/dependent-field-selector.tsx b/apps/sim/ee/workspace-forking/components/fork-sync/dependent-field-selector.tsx index a10e098e491..085fa51cfec 100644 --- a/apps/sim/ee/workspace-forking/components/fork-sync/dependent-field-selector.tsx +++ b/apps/sim/ee/workspace-forking/components/fork-sync/dependent-field-selector.tsx @@ -2,14 +2,17 @@ import { useMemo } from 'react' import { ChipCombobox, type ComboboxOption, Loader } from '@sim/emcn' +import type { SelectorKey } from '@/lib/selectors/manifest' +import type { SelectorContext } from '@/lib/selectors/types' import { dependentFieldNoun } from '@/ee/workspace-forking/components/fork-sync/dependent-field-noun' -import type { SelectorContext, SelectorKey } from '@/hooks/selectors/types' -import { useSelectorOptions } from '@/hooks/selectors/use-selector-query' +import { useSelectorOptions } from '@/hooks/queries/selectors' interface DependentFieldSelectorProps { selectorKey: SelectorKey /** Full selector context, including the newly-chosen parent value. */ context: Record + /** Workspace whose parent resource the selector browses. */ + workspaceId: string /** False until the parent (credential/KB) target is chosen. */ enabled: boolean value: string @@ -26,6 +29,7 @@ interface DependentFieldSelectorProps { export function DependentFieldSelector({ selectorKey, context, + workspaceId, enabled, value, onChange, @@ -39,7 +43,9 @@ export function DependentFieldSelector({ const { data: options = [], isLoading } = useSelectorOptions(selectorKey, { context: selectorContext, + scope: { kind: 'workspace', workspaceId }, enabled, + surfaceId: `fork:${title}`, }) const comboboxOptions = useMemo( diff --git a/apps/sim/ee/workspace-forking/components/fork-sync/fork-sync-view.tsx b/apps/sim/ee/workspace-forking/components/fork-sync/fork-sync-view.tsx index 043d28247a4..6414c39966e 100644 --- a/apps/sim/ee/workspace-forking/components/fork-sync/fork-sync-view.tsx +++ b/apps/sim/ee/workspace-forking/components/fork-sync/fork-sync-view.tsx @@ -23,6 +23,7 @@ import type { ForkResourceUsage, ForkTriggerMapping, } from '@/lib/api/contracts/workspace-fork' +import type { SelectorKey } from '@/lib/selectors/manifest' import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' import { @@ -58,7 +59,6 @@ import type { } from '@/ee/workspace-forking/components/fork-sync/use-fork-sync' import type { ForkDirection } from '@/ee/workspace-forking/hooks/workspace-fork' import { forkSyncBlockerReasonFor } from '@/ee/workspace-forking/lib/promote/sync-blockers' -import type { SelectorKey } from '@/hooks/selectors/types' import { buildWebhookTriggerUrl } from '@/triggers/webhook-url' /** @@ -291,11 +291,10 @@ function DependentSelector({ return ( - queryClient.invalidateQueries({ - queryKey: credentialGroupKeys.access(variables.workspaceId, variables.groupId), - exact: true, - }), + Promise.all([ + queryClient.invalidateQueries({ + queryKey: credentialGroupKeys.access(variables.workspaceId, variables.groupId), + exact: true, + }), + queryClient.invalidateQueries({ queryKey: selectorKeys.all }), + ]), }) } @@ -138,6 +142,7 @@ export function useCreateCredentialGroup() { }) => requestJson(createCredentialGroupContract, { params: { id: workspaceId }, body }), onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ queryKey: credentialGroupKeys.list(variables.workspaceId) }) + queryClient.invalidateQueries({ queryKey: selectorKeys.all }) }, }) } @@ -154,6 +159,7 @@ export function useDeleteCredentialGroup() { queryClient.removeQueries({ queryKey: credentialGroupKeys.detail(variables.workspaceId, variables.groupId), }) + queryClient.invalidateQueries({ queryKey: selectorKeys.all }) }, }) } @@ -185,6 +191,7 @@ export function useUpdateCredentialGroup() { queryClient.invalidateQueries({ queryKey: credentialGroupKeys.detail(variables.workspaceId, variables.groupId), }), + queryClient.invalidateQueries({ queryKey: selectorKeys.all }), ]), }) } @@ -227,6 +234,7 @@ export function useInviteCredentialGroupEnrollments() { queryClient.invalidateQueries({ queryKey: credentialGroupKeys.detail(variables.workspaceId, variables.groupId), }) + queryClient.invalidateQueries({ queryKey: selectorKeys.all }) }, }) } @@ -250,6 +258,7 @@ export function useResendCredentialGroupEnrollment() { queryClient.invalidateQueries({ queryKey: credentialGroupKeys.detail(variables.workspaceId, variables.groupId), }) + queryClient.invalidateQueries({ queryKey: selectorKeys.all }) }, }) } @@ -273,6 +282,7 @@ export function useDeleteCredentialGroupEnrollment() { queryClient.invalidateQueries({ queryKey: credentialGroupKeys.detail(variables.workspaceId, variables.groupId), }) + queryClient.invalidateQueries({ queryKey: selectorKeys.all }) }, }) } diff --git a/apps/sim/hooks/queries/credentials.ts b/apps/sim/hooks/queries/credentials.ts index ff4a7c5ff9f..393d67efe03 100644 --- a/apps/sim/hooks/queries/credentials.ts +++ b/apps/sim/hooks/queries/credentials.ts @@ -30,6 +30,7 @@ import { requireWorkspaceCredentialListResponse, WORKSPACE_CREDENTIAL_LIST_STALE_TIME, } from '@/hooks/queries/utils/fetch-workspace-credentials' +import { selectorKeys } from '@/hooks/queries/utils/selector-keys' /** * Key prefix for OAuth credential queries. @@ -132,6 +133,7 @@ export function useCreateWorkspaceCredential() { queryClient.invalidateQueries({ queryKey: OAUTH_CREDENTIALS_KEY, }) + queryClient.invalidateQueries({ queryKey: selectorKeys.all }) }, }) } @@ -222,6 +224,7 @@ export function useUpdateWorkspaceCredential() { queryClient.invalidateQueries({ queryKey: OAUTH_CREDENTIALS_KEY, }) + queryClient.invalidateQueries({ queryKey: selectorKeys.all }) }, }) } @@ -239,6 +242,7 @@ export function useDeleteWorkspaceCredential() { queryClient.invalidateQueries({ queryKey: OAUTH_CREDENTIALS_KEY }) queryClient.invalidateQueries({ queryKey: environmentKeys.all }) queryClient.invalidateQueries({ queryKey: oauthConnectionsKeys.connections() }) + queryClient.invalidateQueries({ queryKey: selectorKeys.all }) }, }) } @@ -283,6 +287,7 @@ export function useUpsertWorkspaceCredentialMember() { queryClient.invalidateQueries({ queryKey: workspaceCredentialKeys.detail(variables.credentialId), }) + queryClient.invalidateQueries({ queryKey: selectorKeys.all }) }, }) } @@ -308,6 +313,7 @@ export function useRemoveWorkspaceCredentialMember() { queryClient.invalidateQueries({ queryKey: workspaceCredentialKeys.detail(variables.credentialId), }) + queryClient.invalidateQueries({ queryKey: selectorKeys.all }) }, }) } diff --git a/apps/sim/hooks/queries/dynamic-subblock-options.test.tsx b/apps/sim/hooks/queries/dynamic-subblock-options.test.tsx index 279217f330a..ea44af40421 100644 --- a/apps/sim/hooks/queries/dynamic-subblock-options.test.tsx +++ b/apps/sim/hooks/queries/dynamic-subblock-options.test.tsx @@ -6,28 +6,22 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { createRoot, type Root } from 'react-dom/client' import { afterEach, describe, expect, it, vi } from 'vitest' -const { mockGetSelectorDefinition } = vi.hoisted(() => ({ - mockGetSelectorDefinition: vi.fn(), +const { mockUseSelectorOptionDetails } = vi.hoisted(() => ({ + mockUseSelectorOptionDetails: vi.fn(), })) -vi.mock('@/hooks/selectors/registry', () => ({ - getSelectorDefinition: mockGetSelectorDefinition, +vi.mock('@/hooks/queries/selectors', () => ({ + useSelectorOptionDetails: mockUseSelectorOptionDetails, })) +import type { SelectorKey } from '@/lib/selectors/manifest' import type { SubBlockConfig } from '@/blocks/types' -import { - dynamicSubBlockOptionKeys, - useDynamicSubBlockOptionDisplayName, -} from '@/hooks/queries/dynamic-subblock-options' -import type { SelectorDefinition, SelectorKey } from '@/hooks/selectors/types' +import { useDynamicSubBlockOptionDisplayName } from '@/hooks/queries/dynamic-subblock-options' +import { selectorKeys } from '@/hooks/queries/utils/selector-keys' /** Any registered key; the hook only uses it to look the definition up. */ const SELECTOR_KEY = 'workspace.credentialGroups' as SelectorKey -function mockDefinition(definition: Partial) { - mockGetSelectorDefinition.mockReturnValue(definition as SelectorDefinition) -} - interface HookHarness { result: () => T unmount: () => void @@ -74,11 +68,9 @@ describe('useDynamicSubBlockOptionDisplayName', () => { }) it('hydrates a stored dynamic dropdown id to its label', async () => { - const fetchById = vi.fn(async ({ detailId }: { detailId?: string }) => ({ - id: detailId as string, - label: 'Customer support accounts', - })) - mockDefinition({ key: SELECTOR_KEY, getQueryKey: () => [SELECTOR_KEY], fetchById }) + mockUseSelectorOptionDetails.mockReturnValue([ + { id: 'group-uuid', label: 'Customer support accounts' }, + ]) const subBlock = { id: 'credentialGroup', title: 'Credential Group', @@ -98,17 +90,20 @@ describe('useDynamicSubBlockOptionDisplayName', () => { await waitForResult(() => expect(hook.result()).toBe('Customer support accounts')) - expect(fetchById).toHaveBeenCalledWith( - expect.objectContaining({ detailId: 'group-uuid', context: { workspaceId: 'workspace-1' } }) + expect(mockUseSelectorOptionDetails).toHaveBeenCalledWith( + SELECTOR_KEY, + expect.objectContaining({ + detailIds: ['group-uuid'], + scope: { kind: 'workspace', workspaceId: 'workspace-1' }, + }) ) }) it('summarizes every selected dynamic option without dropping ids', async () => { - const fetchById = vi.fn(async ({ detailId }: { detailId?: string }) => ({ - id: detailId as string, - label: detailId === 'gmail' ? 'Gmail' : 'Slack', - })) - mockDefinition({ key: SELECTOR_KEY, getQueryKey: () => [SELECTOR_KEY], fetchById }) + mockUseSelectorOptionDetails.mockReturnValue([ + { id: 'gmail', label: 'Gmail' }, + { id: 'slack', label: 'Slack' }, + ]) const subBlock = { id: 'providerFilter', title: 'Provider', @@ -130,21 +125,19 @@ describe('useDynamicSubBlockOptionDisplayName', () => { await waitForResult(() => expect(hook.result()).toBe('Gmail, Slack')) }) - it('re-resolves a label when the sibling its selector depends on changes', () => { - // The bug: `fetchById` reads sibling context, but the cache key did not, so a label - // resolved before a credential group was picked (null) stayed cached after it was, and the - // card kept showing the raw id. The key now carries the selector's OWN query key, which - // names every context field its result depends on. - const keyFor = (credentialGroupId?: string) => - dynamicSubBlockOptionKeys.detail('workspace-1', 'block-1', 'providerFilter', 'gmail', [ - 'selectors', + it('re-keys a detail query by opaque revision without including dependency values', () => { + const scope = { kind: 'workspace', workspaceId: 'workspace-1' } as const + const keyFor = (revision: number) => + selectorKeys.request( 'workspace.credentialGroupProviders', - 'workspace-1', - credentialGroupId ?? 'none', - ]) - - expect(keyFor(undefined)).not.toEqual(keyFor('group-1')) - expect(keyFor('group-1')).not.toEqual(keyFor('group-2')) - expect(keyFor('group-1')).toEqual(keyFor('group-1')) + scope, + 'canvas:block-1:providerFilter', + 'detail', + revision + ) + + expect(keyFor(0)).not.toEqual(keyFor(1)) + expect(keyFor(1)).toEqual(keyFor(1)) + expect(JSON.stringify(keyFor(1))).not.toContain('group-1') }) }) diff --git a/apps/sim/hooks/queries/dynamic-subblock-options.ts b/apps/sim/hooks/queries/dynamic-subblock-options.ts index 0bc64829ac3..f47bcfce864 100644 --- a/apps/sim/hooks/queries/dynamic-subblock-options.ts +++ b/apps/sim/hooks/queries/dynamic-subblock-options.ts @@ -1,42 +1,14 @@ import { useMemo } from 'react' -import { useQueries } from '@tanstack/react-query' -import { buildSelectorContextFromBlock } from '@/lib/workflows/subblocks/context' +import { buildSelectorRawContext } from '@/lib/selectors/context' +import type { SelectorScope } from '@/lib/selectors/types' +import { getDependsOnFields } from '@/lib/workflows/subblocks/dependencies' import { summarizeNames } from '@/lib/workflows/subblocks/display' import type { SubBlockConfig } from '@/blocks/types' -import { getSelectorDefinition } from '@/hooks/selectors/registry' -import type { SelectorContext } from '@/hooks/selectors/types' +import { useSelectorOptionDetails } from '@/hooks/queries/selectors' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useSubBlockStore } from '@/stores/workflows/subblock/store' import { useWorkflowStore } from '@/stores/workflows/workflow/store' -export const DYNAMIC_SUBBLOCK_OPTION_STALE_TIME = 30 * 1000 - -export const dynamicSubBlockOptionKeys = { - all: ['dynamic-subblock-options'] as const, - details: () => [...dynamicSubBlockOptionKeys.all, 'detail'] as const, - /** - * `selectorScope` is the selector's OWN query key for this context — every context field its - * result depends on, named by the selector rather than restated here. Without it a label - * resolved under an empty or previous sibling (no credential group picked yet) stays cached - * and is reused once the sibling is set, so the card keeps showing a raw id or a stale name. - */ - detail: ( - workspaceId?: string, - blockId?: string, - subBlockId?: string, - optionId?: string, - selectorScope: readonly unknown[] = [] - ) => - [ - ...dynamicSubBlockOptionKeys.details(), - workspaceId ?? '', - blockId ?? '', - subBlockId ?? '', - optionId ?? '', - ...selectorScope, - ] as const, -} - interface UseDynamicSubBlockOptionDisplayNameArgs { workspaceId?: string blockId?: string @@ -48,14 +20,11 @@ function getResolvableOptionIds(value: unknown): string[] { const values = typeof value === 'string' ? [value] : Array.isArray(value) ? value : [] return values.filter( (entry): entry is string => - typeof entry === 'string' && - entry.length > 0 && - !entry.startsWith('<') && - !entry.includes('{{') + typeof entry === 'string' && entry.length > 0 && !entry.startsWith('<') ) } -/** Resolves labels for dropdown options whose choices are loaded dynamically. */ +/** Resolves labels for selector-backed values through the shared server facade. */ export function useDynamicSubBlockOptionDisplayName({ workspaceId, blockId, @@ -63,78 +32,58 @@ export function useDynamicSubBlockOptionDisplayName({ value, }: UseDynamicSubBlockOptionDisplayNameArgs): string | null { const optionIds = useMemo(() => getResolvableOptionIds(value), [value]) - // Label resolution follows the option source: a selector's own `fetchById`. There is no - // per-block resolver any more, so a selector without one simply renders the raw id. - const definition = subBlock?.selectorKey ? getSelectorDefinition(subBlock.selectorKey) : undefined - const fetchById = definition?.fetchById - - /** - * The block's own values, the same context the canvas builds. A `workspaceId`-only context - * silently fails every selector scoped by a sibling — `workspace.credentialGroupProviders` - * needs the group before it can name a provider, so the card fell back to raw ids. - */ const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId) const block = useWorkflowStore((state) => (blockId ? state.blocks[blockId] : undefined)) const liveValues = useSubBlockStore((state) => activeWorkflowId && blockId ? state.workflowValues[activeWorkflowId]?.[blockId] : undefined ) + const selectorKey = subBlock?.selectorKey - const resolverContext = useMemo((): SelectorContext => { - if (!block?.type) return { workspaceId } + const context = useMemo(() => { + if (!selectorKey || !block?.type) return {} const merged: Record = { ...(block.subBlocks ?? {}) } - for (const [id, value] of Object.entries(liveValues ?? {})) { - merged[id] = { ...merged[id], value } + for (const [id, liveValue] of Object.entries(liveValues ?? {})) { + merged[id] = { ...merged[id], value: liveValue } } - return buildSelectorContextFromBlock(block.type, merged, { - workflowId: activeWorkflowId ?? undefined, - workspaceId, + return buildSelectorRawContext({ + selectorKey, + blockType: block.type, + subBlocks: merged, + dependsOn: getDependsOnFields(subBlock.dependsOn), canonicalModes: block.data?.canonicalModes, triggerMode: block.triggerMode, + staticContext: { + mimeType: subBlock.mimeType, + excludeWorkflowId: + selectorKey === 'sim.workflows' ? (activeWorkflowId ?? undefined) : undefined, + }, }) - }, [block, liveValues, activeWorkflowId, workspaceId]) + }, [activeWorkflowId, block, liveValues, selectorKey, subBlock]) - /** - * The selector's own key for this context. Reusing it means the cache is scoped by exactly - * what the selector reads — no second list of context fields to keep in step, and it stays - * correct when a selector's dependencies change. - */ - const selectorScope = useMemo( - () => - definition ? definition.getQueryKey({ key: definition.key, context: resolverContext }) : [], - [definition, resolverContext] - ) - const canResolve = Boolean(blockId && fetchById && optionIds.length > 0) + const scope = useMemo(() => { + if (activeWorkflowId) { + return { + kind: 'workflow', + workflowId: activeWorkflowId, + ...(workspaceId ? { workspaceId } : {}), + } + } + return workspaceId ? { kind: 'workspace', workspaceId } : undefined + }, [activeWorkflowId, workspaceId]) - const queries = useQueries({ - queries: canResolve - ? optionIds.map((optionId) => ({ - queryKey: dynamicSubBlockOptionKeys.detail( - workspaceId, - blockId, - subBlock?.id, - optionId, - selectorScope as readonly unknown[] - ), - queryFn: ({ signal }) => { - if (!blockId || !fetchById || !definition) { - throw new Error('Dynamic subblock option resolver is required') - } - return fetchById({ - key: definition.key, - context: resolverContext, - detailId: optionId, - signal, - }) - }, - staleTime: DYNAMIC_SUBBLOCK_OPTION_STALE_TIME, - })) - : [], + const selectedOptions = useSelectorOptionDetails(selectorKey ?? 'workspace.triggerTypes', { + context, + scope, + detailIds: selectorKey && blockId ? optionIds : [], + enabled: Boolean(selectorKey && blockId && optionIds.length > 0), + surfaceId: `canvas:${blockId ?? 'none'}:${subBlock?.id ?? 'none'}`, }) return useMemo(() => { - if (!canResolve || queries.length !== optionIds.length) return null - const labels = queries.map((query) => query.data?.label) + if (!selectorKey || optionIds.length === 0) return null + const labelsById = new Map(selectedOptions.map((option) => [option.id, option.label])) + const labels = optionIds.map((id) => labelsById.get(id)) if (!labels.every((label): label is string => Boolean(label))) return null return summarizeNames(labels) - }, [canResolve, optionIds.length, queries]) + }, [optionIds, selectedOptions, selectorKey]) } diff --git a/apps/sim/hooks/queries/environment.ts b/apps/sim/hooks/queries/environment.ts index db543ff88ad..8fbd2942a99 100644 --- a/apps/sim/hooks/queries/environment.ts +++ b/apps/sim/hooks/queries/environment.ts @@ -9,6 +9,7 @@ import { } from '@/lib/api/contracts' import type { WorkspaceEnvironmentData } from '@/lib/environment/api' import { fetchPersonalEnvironment, fetchWorkspaceEnvironment } from '@/lib/environment/api' +import { selectorKeys } from '@/hooks/queries/utils/selector-keys' const logger = createLogger('EnvironmentQueries') @@ -79,6 +80,7 @@ export function useSavePersonalEnvironment() { await Promise.all([ queryClient.invalidateQueries({ queryKey: environmentKeys.personal() }), queryClient.invalidateQueries({ queryKey: environmentKeys.workspaces() }), + queryClient.invalidateQueries({ queryKey: selectorKeys.all }), ]) }, }) @@ -104,9 +106,12 @@ export function useUpsertWorkspaceEnvironment() { return data }, onSettled: (_data, _error, variables) => - queryClient.invalidateQueries({ - queryKey: environmentKeys.workspace(variables.workspaceId), - }), + Promise.all([ + queryClient.invalidateQueries({ + queryKey: environmentKeys.workspace(variables.workspaceId), + }), + queryClient.invalidateQueries({ queryKey: selectorKeys.all }), + ]), }) } @@ -130,8 +135,11 @@ export function useRemoveWorkspaceEnvironment() { return data }, onSettled: (_data, _error, variables) => - queryClient.invalidateQueries({ - queryKey: environmentKeys.workspace(variables.workspaceId), - }), + Promise.all([ + queryClient.invalidateQueries({ + queryKey: environmentKeys.workspace(variables.workspaceId), + }), + queryClient.invalidateQueries({ queryKey: selectorKeys.all }), + ]), }) } diff --git a/apps/sim/hooks/queries/selectors.test.tsx b/apps/sim/hooks/queries/selectors.test.tsx new file mode 100644 index 00000000000..b88ca3d97ea --- /dev/null +++ b/apps/sim/hooks/queries/selectors.test.tsx @@ -0,0 +1,327 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { sleep } from '@sim/utils/helpers' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockExecuteSelectorRequest, mockLoggerWarn } = vi.hoisted(() => ({ + mockExecuteSelectorRequest: vi.fn(), + mockLoggerWarn: vi.fn(), +})) + +vi.mock('@/lib/selectors/client/execute-selector', () => ({ + executeSelectorRequest: mockExecuteSelectorRequest, +})) + +vi.mock('@sim/logger', () => ({ + createLogger: () => ({ warn: mockLoggerWarn }), +})) + +import { useSelectorOptionDetail, useSelectorOptions } from '@/hooks/queries/selectors' + +interface HookHarness { + getResult: () => T + queryClient: QueryClient + rerender: (nextHook?: () => T) => void + unmount: () => void +} + +const mountedRoots = new Set() + +function renderHookWithClient( + initialHook: () => T, + queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) +): HookHarness { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + mountedRoots.add(root) + let hook = initialHook + let result: T | undefined + + function Probe() { + result = hook() + return null + } + + const render = () => { + root.render( + + + + ) + } + + act(render) + + return { + getResult: () => { + if (result === undefined) throw new Error('Hook result is not ready') + return result + }, + queryClient, + rerender: (nextHook) => { + if (nextHook) hook = nextHook + act(render) + }, + unmount: () => { + if (!mountedRoots.delete(root)) return + act(() => root.unmount()) + void queryClient.cancelQueries() + container.remove() + }, + } +} + +async function waitFor(assertion: () => void, timeout = 2_000) { + await act(async () => { + await vi.waitFor(assertion, { interval: 1, timeout }) + }) +} + +function serializedKeys(queryClient: QueryClient): string { + return JSON.stringify( + queryClient + .getQueryCache() + .getAll() + .map((query) => query.queryKey) + ) +} + +beforeEach(() => { + vi.clearAllMocks() +}) + +afterEach(() => { + act(() => { + for (const root of mountedRoots) root.unmount() + }) + mountedRoots.clear() + document.body.replaceChildren() +}) + +describe('generic selector queries', () => { + it('transports supported search and keeps context and request plaintext out of query keys', async () => { + const credentialReference = '{{SHARED_GOOGLE_CREDENTIAL}}' + const search = 'private search phrase' + mockExecuteSelectorRequest.mockResolvedValue({ + kind: 'list', + items: [{ id: 'file-1', label: 'Quarterly report' }], + }) + + const hook = renderHookWithClient(() => + useSelectorOptions('google.drive', { + context: { + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + oauthCredential: credentialReference, + mimeType: 'application/private-canary', + }, + search, + surfaceId: 'canvas:block-1:file', + }) + ) + + await waitFor(() => + expect(hook.getResult().data).toEqual([{ id: 'file-1', label: 'Quarterly report' }]) + ) + + expect(mockExecuteSelectorRequest).toHaveBeenCalledWith( + expect.objectContaining({ + selectorKey: 'google.drive', + scope: { + kind: 'workflow', + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + }, + context: { + oauthCredential: credentialReference, + mimeType: 'application/private-canary', + }, + request: { kind: 'list', search }, + signal: expect.any(AbortSignal), + }) + ) + const keys = serializedKeys(hook.queryClient) + expect(keys).toContain('google.drive') + expect(keys).not.toContain(credentialReference) + expect(keys).not.toContain(search) + expect(keys).not.toContain('application/private-canary') + }) + + it('omits unsupported search without needlessly issuing another request when it changes', async () => { + mockExecuteSelectorRequest.mockResolvedValue({ kind: 'list', items: [] }) + let search = 'first private phrase' + const useHook = () => + useSelectorOptions('gmail.labels', { + context: { + workspaceId: 'workspace-1', + oauthCredential: '{{GMAIL_CREDENTIAL}}', + }, + search, + surfaceId: 'connector:gmail:label', + }) + const hook = renderHookWithClient(useHook) + + await waitFor(() => expect(mockExecuteSelectorRequest).toHaveBeenCalledTimes(1)) + expect(mockExecuteSelectorRequest.mock.calls[0][0].request).toEqual({ kind: 'list' }) + + search = 'second private phrase' + hook.rerender(useHook) + await act(async () => { + await sleep(5) + }) + + expect(mockExecuteSelectorRequest).toHaveBeenCalledTimes(1) + expect(serializedKeys(hook.queryClient)).not.toContain('private phrase') + }) + + it('uses distinct opaque revisions for dependency changes and later mounts', async () => { + mockExecuteSelectorRequest.mockResolvedValue({ kind: 'list', items: [] }) + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + let credential = '{{FIRST_SHARED_CREDENTIAL}}' + const useHook = () => + useSelectorOptions('gmail.labels', { + context: { workspaceId: 'workspace-1', oauthCredential: credential }, + surfaceId: 'canvas:block-1:label', + }) + const first = renderHookWithClient(useHook, queryClient) + await waitFor(() => expect(mockExecuteSelectorRequest).toHaveBeenCalledTimes(1)) + + credential = '{{SECOND_SHARED_CREDENTIAL}}' + first.rerender(useHook) + await waitFor(() => expect(mockExecuteSelectorRequest).toHaveBeenCalledTimes(2)) + first.unmount() + + const second = renderHookWithClient(useHook, queryClient) + await waitFor(() => expect(mockExecuteSelectorRequest).toHaveBeenCalledTimes(3)) + + const keys = queryClient + .getQueryCache() + .getAll() + .map((query) => query.queryKey) + const revisions = keys.filter((key) => key.at(-1) !== 'paged').map((key) => key.at(-1)) + expect(new Set(revisions).size).toBe(3) + expect(serializedKeys(queryClient)).not.toContain('SHARED_CREDENTIAL') + second.unmount() + }) + + it('progressively drains paginated selectors without putting cursors in the base key', async () => { + mockExecuteSelectorRequest.mockImplementation( + async ({ request }: { request: { cursor?: string } }) => + request.cursor + ? { kind: 'list', items: [{ id: 'repo-2', label: 'Second' }] } + : { + kind: 'list', + items: [{ id: 'repo-1', label: 'First' }], + nextCursor: 'private-provider-cursor', + } + ) + + const hook = renderHookWithClient(() => + useSelectorOptions('bitbucket.workspaces', { + context: { workspaceId: 'workspace-1', oauthCredential: '{{BITBUCKET_CREDENTIAL}}' }, + surfaceId: 'canvas:block-1:workspace', + }) + ) + + await waitFor(() => + expect(hook.getResult().data).toEqual([ + { id: 'repo-1', label: 'First' }, + { id: 'repo-2', label: 'Second' }, + ]) + ) + + expect(mockExecuteSelectorRequest).toHaveBeenCalledTimes(2) + expect(mockExecuteSelectorRequest.mock.calls[1][0].request).toEqual({ + kind: 'list', + cursor: 'private-provider-cursor', + }) + expect(serializedKeys(hook.queryClient)).not.toContain('private-provider-cursor') + expect(hook.getResult()).toMatchObject({ hasMore: false, truncated: false }) + }) + + it('stops automatic pagination at the 50-page safety cap', async () => { + mockExecuteSelectorRequest.mockImplementation(async () => ({ + kind: 'list', + items: [{ id: 'workspace', label: 'Workspace' }], + nextCursor: `cursor-${mockExecuteSelectorRequest.mock.calls.length}`, + })) + + const hook = renderHookWithClient(() => + useSelectorOptions('bitbucket.workspaces', { + context: { workspaceId: 'workspace-1', oauthCredential: 'credential-1' }, + surfaceId: 'canvas:block-1:workspace', + }) + ) + + await waitFor(() => expect(hook.getResult().truncated).toBe(true), 5_000) + + expect(mockExecuteSelectorRequest).toHaveBeenCalledTimes(50) + expect(hook.getResult()).toMatchObject({ hasMore: false, truncated: true }) + expect(mockLoggerWarn).toHaveBeenCalledWith( + 'Selector hit auto-drain cap; option list is truncated', + { selectorKey: 'bitbucket.workspaces', pages: 50 } + ) + }) + + it('hydrates detail options while keeping the detail id and references out of its key', async () => { + const detailId = 'private-issue-id' + mockExecuteSelectorRequest.mockResolvedValue({ + kind: 'detail', + item: { id: detailId, label: 'Issue label' }, + }) + + const hook = renderHookWithClient(() => + useSelectorOptionDetail('jira.issues', { + context: { + workflowId: 'workflow-1', + oauthCredential: '{{JIRA_CREDENTIAL}}', + domain: '{{JIRA_DOMAIN}}', + }, + detailId, + surfaceId: 'canvas:block-1:issue', + }) + ) + + await waitFor(() => + expect(hook.getResult().data).toEqual({ id: detailId, label: 'Issue label' }) + ) + + expect(mockExecuteSelectorRequest.mock.calls[0][0].request).toEqual({ + kind: 'detail', + id: detailId, + }) + const keys = serializedKeys(hook.queryClient) + expect(keys).not.toContain(detailId) + expect(keys).not.toContain('JIRA_CREDENTIAL') + expect(keys).not.toContain('JIRA_DOMAIN') + }) + + it('forwards React Query cancellation to selector execution', async () => { + let requestSignal: AbortSignal | undefined + mockExecuteSelectorRequest.mockImplementation( + ({ signal }: { signal: AbortSignal }) => + new Promise((_resolve, reject) => { + requestSignal = signal + signal.addEventListener('abort', () => reject(signal.reason), { once: true }) + }) + ) + + const hook = renderHookWithClient(() => + useSelectorOptions('gmail.labels', { + context: { workspaceId: 'workspace-1', oauthCredential: 'credential-1' }, + surfaceId: 'connector:gmail:label', + }) + ) + await waitFor(() => expect(requestSignal).toBeDefined()) + + hook.unmount() + + expect(requestSignal?.aborted).toBe(true) + }) +}) diff --git a/apps/sim/hooks/queries/selectors.ts b/apps/sim/hooks/queries/selectors.ts new file mode 100644 index 00000000000..617df441297 --- /dev/null +++ b/apps/sim/hooks/queries/selectors.ts @@ -0,0 +1,306 @@ +'use client' + +import { useEffect, useId, useMemo, useRef } from 'react' +import { createLogger } from '@sim/logger' +import { useInfiniteQuery, useQueries, useQuery } from '@tanstack/react-query' +import { executeSelectorRequest } from '@/lib/selectors/client/execute-selector' +import { projectSelectorContext } from '@/lib/selectors/context' +import { + getSelectorManifestEntry, + isSelectorReady, + type SelectorKey, +} from '@/lib/selectors/manifest' +import type { + SelectorContext, + SelectorOption, + SelectorPage, + SelectorScope, +} from '@/lib/selectors/types' +import { selectorKeys } from '@/hooks/queries/utils/selector-keys' + +const logger = createLogger('SelectorQuery') +const MAX_AUTO_DRAIN_PAGES = 50 +const EMPTY_PAGE: SelectorPage = { items: [] } +let nextOpaqueRevision = 1 + +export type SelectorClientContext = SelectorContext & { + workflowId?: string + workspaceId?: string +} + +interface SelectorHookArgs { + context: SelectorClientContext + scope?: SelectorScope + search?: string + enabled?: boolean + surfaceId?: string +} + +export interface SelectorOptionsResult { + data: SelectorOption[] | undefined + isLoading: boolean + isFetching: boolean + isFetchingMore: boolean + hasMore: boolean + truncated: boolean + error: Error | null + isSuccess: boolean + refetch(): void +} + +export function selectorScopeFromContext( + context: SelectorClientContext, + explicit?: SelectorScope +): SelectorScope | undefined { + if (explicit) return explicit + if (context.workflowId) { + return { + kind: 'workflow', + workflowId: context.workflowId, + ...(context.workspaceId ? { workspaceId: context.workspaceId } : {}), + } + } + if (context.workspaceId) return { kind: 'workspace', workspaceId: context.workspaceId } + return undefined +} + +function sameValues(left: readonly unknown[], right: readonly unknown[]): boolean { + return ( + left.length === right.length && left.every((value, index) => Object.is(value, right[index])) + ) +} + +/** + * Tracks dependency changes without serializing, hashing, or placing their values in a cache key. + * The values stay only in the mounted component's ordinary state/ref memory. + */ +function useOpaqueRevision(values: readonly unknown[]): number { + const state = useRef<{ values: readonly unknown[]; revision: number } | null>(null) + if (!state.current) { + state.current = { values, revision: nextOpaqueRevision++ } + } + if (!sameValues(state.current.values, values)) { + state.current = { values, revision: nextOpaqueRevision++ } + } + return state.current.revision +} + +function usePreparedSelector( + key: SelectorKey, + args: SelectorHookArgs, + requestValues: readonly unknown[] +) { + const generatedSurfaceId = useId() + const manifest = getSelectorManifestEntry(key) + const context = projectSelectorContext(key, args.context) + const scope = selectorScopeFromContext(args.context, args.scope) + const contextValues = manifest.context.allowed.map((field) => context[field]) + const revision = useOpaqueRevision([...contextValues, ...requestValues]) + const ready = + args.enabled !== false && + isSelectorReady(key, context) && + (manifest.classification === 'local' || Boolean(scope)) + return { + manifest, + context, + scope, + revision, + ready, + surfaceId: args.surfaceId ?? generatedSurfaceId, + } +} + +export function useSelectorOptions( + key: SelectorKey, + args: SelectorHookArgs +): SelectorOptionsResult { + const effectiveSearch = getSelectorManifestEntry(key).supportsSearch ? args.search : undefined + const prepared = usePreparedSelector(key, args, [effectiveSearch]) + const supportsPagination = prepared.manifest.listMode === 'paginated' + const baseKey = selectorKeys.request( + key, + prepared.scope, + prepared.surfaceId, + 'list', + prepared.revision + ) + + const flatQuery = useQuery({ + // rq-lint-allow: context and search are represented by an opaque privacy revision. + queryKey: baseKey, + queryFn: async ({ signal }) => { + const result = await executeSelectorRequest({ + selectorKey: key, + scope: prepared.scope, + context: prepared.context, + request: { + kind: 'list', + ...(effectiveSearch !== undefined ? { search: effectiveSearch } : {}), + }, + signal, + }) + if (result.kind !== 'list') throw new Error('Selector returned an unexpected detail result') + return result.items + }, + enabled: !supportsPagination && prepared.ready, + staleTime: prepared.manifest.staleTime, + }) + + const pagedQuery = useInfiniteQuery({ + // rq-lint-allow: context and search are represented by an opaque privacy revision. + queryKey: [...baseKey, 'paged'], + queryFn: async ({ pageParam, signal }) => { + const result = await executeSelectorRequest({ + selectorKey: key, + scope: prepared.scope, + context: prepared.context, + request: { + kind: 'list', + ...(effectiveSearch !== undefined ? { search: effectiveSearch } : {}), + ...(typeof pageParam === 'string' ? { cursor: pageParam } : {}), + }, + signal, + }) + if (result.kind !== 'list') return EMPTY_PAGE + return result + }, + getNextPageParam: (last) => last.nextCursor, + initialPageParam: undefined as string | undefined, + enabled: supportsPagination && prepared.ready, + staleTime: prepared.manifest.staleTime, + }) + + const pageCount = pagedQuery.data?.pages.length ?? 0 + const reachedDrainCap = pageCount >= MAX_AUTO_DRAIN_PAGES + useEffect(() => { + if (!supportsPagination || pagedQuery.isError || !pagedQuery.hasNextPage) return + if (reachedDrainCap) { + logger.warn('Selector hit auto-drain cap; option list is truncated', { + selectorKey: key, + pages: pageCount, + }) + return + } + if (!pagedQuery.isFetchingNextPage) void pagedQuery.fetchNextPage() + }, [ + key, + pageCount, + pagedQuery.fetchNextPage, + pagedQuery.hasNextPage, + pagedQuery.isError, + pagedQuery.isFetchingNextPage, + reachedDrainCap, + supportsPagination, + ]) + + const pagedOptions = useMemo( + () => pagedQuery.data?.pages.flatMap((page) => page.items), + [pagedQuery.data] + ) + if (supportsPagination) { + return { + data: pagedOptions, + isLoading: pagedQuery.isLoading, + isFetching: pagedQuery.isFetching, + isFetchingMore: pagedQuery.isFetchingNextPage, + hasMore: Boolean(pagedQuery.hasNextPage) && !reachedDrainCap, + truncated: Boolean(pagedQuery.hasNextPage) && reachedDrainCap, + error: (pagedQuery.error as Error | null) ?? null, + isSuccess: pagedQuery.isSuccess, + refetch: () => { + void pagedQuery.refetch() + }, + } + } + return { + data: flatQuery.data, + isLoading: flatQuery.isLoading, + isFetching: flatQuery.isFetching, + isFetchingMore: false, + hasMore: false, + truncated: false, + error: (flatQuery.error as Error | null) ?? null, + isSuccess: flatQuery.isSuccess, + refetch: () => { + void flatQuery.refetch() + }, + } +} + +export function useSelectorOptionDetail( + key: SelectorKey, + args: SelectorHookArgs & { detailId?: string } +) { + const prepared = usePreparedSelector(key, args, [args.detailId]) + const enabled = + prepared.ready && + prepared.manifest.supportsDetail && + args.enabled !== false && + Boolean(args.detailId) + return useQuery({ + // rq-lint-allow: the detail id and context are represented by an opaque privacy revision. + queryKey: selectorKeys.request( + key, + prepared.scope, + prepared.surfaceId, + 'detail', + prepared.revision + ), + queryFn: async ({ signal }) => { + const result = await executeSelectorRequest({ + selectorKey: key, + scope: prepared.scope, + context: prepared.context, + request: { kind: 'detail', id: args.detailId! }, + signal, + }) + if (result.kind !== 'detail') throw new Error('Selector returned an unexpected list result') + return result.item + }, + enabled, + staleTime: prepared.manifest.staleTime, + }) +} + +export function useSelectorOptionDetails( + key: SelectorKey, + args: SelectorHookArgs & { detailIds: string[] } +): SelectorOption[] { + const uniqueIds = useMemo(() => [...new Set(args.detailIds.filter(Boolean))], [args.detailIds]) + const prepared = usePreparedSelector(key, args, uniqueIds) + const results = useQueries({ + queries: uniqueIds.map((detailId, ordinal) => ({ + // rq-lint-allow: ids and context are represented by an opaque privacy revision and ordinal. + queryKey: selectorKeys.request( + key, + prepared.scope, + prepared.surfaceId, + 'detail', + prepared.revision, + ordinal + ), + queryFn: async ({ signal }: { signal: AbortSignal }) => { + const result = await executeSelectorRequest({ + selectorKey: key, + scope: prepared.scope, + context: prepared.context, + request: { kind: 'detail', id: detailId }, + signal, + }) + if (result.kind !== 'detail') throw new Error('Selector returned an unexpected list result') + return result.item + }, + enabled: prepared.ready && prepared.manifest.supportsDetail && args.enabled !== false, + staleTime: prepared.manifest.staleTime, + })), + }) + return useMemo(() => results.flatMap((result) => (result.data ? [result.data] : [])), [results]) +} + +export function useSelectorOptionMap(options: SelectorOption[], extra?: SelectorOption | null) { + return useMemo(() => { + const merged = + extra && !options.some((option) => option.id === extra.id) ? [extra, ...options] : options + return new Map(merged.map((option) => [option.id, option])) + }, [extra, options]) +} diff --git a/apps/sim/hooks/queries/utils/invalidate-workflow-lists.ts b/apps/sim/hooks/queries/utils/invalidate-workflow-lists.ts index f04e19aa9b8..8e04230f3b9 100644 --- a/apps/sim/hooks/queries/utils/invalidate-workflow-lists.ts +++ b/apps/sim/hooks/queries/utils/invalidate-workflow-lists.ts @@ -1,9 +1,9 @@ import type { QueryClient } from '@tanstack/react-query' +import { selectorKeys } from '@/hooks/queries/utils/selector-keys' import { type WorkflowQueryScope, workflowKeys } from '@/hooks/queries/utils/workflow-keys' -import { selectorKeys } from '@/hooks/selectors/query-keys' -export async function invalidateWorkflowSelectors(queryClient: QueryClient, workspaceId: string) { - await queryClient.invalidateQueries({ queryKey: selectorKeys.simWorkflowsPrefix(workspaceId) }) +export async function invalidateWorkflowSelectors(queryClient: QueryClient, _workspaceId: string) { + await queryClient.invalidateQueries({ queryKey: selectorKeys.all }) } /** diff --git a/apps/sim/hooks/queries/utils/selector-keys.ts b/apps/sim/hooks/queries/utils/selector-keys.ts new file mode 100644 index 00000000000..4cb3d60eb43 --- /dev/null +++ b/apps/sim/hooks/queries/utils/selector-keys.ts @@ -0,0 +1,28 @@ +import type { SelectorKey } from '@/lib/selectors/manifest' +import type { SelectorScope } from '@/lib/selectors/types' + +export const selectorKeys = { + all: ['selectors'] as const, + scoped: (selectorKey: SelectorKey, scope: SelectorScope | undefined, surfaceId: string) => + [ + ...selectorKeys.all, + selectorKey, + scope?.kind ?? 'local', + scope?.kind === 'workflow' ? scope.workflowId : (scope?.workspaceId ?? 'none'), + surfaceId, + ] as const, + request: ( + selectorKey: SelectorKey, + scope: SelectorScope | undefined, + surfaceId: string, + requestKind: 'list' | 'detail', + opaqueRevision: number, + ordinal?: number + ) => + [ + ...selectorKeys.scoped(selectorKey, scope, surfaceId), + requestKind, + opaqueRevision, + ...(ordinal === undefined ? [] : [ordinal]), + ] as const, +} diff --git a/apps/sim/hooks/queries/workflow-search-replace.test.ts b/apps/sim/hooks/queries/workflow-search-replace.test.ts index 3ff95f8616a..08e2f565f46 100644 --- a/apps/sim/hooks/queries/workflow-search-replace.test.ts +++ b/apps/sim/hooks/queries/workflow-search-replace.test.ts @@ -94,17 +94,13 @@ describe('workflowSearchReplaceKeys', () => { }) it('builds scoped selector replacement option keys', () => { - expect( - workflowSearchReplaceKeys.selectorReplacementOptions( - 'gmail.labels', - '{"oauthCredential":"credential-1","workspaceId":"workspace-1"}' - ) - ).toEqual([ + expect(workflowSearchReplaceKeys.selectorReplacementOptions('gmail.labels', 2, 4)).toEqual([ 'workflow-search-replace', 'replacement-options', 'selector', 'gmail.labels', - '{"oauthCredential":"credential-1","workspaceId":"workspace-1"}', + 2, + 4, ]) }) }) diff --git a/apps/sim/hooks/queries/workflow-search-replace.ts b/apps/sim/hooks/queries/workflow-search-replace.ts index ec1cda83ddf..72930944b19 100644 --- a/apps/sim/hooks/queries/workflow-search-replace.ts +++ b/apps/sim/hooks/queries/workflow-search-replace.ts @@ -1,4 +1,4 @@ -import { useMemo } from 'react' +import { useMemo, useRef } from 'react' import { useQueries, useQuery } from '@tanstack/react-query' import { requestJson } from '@/lib/api/client/request' import type { KnowledgeBaseData } from '@/lib/api/contracts/knowledge' @@ -21,9 +21,17 @@ import { import { createMcpToolId } from '@/lib/mcp/shared' import type { Credential } from '@/lib/oauth' import { - getWorkflowSearchMatchResourceGroupKey, - stableStringifyWorkflowSearchValue, -} from '@/lib/workflows/search-replace/resources' + executeSelectorRequest, + loadAllSelectorOptions, +} from '@/lib/selectors/client/execute-selector' +import { projectSelectorContext } from '@/lib/selectors/context' +import { + getSelectorManifestEntry, + isSelectorReady, + type SelectorKey, +} from '@/lib/selectors/manifest' +import type { SelectorOption, SelectorScope } from '@/lib/selectors/types' +import { getWorkflowSearchMatchResourceGroupKey } from '@/lib/workflows/search-replace/resources' import type { WorkflowSearchMatch, WorkflowSearchReplacementOption, @@ -35,12 +43,11 @@ import { fetchOAuthCredentials, } from '@/hooks/queries/oauth/oauth-credentials' import { collectDuplicateNames, disambiguateLabelByFolder } from '@/hooks/queries/utils/folder-tree' -import { getSelectorDefinition, loadAllSelectorOptions } from '@/hooks/selectors/registry' -import type { SelectorKey, SelectorOption } from '@/hooks/selectors/types' import type { WorkflowFolder } from '@/stores/folders/types' /** Stable identity while a folder list loads, so `select` isn't re-keyed on it. */ const EMPTY_FOLDER_MAP: Record = {} +let nextWorkflowSearchOpaqueRevision = 1 export interface WorkflowSearchResolvedResource { matchRawValue: string @@ -55,8 +62,8 @@ export const workflowSearchReplaceKeys = { resourceDetails: () => [...workflowSearchReplaceKeys.all, 'resource-detail'] as const, oauthDetails: (workflowId?: string) => [...workflowSearchReplaceKeys.resourceDetails(), 'oauth', workflowId ?? ''] as const, - oauthDetail: (credentialId?: string, workflowId?: string) => - [...workflowSearchReplaceKeys.oauthDetails(workflowId), credentialId ?? ''] as const, + oauthDetail: (workflowId?: string, ordinal?: number, revision?: number) => + [...workflowSearchReplaceKeys.oauthDetails(workflowId), ordinal ?? -1, revision ?? 0] as const, replacementOptions: () => [...workflowSearchReplaceKeys.all, 'replacement-options'] as const, oauthReplacementOptions: (providerId?: string, workspaceId?: string, workflowId?: string) => [ @@ -92,19 +99,20 @@ export const workflowSearchReplaceKeys = { knowledgeReplacementOptions: (workspaceId?: string) => [...workflowSearchReplaceKeys.replacementOptions(), 'knowledge', workspaceId ?? ''] as const, selectorDetails: () => [...workflowSearchReplaceKeys.resourceDetails(), 'selector'] as const, - selectorDetail: (selectorKey?: string, contextKey?: string, value?: string) => + selectorDetail: (selectorKey?: string, ordinal?: number, revision?: number) => [ ...workflowSearchReplaceKeys.selectorDetails(), selectorKey ?? '', - contextKey ?? '', - value ?? '', + ordinal ?? -1, + revision ?? 0, ] as const, - selectorReplacementOptions: (selectorKey?: string, contextKey?: string) => + selectorReplacementOptions: (selectorKey?: string, ordinal?: number, revision?: number) => [ ...workflowSearchReplaceKeys.replacementOptions(), 'selector', selectorKey ?? '', - contextKey ?? '', + ordinal ?? -1, + revision ?? 0, ] as const, } @@ -135,32 +143,108 @@ function uniqueMatches( }) } -function selectorContextKey(match: WorkflowSearchMatch): string { - return stableStringifyWorkflowSearchValue(match.resource?.selectorContext ?? {}) +function sameValues(left: readonly unknown[], right: readonly unknown[]): boolean { + return ( + left.length === right.length && left.every((value, index) => Object.is(value, right[index])) + ) +} + +function useOpaqueRevision(values: readonly unknown[]): number { + const state = useRef<{ values: readonly unknown[]; revision: number } | null>(null) + if (!state.current) { + state.current = { values, revision: nextWorkflowSearchOpaqueRevision++ } + } + if (!sameValues(state.current.values, values)) { + state.current = { values, revision: nextWorkflowSearchOpaqueRevision++ } + } + return state.current.revision +} + +function sameSelectorContext(left: WorkflowSearchMatch, right: WorkflowSearchMatch): boolean { + const leftContext = left.resource?.selectorContext ?? {} + const rightContext = right.resource?.selectorContext ?? {} + const leftKeys = Object.keys(leftContext) + const rightKeys = Object.keys(rightContext) + return ( + leftKeys.length === rightKeys.length && + leftKeys.every( + (key) => + Object.hasOwn(rightContext, key) && + Object.is( + leftContext[key as keyof typeof leftContext], + rightContext[key as keyof typeof rightContext] + ) + ) + ) +} + +function selectorRevisionValues( + matches: WorkflowSearchMatch[], + includeRawValue: boolean +): unknown[] { + const values: unknown[] = [] + for (const match of matches) { + values.push(match.kind, match.resource?.selectorKey) + if (includeRawValue) values.push(match.rawValue) + const context = match.resource?.selectorContext ?? {} + const fields = Object.keys(context).sort() + values.push(fields.length) + for (const field of fields) { + values.push(field, context[field as keyof typeof context]) + } + } + return values +} + +function getSelectorScope(match: WorkflowSearchMatch): SelectorScope | undefined { + const context = match.resource?.selectorContext + if (context?.workflowId) { + return { + kind: 'workflow', + workflowId: context.workflowId, + ...(context.workspaceId ? { workspaceId: context.workspaceId } : {}), + } + } + if (context?.workspaceId) return { kind: 'workspace', workspaceId: context.workspaceId } + return undefined } function uniqueSelectorDetailMatches(matches: WorkflowSearchMatch[]): WorkflowSearchMatch[] { - const seen = new Set() + const seen: WorkflowSearchMatch[] = [] return matches.filter((match) => { const selectorKey = match.resource?.selectorKey if (!selectorKey || !match.rawValue) return false - - const key = `${selectorKey}:${selectorContextKey(match)}:${match.rawValue}` - if (seen.has(key)) return false - seen.add(key) + if ( + seen.some( + (candidate) => + candidate.resource?.selectorKey === selectorKey && + candidate.rawValue === match.rawValue && + sameSelectorContext(candidate, match) + ) + ) { + return false + } + seen.push(match) return true }) } function uniqueSelectorOptionGroups(matches: WorkflowSearchMatch[]): WorkflowSearchMatch[] { - const seen = new Set() + const seen: WorkflowSearchMatch[] = [] return matches.filter((match) => { const selectorKey = match.resource?.selectorKey if (!selectorKey) return false - - const key = `${match.kind}:${selectorKey}:${selectorContextKey(match)}` - if (seen.has(key)) return false - seen.add(key) + if ( + seen.some( + (candidate) => + candidate.kind === match.kind && + candidate.resource?.selectorKey === selectorKey && + sameSelectorContext(candidate, match) + ) + ) { + return false + } + seen.push(match) return true }) } @@ -186,10 +270,11 @@ export function useWorkflowSearchOAuthCredentialDetails( workflowId?: string ) { const oauthMatches = useMemo(() => uniqueMatches(matches, 'oauth-credential'), [matches]) + const revision = useOpaqueRevision(oauthMatches.map((match) => match.rawValue)) return useQueries({ - queries: oauthMatches.map((match) => ({ - queryKey: workflowSearchReplaceKeys.oauthDetail(match.rawValue, workflowId), + queries: oauthMatches.map((match, ordinal) => ({ + queryKey: workflowSearchReplaceKeys.oauthDetail(workflowId, ordinal, revision), queryFn: ({ signal }: { signal: AbortSignal }) => fetchOAuthCredentialDetail(match.rawValue, workflowId, signal), enabled: Boolean(match.rawValue), @@ -378,32 +463,45 @@ export function useWorkflowSearchMcpToolDetails( export function useWorkflowSearchSelectorDetails(matches: WorkflowSearchMatch[]) { const selectorMatches = useMemo(() => uniqueSelectorDetailMatches(matches), [matches]) + const revision = useOpaqueRevision(selectorRevisionValues(selectorMatches, true)) return useQueries({ - queries: selectorMatches.map((match) => { + queries: selectorMatches.map((match, ordinal) => { const selectorKey = match.resource?.selectorKey as SelectorKey - const context = match.resource?.selectorContext ?? {} - const contextKey = selectorContextKey(match) - const definition = getSelectorDefinition(selectorKey) - const queryArgs = { key: selectorKey, context, detailId: match.rawValue } - const baseEnabled = definition.enabled ? definition.enabled(queryArgs) : true + const context = projectSelectorContext(selectorKey, match.resource?.selectorContext ?? {}) + const scope = getSelectorScope(match) + const manifest = getSelectorManifestEntry(selectorKey) + const baseEnabled = isSelectorReady(selectorKey, context) return { - queryKey: workflowSearchReplaceKeys.selectorDetail(selectorKey, contextKey, match.rawValue), + queryKey: workflowSearchReplaceKeys.selectorDetail(selectorKey, ordinal, revision), queryFn: async ({ signal }: { signal: AbortSignal }): Promise => { - if (definition.fetchById) { - return definition.fetchById({ ...queryArgs, signal }) + if (manifest.supportsDetail) { + const result = await executeSelectorRequest({ + selectorKey, + scope, + context, + request: { kind: 'detail', id: match.rawValue }, + signal, + }) + return result.kind === 'detail' ? result.item : null } - const options = await loadAllSelectorOptions(definition, { - key: selectorKey, + const options = await loadAllSelectorOptions({ + selectorKey, + scope, context, signal, }) return options.find((option) => option.id === match.rawValue) ?? null }, - enabled: Boolean(selectorKey && match.rawValue && baseEnabled), - staleTime: definition.staleTime ?? WORKFLOW_SEARCH_SELECTOR_DETAIL_STALE_TIME, + enabled: Boolean( + selectorKey && + match.rawValue && + baseEnabled && + (manifest.classification === 'local' || scope) + ), + staleTime: manifest.staleTime ?? WORKFLOW_SEARCH_SELECTOR_DETAIL_STALE_TIME, select: (option: SelectorOption | null): WorkflowSearchResolvedResource => ({ matchRawValue: match.rawValue, resourceGroupKey: match.resource?.resourceGroupKey, @@ -652,22 +750,28 @@ export function useWorkflowSearchMcpToolReplacementOptions( export function useWorkflowSearchSelectorReplacementOptions(matches: WorkflowSearchMatch[]) { const selectorGroups = useMemo(() => uniqueSelectorOptionGroups(matches), [matches]) + const revision = useOpaqueRevision(selectorRevisionValues(selectorGroups, false)) return useQueries({ - queries: selectorGroups.map((match) => { + queries: selectorGroups.map((match, ordinal) => { const selectorKey = match.resource?.selectorKey as SelectorKey - const context = match.resource?.selectorContext ?? {} - const contextKey = selectorContextKey(match) - const definition = getSelectorDefinition(selectorKey) - const queryArgs = { key: selectorKey, context } - const baseEnabled = definition.enabled ? definition.enabled(queryArgs) : true + const context = projectSelectorContext(selectorKey, match.resource?.selectorContext ?? {}) + const scope = getSelectorScope(match) + const manifest = getSelectorManifestEntry(selectorKey) + const baseEnabled = isSelectorReady(selectorKey, context) return { - queryKey: workflowSearchReplaceKeys.selectorReplacementOptions(selectorKey, contextKey), + queryKey: workflowSearchReplaceKeys.selectorReplacementOptions( + selectorKey, + ordinal, + revision + ), queryFn: ({ signal }: { signal: AbortSignal }) => - loadAllSelectorOptions(definition, { ...queryArgs, signal }), - enabled: Boolean(selectorKey && baseEnabled), - staleTime: definition.staleTime ?? WORKFLOW_SEARCH_SELECTOR_REPLACEMENT_STALE_TIME, + loadAllSelectorOptions({ selectorKey, scope, context, signal }), + enabled: Boolean( + selectorKey && baseEnabled && (manifest.classification === 'local' || scope) + ), + staleTime: manifest.staleTime ?? WORKFLOW_SEARCH_SELECTOR_REPLACEMENT_STALE_TIME, select: (options: SelectorOption[]): WorkflowSearchReplacementOption[] => options.map((option) => ({ kind: match.kind, diff --git a/apps/sim/hooks/selectors/helpers.ts b/apps/sim/hooks/selectors/helpers.ts deleted file mode 100644 index 47a2c068d13..00000000000 --- a/apps/sim/hooks/selectors/helpers.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { requestJson } from '@/lib/api/client/request' -import { oauthTokenContract } from '@/lib/api/contracts/selectors' - -export interface OAuthTokenBundle { - accessToken: string - cloudId?: string - domain?: string - apiDomain?: string -} - -/** - * Returns the access token plus any provider-specific extras (e.g. `cloudId` for - * Atlassian service accounts whose tokens cannot call api.atlassian.com/oauth/token/accessible-resources). - */ -export async function fetchOAuthToken( - credentialId: string, - workflowId?: string -): Promise { - if (!credentialId) return null - const token = await requestJson(oauthTokenContract, { - body: { credentialId, workflowId }, - }) - if (!token.accessToken) return null - return { - accessToken: token.accessToken, - cloudId: token.cloudId, - domain: token.domain, - apiDomain: token.apiDomain, - } -} diff --git a/apps/sim/hooks/selectors/providers/airtable/selectors.ts b/apps/sim/hooks/selectors/providers/airtable/selectors.ts deleted file mode 100644 index d8456280960..00000000000 --- a/apps/sim/hooks/selectors/providers/airtable/selectors.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { requestJson } from '@/lib/api/client/request' -import * as selectorContracts from '@/lib/api/contracts/selectors' -import { ensureCredential, SELECTOR_STALE } from '@/hooks/selectors/providers/shared' -import type { SelectorDefinition, SelectorKey, SelectorQueryArgs } from '@/hooks/selectors/types' - -export const airtableSelectors = { - 'airtable.bases': { - key: 'airtable.bases', - contracts: [selectorContracts.airtableBasesSelectorContract], - staleTime: SELECTOR_STALE, - getQueryKey: ({ context }: SelectorQueryArgs) => [ - 'selectors', - 'airtable.bases', - context.oauthCredential ?? 'none', - ], - enabled: ({ context }) => Boolean(context.oauthCredential), - fetchList: async ({ context, signal }: SelectorQueryArgs) => { - const credentialId = ensureCredential(context, 'airtable.bases') - const data = await requestJson(selectorContracts.airtableBasesSelectorContract, { - body: { credential: credentialId, workflowId: context.workflowId }, - signal, - }) - return (data.bases || []).map((base) => ({ - id: base.id, - label: base.name, - })) - }, - fetchById: async ({ context, detailId, signal }: SelectorQueryArgs) => { - if (!detailId) return null - const credentialId = ensureCredential(context, 'airtable.bases') - const data = await requestJson(selectorContracts.airtableBasesSelectorContract, { - body: { - credential: credentialId, - workflowId: context.workflowId, - baseId: detailId, - }, - signal, - }) - const base = (data.bases || []).find((b) => b.id === detailId) ?? null - if (!base) return null - return { id: base.id, label: base.name } - }, - }, - 'airtable.tables': { - key: 'airtable.tables', - contracts: [selectorContracts.airtableTablesSelectorContract], - staleTime: SELECTOR_STALE, - getQueryKey: ({ context }: SelectorQueryArgs) => [ - 'selectors', - 'airtable.tables', - context.oauthCredential ?? 'none', - context.baseId ?? 'none', - ], - enabled: ({ context }) => Boolean(context.oauthCredential && context.baseId), - fetchList: async ({ context, signal }: SelectorQueryArgs) => { - const credentialId = ensureCredential(context, 'airtable.tables') - if (!context.baseId) { - throw new Error('Missing base ID for airtable.tables selector') - } - const data = await requestJson(selectorContracts.airtableTablesSelectorContract, { - body: { - credential: credentialId, - workflowId: context.workflowId, - baseId: context.baseId, - }, - signal, - }) - return (data.tables || []).map((table) => ({ - id: table.id, - label: table.name, - })) - }, - fetchById: async ({ context, detailId, signal }: SelectorQueryArgs) => { - if (!detailId) return null - const credentialId = ensureCredential(context, 'airtable.tables') - if (!context.baseId) return null - const data = await requestJson(selectorContracts.airtableTablesSelectorContract, { - body: { - credential: credentialId, - workflowId: context.workflowId, - baseId: context.baseId, - }, - signal, - }) - const table = (data.tables || []).find((t) => t.id === detailId) ?? null - if (!table) return null - return { id: table.id, label: table.name } - }, - }, -} satisfies Record, SelectorDefinition> diff --git a/apps/sim/hooks/selectors/providers/asana/selectors.ts b/apps/sim/hooks/selectors/providers/asana/selectors.ts deleted file mode 100644 index 2c43d3bf49b..00000000000 --- a/apps/sim/hooks/selectors/providers/asana/selectors.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { requestJson } from '@/lib/api/client/request' -import * as selectorContracts from '@/lib/api/contracts/selectors' -import { ensureCredential, SELECTOR_STALE } from '@/hooks/selectors/providers/shared' -import type { SelectorDefinition, SelectorKey, SelectorQueryArgs } from '@/hooks/selectors/types' - -export const asanaSelectors = { - 'asana.workspaces': { - key: 'asana.workspaces', - contracts: [selectorContracts.asanaWorkspacesSelectorContract], - staleTime: SELECTOR_STALE, - getQueryKey: ({ context }: SelectorQueryArgs) => [ - 'selectors', - 'asana.workspaces', - context.oauthCredential ?? 'none', - ], - enabled: ({ context }) => Boolean(context.oauthCredential), - fetchList: async ({ context, signal }: SelectorQueryArgs) => { - const credentialId = ensureCredential(context, 'asana.workspaces') - const data = await requestJson(selectorContracts.asanaWorkspacesSelectorContract, { - body: { credential: credentialId, workflowId: context.workflowId }, - signal, - }) - return (data.workspaces || []).map((ws) => ({ id: ws.id, label: ws.name })) - }, - fetchById: async ({ context, detailId, signal }: SelectorQueryArgs) => { - if (!detailId) return null - const credentialId = ensureCredential(context, 'asana.workspaces') - const data = await requestJson(selectorContracts.asanaWorkspacesSelectorContract, { - body: { credential: credentialId, workflowId: context.workflowId }, - signal, - }) - const ws = (data.workspaces || []).find((w) => w.id === detailId) ?? null - if (!ws) return null - return { id: ws.id, label: ws.name } - }, - }, -} satisfies Record, SelectorDefinition> diff --git a/apps/sim/hooks/selectors/providers/attio/selectors.ts b/apps/sim/hooks/selectors/providers/attio/selectors.ts deleted file mode 100644 index ef2fc6c45f9..00000000000 --- a/apps/sim/hooks/selectors/providers/attio/selectors.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { requestJson } from '@/lib/api/client/request' -import * as selectorContracts from '@/lib/api/contracts/selectors' -import { ensureCredential, SELECTOR_STALE } from '@/hooks/selectors/providers/shared' -import type { SelectorDefinition, SelectorKey, SelectorQueryArgs } from '@/hooks/selectors/types' - -export const attioSelectors = { - 'attio.objects': { - key: 'attio.objects', - contracts: [selectorContracts.attioObjectsSelectorContract], - staleTime: SELECTOR_STALE, - getQueryKey: ({ context }: SelectorQueryArgs) => [ - 'selectors', - 'attio.objects', - context.oauthCredential ?? 'none', - ], - enabled: ({ context }) => Boolean(context.oauthCredential), - fetchList: async ({ context, signal }: SelectorQueryArgs) => { - const credentialId = ensureCredential(context, 'attio.objects') - const data = await requestJson(selectorContracts.attioObjectsSelectorContract, { - body: { credential: credentialId, workflowId: context.workflowId }, - signal, - }) - return (data.objects || []).map((obj) => ({ - id: obj.id, - label: obj.name, - })) - }, - fetchById: async ({ context, detailId, signal }: SelectorQueryArgs) => { - if (!detailId) return null - const credentialId = ensureCredential(context, 'attio.objects') - const data = await requestJson(selectorContracts.attioObjectsSelectorContract, { - body: { credential: credentialId, workflowId: context.workflowId }, - signal, - }) - const obj = (data.objects || []).find((o) => o.id === detailId) ?? null - if (!obj) return null - return { id: obj.id, label: obj.name } - }, - }, - 'attio.lists': { - key: 'attio.lists', - contracts: [selectorContracts.attioListsSelectorContract], - staleTime: SELECTOR_STALE, - getQueryKey: ({ context }: SelectorQueryArgs) => [ - 'selectors', - 'attio.lists', - context.oauthCredential ?? 'none', - ], - enabled: ({ context }) => Boolean(context.oauthCredential), - fetchList: async ({ context, signal }: SelectorQueryArgs) => { - const credentialId = ensureCredential(context, 'attio.lists') - const data = await requestJson(selectorContracts.attioListsSelectorContract, { - body: { credential: credentialId, workflowId: context.workflowId }, - signal, - }) - return (data.lists || []).map((list) => ({ - id: list.id, - label: list.name, - })) - }, - fetchById: async ({ context, detailId, signal }: SelectorQueryArgs) => { - if (!detailId) return null - const credentialId = ensureCredential(context, 'attio.lists') - const data = await requestJson(selectorContracts.attioListsSelectorContract, { - body: { credential: credentialId, workflowId: context.workflowId }, - signal, - }) - const list = (data.lists || []).find((l) => l.id === detailId) ?? null - if (!list) return null - return { id: list.id, label: list.name } - }, - }, -} satisfies Record, SelectorDefinition> diff --git a/apps/sim/hooks/selectors/providers/bigquery/selectors.ts b/apps/sim/hooks/selectors/providers/bigquery/selectors.ts deleted file mode 100644 index 95cf0b020cf..00000000000 --- a/apps/sim/hooks/selectors/providers/bigquery/selectors.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { requestJson } from '@/lib/api/client/request' -import * as selectorContracts from '@/lib/api/contracts/selectors' -import { ensureCredential, SELECTOR_STALE } from '@/hooks/selectors/providers/shared' -import type { SelectorDefinition, SelectorKey, SelectorQueryArgs } from '@/hooks/selectors/types' - -export const bigquerySelectors = { - 'bigquery.datasets': { - key: 'bigquery.datasets', - contracts: [selectorContracts.bigQueryDatasetsSelectorContract], - staleTime: SELECTOR_STALE, - getQueryKey: ({ context }: SelectorQueryArgs) => [ - 'selectors', - 'bigquery.datasets', - context.oauthCredential ?? 'none', - context.projectId ?? 'none', - context.impersonateUserEmail ?? 'none', - ], - enabled: ({ context }) => Boolean(context.oauthCredential && context.projectId), - fetchList: async ({ context, signal }: SelectorQueryArgs) => { - const credentialId = ensureCredential(context, 'bigquery.datasets') - if (!context.projectId) throw new Error('Missing project ID for bigquery.datasets selector') - const data = await requestJson(selectorContracts.bigQueryDatasetsSelectorContract, { - body: { - credential: credentialId, - workflowId: context.workflowId, - projectId: context.projectId, - impersonateEmail: context.impersonateUserEmail, - }, - signal, - }) - return (data.datasets || []).map((ds) => ({ - id: ds.datasetReference.datasetId, - label: ds.friendlyName || ds.datasetReference.datasetId, - })) - }, - fetchById: async ({ context, detailId, signal }: SelectorQueryArgs) => { - if (!detailId || !context.projectId) return null - const credentialId = ensureCredential(context, 'bigquery.datasets') - const data = await requestJson(selectorContracts.bigQueryDatasetsSelectorContract, { - body: { - credential: credentialId, - workflowId: context.workflowId, - projectId: context.projectId, - impersonateEmail: context.impersonateUserEmail, - }, - signal, - }) - const ds = - (data.datasets || []).find((d) => d.datasetReference.datasetId === detailId) ?? null - if (!ds) return null - return { - id: ds.datasetReference.datasetId, - label: ds.friendlyName || ds.datasetReference.datasetId, - } - }, - }, - 'bigquery.tables': { - key: 'bigquery.tables', - contracts: [selectorContracts.bigQueryTablesSelectorContract], - staleTime: SELECTOR_STALE, - getQueryKey: ({ context }: SelectorQueryArgs) => [ - 'selectors', - 'bigquery.tables', - context.oauthCredential ?? 'none', - context.projectId ?? 'none', - context.datasetId ?? 'none', - context.impersonateUserEmail ?? 'none', - ], - enabled: ({ context }) => - Boolean(context.oauthCredential && context.projectId && context.datasetId), - fetchList: async ({ context, signal }: SelectorQueryArgs) => { - const credentialId = ensureCredential(context, 'bigquery.tables') - if (!context.projectId) throw new Error('Missing project ID for bigquery.tables selector') - if (!context.datasetId) throw new Error('Missing dataset ID for bigquery.tables selector') - const data = await requestJson(selectorContracts.bigQueryTablesSelectorContract, { - body: { - credential: credentialId, - workflowId: context.workflowId, - projectId: context.projectId, - datasetId: context.datasetId, - impersonateEmail: context.impersonateUserEmail, - }, - signal, - }) - return (data.tables || []).map((t) => ({ - id: t.tableReference.tableId, - label: t.friendlyName || t.tableReference.tableId, - })) - }, - fetchById: async ({ context, detailId, signal }: SelectorQueryArgs) => { - if (!detailId || !context.projectId || !context.datasetId) return null - const credentialId = ensureCredential(context, 'bigquery.tables') - const data = await requestJson(selectorContracts.bigQueryTablesSelectorContract, { - body: { - credential: credentialId, - workflowId: context.workflowId, - projectId: context.projectId, - datasetId: context.datasetId, - impersonateEmail: context.impersonateUserEmail, - }, - signal, - }) - const t = (data.tables || []).find((tbl) => tbl.tableReference.tableId === detailId) ?? null - if (!t) return null - return { id: t.tableReference.tableId, label: t.friendlyName || t.tableReference.tableId } - }, - }, -} satisfies Record< - Extract, - SelectorDefinition -> diff --git a/apps/sim/hooks/selectors/providers/bitbucket/selectors.test.ts b/apps/sim/hooks/selectors/providers/bitbucket/selectors.test.ts deleted file mode 100644 index 5219819b0e2..00000000000 --- a/apps/sim/hooks/selectors/providers/bitbucket/selectors.test.ts +++ /dev/null @@ -1,213 +0,0 @@ -/** - * @vitest-environment node - */ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { mockRequestJson } = vi.hoisted(() => ({ mockRequestJson: vi.fn() })) - -vi.mock('@/lib/api/client/request', () => ({ requestJson: mockRequestJson })) - -import { getSelectorDefinition } from '@/hooks/selectors/registry' -import type { SelectorQueryArgs } from '@/hooks/selectors/types' - -const workspaces = getSelectorDefinition('bitbucket.workspaces') -const repositories = getSelectorDefinition('bitbucket.repositories') - -const workspaceArgs = ( - overrides: Partial = {} -): SelectorQueryArgs => ({ - key: 'bitbucket.workspaces', - context: { oauthCredential: 'credential-1', workflowId: 'workflow-1', ...overrides }, -}) - -const repositoryArgs = ( - overrides: Partial = {} -): SelectorQueryArgs => ({ - key: 'bitbucket.repositories', - context: { - oauthCredential: 'credential-1', - workflowId: 'workflow-1', - workspaceSlug: 'acme-platform', - ...overrides, - }, -}) - -describe('bitbucket.workspaces selector', () => { - beforeEach(() => vi.clearAllMocks()) - - it('is enabled only after a credential is selected and isolates each authorization context', () => { - expect(workspaces.enabled?.(workspaceArgs())).toBe(true) - expect(workspaces.enabled?.(workspaceArgs({ oauthCredential: undefined }))).toBe(false) - expect(workspaces.getQueryKey(workspaceArgs())).toEqual([ - 'selectors', - 'bitbucket.workspaces', - 'credential-1', - 'workflow-1', - ]) - expect(workspaces.getQueryKey(workspaceArgs({ oauthCredential: 'credential-2' }))).toEqual([ - 'selectors', - 'bitbucket.workspaces', - 'credential-2', - 'workflow-1', - ]) - expect(workspaces.getQueryKey(workspaceArgs({ workflowId: 'workflow-2' }))).toEqual([ - 'selectors', - 'bitbucket.workspaces', - 'credential-1', - 'workflow-2', - ]) - }) - - it('loads one page using a credential id and preserves provider metadata', async () => { - const nextCursor = 'https://api.bitbucket.org/2.0/user/workspaces?page=2&pagelen=100' - mockRequestJson.mockResolvedValue({ - workspaces: [ - { - slug: 'acme-platform', - uuid: '{workspace-uuid}', - name: 'Acme Platform', - administrator: true, - }, - ], - nextCursor, - }) - - const page = await workspaces.fetchPage?.({ - ...workspaceArgs(), - cursor: 'https://api.bitbucket.org/2.0/user/workspaces?page=1&pagelen=100', - }) - - expect(mockRequestJson).toHaveBeenCalledWith( - expect.objectContaining({ path: '/api/tools/bitbucket/workspaces' }), - expect.objectContaining({ - body: { - credential: 'credential-1', - workflowId: 'workflow-1', - cursor: 'https://api.bitbucket.org/2.0/user/workspaces?page=1&pagelen=100', - }, - }) - ) - expect(page).toEqual({ - items: [ - { - id: 'acme-platform', - label: 'Acme Platform', - meta: { - slug: 'acme-platform', - uuid: '{workspace-uuid}', - fullName: 'Acme Platform', - administrator: true, - }, - }, - ], - nextCursor, - }) - }) - - it('rejects a missing credential before making a route request', async () => { - await expect( - workspaces.fetchPage?.(workspaceArgs({ oauthCredential: undefined })) - ).rejects.toThrow(/Missing credential/) - expect(mockRequestJson).not.toHaveBeenCalled() - }) -}) - -describe('bitbucket.repositories selector', () => { - beforeEach(() => vi.clearAllMocks()) - - it('waits for both dependencies and isolates pages by auth context and workspace', () => { - expect(repositories.enabled?.(repositoryArgs())).toBe(true) - expect(repositories.enabled?.(repositoryArgs({ oauthCredential: undefined }))).toBe(false) - expect(repositories.enabled?.(repositoryArgs({ workspaceSlug: undefined }))).toBe(false) - - expect(repositories.getQueryKey(repositoryArgs())).toEqual([ - 'selectors', - 'bitbucket.repositories', - 'credential-1', - 'workflow-1', - 'acme-platform', - ]) - expect(repositories.getQueryKey(repositoryArgs({ workspaceSlug: 'other-team' }))).toEqual([ - 'selectors', - 'bitbucket.repositories', - 'credential-1', - 'workflow-1', - 'other-team', - ]) - expect(repositories.getQueryKey(repositoryArgs({ oauthCredential: 'credential-2' }))).toEqual([ - 'selectors', - 'bitbucket.repositories', - 'credential-2', - 'workflow-1', - 'acme-platform', - ]) - expect(repositories.getQueryKey(repositoryArgs({ workflowId: 'workflow-2' }))).toEqual([ - 'selectors', - 'bitbucket.repositories', - 'credential-1', - 'workflow-2', - 'acme-platform', - ]) - }) - - it('keeps the workspace dependency on every page and maps slug ids with UUID/full-name metadata', async () => { - const nextCursor = 'https://api.bitbucket.org/2.0/repositories/acme-platform?page=3&pagelen=100' - mockRequestJson.mockResolvedValue({ - repositories: [ - { - slug: 'payments-api', - uuid: '{repository-uuid}', - name: 'Payments API', - fullName: 'acme-platform/payments-api', - }, - ], - nextCursor, - }) - - const page = await repositories.fetchPage?.({ - ...repositoryArgs(), - cursor: 'https://api.bitbucket.org/2.0/repositories/acme-platform?page=2&pagelen=100', - }) - - expect(mockRequestJson).toHaveBeenCalledWith( - expect.objectContaining({ path: '/api/tools/bitbucket/repositories' }), - expect.objectContaining({ - body: { - credential: 'credential-1', - workflowId: 'workflow-1', - workspaceSlug: 'acme-platform', - cursor: 'https://api.bitbucket.org/2.0/repositories/acme-platform?page=2&pagelen=100', - }, - }) - ) - expect(page).toEqual({ - items: [ - { - id: 'payments-api', - label: 'Payments API', - meta: { - slug: 'payments-api', - uuid: '{repository-uuid}', - fullName: 'acme-platform/payments-api', - workspaceSlug: 'acme-platform', - }, - }, - ], - nextCursor, - }) - }) - - it('rejects a missing workspace dependency instead of issuing an unscoped request', async () => { - await expect( - repositories.fetchPage?.(repositoryArgs({ workspaceSlug: undefined })) - ).rejects.toThrow(/Missing workspace slug/) - expect(mockRequestJson).not.toHaveBeenCalled() - }) - - it('rejects a missing credential before making a route request', async () => { - await expect( - repositories.fetchPage?.(repositoryArgs({ oauthCredential: undefined })) - ).rejects.toThrow(/Missing credential/) - expect(mockRequestJson).not.toHaveBeenCalled() - }) -}) diff --git a/apps/sim/hooks/selectors/providers/bitbucket/selectors.ts b/apps/sim/hooks/selectors/providers/bitbucket/selectors.ts deleted file mode 100644 index 63dab44c923..00000000000 --- a/apps/sim/hooks/selectors/providers/bitbucket/selectors.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { requestJson } from '@/lib/api/client/request' -import * as selectorContracts from '@/lib/api/contracts/selectors' -import { ensureCredential, SELECTOR_STALE } from '@/hooks/selectors/providers/shared' -import type { SelectorDefinition, SelectorKey, SelectorQueryArgs } from '@/hooks/selectors/types' - -export const bitbucketSelectors = { - 'bitbucket.workspaces': { - key: 'bitbucket.workspaces', - contracts: [selectorContracts.bitbucketWorkspacesSelectorContract], - staleTime: SELECTOR_STALE, - getQueryKey: ({ context }: SelectorQueryArgs) => [ - 'selectors', - 'bitbucket.workspaces', - context.oauthCredential ?? 'none', - context.workflowId ?? 'none', - ], - enabled: ({ context }) => Boolean(context.oauthCredential), - /** Loads one Bitbucket page so the shared selector hook can progressively drain it. */ - fetchPage: async ({ context, cursor, signal }) => { - const credentialId = ensureCredential(context, 'bitbucket.workspaces') - const data = await requestJson(selectorContracts.bitbucketWorkspacesSelectorContract, { - body: { - credential: credentialId, - workflowId: context.workflowId, - cursor, - }, - signal, - }) - - return { - items: data.workspaces.map((workspace) => ({ - id: workspace.slug, - label: workspace.name, - meta: { - slug: workspace.slug, - uuid: workspace.uuid, - fullName: workspace.name, - administrator: workspace.administrator, - }, - })), - nextCursor: data.nextCursor, - } - }, - }, - 'bitbucket.repositories': { - key: 'bitbucket.repositories', - contracts: [selectorContracts.bitbucketRepositoriesSelectorContract], - staleTime: SELECTOR_STALE, - getQueryKey: ({ context }: SelectorQueryArgs) => [ - 'selectors', - 'bitbucket.repositories', - context.oauthCredential ?? 'none', - context.workflowId ?? 'none', - context.workspaceSlug ?? 'none', - ], - enabled: ({ context }) => Boolean(context.oauthCredential && context.workspaceSlug), - /** - * Repository discovery is scoped to the selected workspace on every page; - * no unscoped request is sent while the dependency is absent. - */ - fetchPage: async ({ context, cursor, signal }) => { - const credentialId = ensureCredential(context, 'bitbucket.repositories') - if (!context.workspaceSlug) { - throw new Error('Missing workspace slug for bitbucket.repositories selector') - } - - const data = await requestJson(selectorContracts.bitbucketRepositoriesSelectorContract, { - body: { - credential: credentialId, - workflowId: context.workflowId, - workspaceSlug: context.workspaceSlug, - cursor, - }, - signal, - }) - - return { - items: data.repositories.map((repository) => ({ - id: repository.slug, - label: repository.name, - meta: { - slug: repository.slug, - uuid: repository.uuid, - fullName: repository.fullName, - workspaceSlug: context.workspaceSlug, - }, - })), - nextCursor: data.nextCursor, - } - }, - }, -} satisfies Record< - Extract, - SelectorDefinition -> diff --git a/apps/sim/hooks/selectors/providers/calcom/selectors.ts b/apps/sim/hooks/selectors/providers/calcom/selectors.ts deleted file mode 100644 index db6c66c918c..00000000000 --- a/apps/sim/hooks/selectors/providers/calcom/selectors.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { requestJson } from '@/lib/api/client/request' -import * as selectorContracts from '@/lib/api/contracts/selectors' -import { ensureCredential, SELECTOR_STALE } from '@/hooks/selectors/providers/shared' -import type { SelectorDefinition, SelectorKey, SelectorQueryArgs } from '@/hooks/selectors/types' - -export const calcomSelectors = { - 'calcom.eventTypes': { - key: 'calcom.eventTypes', - contracts: [selectorContracts.calcomEventTypesSelectorContract], - staleTime: SELECTOR_STALE, - getQueryKey: ({ context }: SelectorQueryArgs) => [ - 'selectors', - 'calcom.eventTypes', - context.oauthCredential ?? 'none', - ], - enabled: ({ context }) => Boolean(context.oauthCredential), - fetchList: async ({ context, signal }: SelectorQueryArgs) => { - const credentialId = ensureCredential(context, 'calcom.eventTypes') - const data = await requestJson(selectorContracts.calcomEventTypesSelectorContract, { - body: { credential: credentialId, workflowId: context.workflowId }, - signal, - }) - return (data.eventTypes || []).map((et) => ({ - id: et.id, - label: et.title || et.slug, - })) - }, - fetchById: async ({ context, detailId, signal }: SelectorQueryArgs) => { - if (!detailId) return null - const credentialId = ensureCredential(context, 'calcom.eventTypes') - const data = await requestJson(selectorContracts.calcomEventTypesSelectorContract, { - body: { credential: credentialId, workflowId: context.workflowId }, - signal, - }) - const et = (data.eventTypes || []).find((e) => e.id === detailId) ?? null - if (!et) return null - return { id: et.id, label: et.title || et.slug } - }, - }, - 'calcom.schedules': { - key: 'calcom.schedules', - contracts: [selectorContracts.calcomSchedulesSelectorContract], - staleTime: SELECTOR_STALE, - getQueryKey: ({ context }: SelectorQueryArgs) => [ - 'selectors', - 'calcom.schedules', - context.oauthCredential ?? 'none', - ], - enabled: ({ context }) => Boolean(context.oauthCredential), - fetchList: async ({ context, signal }: SelectorQueryArgs) => { - const credentialId = ensureCredential(context, 'calcom.schedules') - const data = await requestJson(selectorContracts.calcomSchedulesSelectorContract, { - body: { credential: credentialId, workflowId: context.workflowId }, - signal, - }) - return (data.schedules || []).map((s) => ({ - id: s.id, - label: s.name, - })) - }, - fetchById: async ({ context, detailId, signal }: SelectorQueryArgs) => { - if (!detailId) return null - const credentialId = ensureCredential(context, 'calcom.schedules') - const data = await requestJson(selectorContracts.calcomSchedulesSelectorContract, { - body: { credential: credentialId, workflowId: context.workflowId }, - signal, - }) - const s = (data.schedules || []).find((sc) => sc.id === detailId) ?? null - if (!s) return null - return { id: s.id, label: s.name } - }, - }, -} satisfies Record< - Extract, - SelectorDefinition -> diff --git a/apps/sim/hooks/selectors/providers/clickup/selectors.ts b/apps/sim/hooks/selectors/providers/clickup/selectors.ts deleted file mode 100644 index 87cf96ec094..00000000000 --- a/apps/sim/hooks/selectors/providers/clickup/selectors.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { requestJson } from '@/lib/api/client/request' -import * as selectorContracts from '@/lib/api/contracts/selectors' -import { ensureCredential, SELECTOR_STALE } from '@/hooks/selectors/providers/shared' -import type { SelectorDefinition, SelectorKey, SelectorQueryArgs } from '@/hooks/selectors/types' - -export const clickupSelectors = { - 'clickup.workspaces': { - key: 'clickup.workspaces', - contracts: [selectorContracts.clickupWorkspacesSelectorContract], - staleTime: SELECTOR_STALE, - getQueryKey: ({ context }: SelectorQueryArgs) => [ - 'selectors', - 'clickup.workspaces', - context.oauthCredential ?? 'none', - ], - enabled: ({ context }) => Boolean(context.oauthCredential), - fetchList: async ({ context, signal }: SelectorQueryArgs) => { - const credentialId = ensureCredential(context, 'clickup.workspaces') - const data = await requestJson(selectorContracts.clickupWorkspacesSelectorContract, { - body: { credential: credentialId, workflowId: context.workflowId }, - signal, - }) - return (data.workspaces || []).map((workspace) => ({ - id: workspace.id, - label: workspace.name, - })) - }, - }, - 'clickup.spaces': { - key: 'clickup.spaces', - contracts: [selectorContracts.clickupSpacesSelectorContract], - staleTime: SELECTOR_STALE, - getQueryKey: ({ context }: SelectorQueryArgs) => [ - 'selectors', - 'clickup.spaces', - context.oauthCredential ?? 'none', - context.teamId ?? 'none', - ], - enabled: ({ context }) => Boolean(context.oauthCredential && context.teamId), - fetchList: async ({ context, signal }: SelectorQueryArgs) => { - const credentialId = ensureCredential(context, 'clickup.spaces') - if (!context.teamId) { - throw new Error('Missing workspace (team) ID for clickup.spaces selector') - } - const data = await requestJson(selectorContracts.clickupSpacesSelectorContract, { - body: { - credential: credentialId, - teamId: context.teamId, - workflowId: context.workflowId, - }, - signal, - }) - return (data.spaces || []).map((space) => ({ - id: space.id, - label: space.name, - })) - }, - }, - 'clickup.folders': { - key: 'clickup.folders', - contracts: [selectorContracts.clickupFoldersSelectorContract], - staleTime: SELECTOR_STALE, - getQueryKey: ({ context }: SelectorQueryArgs) => [ - 'selectors', - 'clickup.folders', - context.oauthCredential ?? 'none', - context.spaceId ?? context.listSpaceId ?? 'none', - ], - enabled: ({ context }) => - Boolean(context.oauthCredential && (context.spaceId || context.listSpaceId)), - fetchList: async ({ context, signal }: SelectorQueryArgs) => { - const credentialId = ensureCredential(context, 'clickup.folders') - const spaceId = context.spaceId || context.listSpaceId - if (!spaceId) { - throw new Error('Missing space ID for clickup.folders selector') - } - const data = await requestJson(selectorContracts.clickupFoldersSelectorContract, { - body: { - credential: credentialId, - spaceId, - workflowId: context.workflowId, - }, - signal, - }) - return (data.folders || []).map((folder) => ({ - id: folder.id, - label: folder.name, - })) - }, - }, - 'clickup.lists': { - key: 'clickup.lists', - contracts: [selectorContracts.clickupListsSelectorContract], - staleTime: SELECTOR_STALE, - getQueryKey: ({ context }: SelectorQueryArgs) => [ - 'selectors', - 'clickup.lists', - context.oauthCredential ?? 'none', - context.spaceId ?? context.listSpaceId ?? 'none', - context.folderId ?? 'none', - ], - enabled: ({ context }) => - Boolean( - context.oauthCredential && (context.folderId || context.spaceId || context.listSpaceId) - ), - fetchList: async ({ context, signal }: SelectorQueryArgs) => { - const credentialId = ensureCredential(context, 'clickup.lists') - const spaceId = context.spaceId || context.listSpaceId - if (!context.folderId && !spaceId) { - throw new Error('Missing folder or space ID for clickup.lists selector') - } - const data = await requestJson(selectorContracts.clickupListsSelectorContract, { - body: { - credential: credentialId, - folderId: context.folderId, - spaceId, - workflowId: context.workflowId, - }, - signal, - }) - return (data.lists || []).map((list) => ({ - id: list.id, - label: list.name, - })) - }, - }, -} satisfies Record< - Extract< - SelectorKey, - 'clickup.workspaces' | 'clickup.spaces' | 'clickup.folders' | 'clickup.lists' - >, - SelectorDefinition -> diff --git a/apps/sim/hooks/selectors/providers/cloudwatch/selectors.test.ts b/apps/sim/hooks/selectors/providers/cloudwatch/selectors.test.ts deleted file mode 100644 index 66f2ce150ac..00000000000 --- a/apps/sim/hooks/selectors/providers/cloudwatch/selectors.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * @vitest-environment node - */ -import { describe, expect, it } from 'vitest' -import { cloudwatchSelectors } from '@/hooks/selectors/providers/cloudwatch/selectors' -import type { SelectorQueryArgs } from '@/hooks/selectors/types' - -const AWS_CONTEXT: SelectorQueryArgs['context'] = { - awsAccessKeyId: 'AKIA', - awsSecretAccessKey: 'secret', - awsRegion: 'us-east-1', - logGroupName: '/aws/lambda/fn', -} - -describe('cloudwatch selector query keys', () => { - it.each([['cloudwatch.logGroups' as const], ['cloudwatch.logStreams' as const]])( - '%s scopes its key by search, which fetchList forwards as `prefix`', - (key) => { - const definition = cloudwatchSelectors[key] - const base: SelectorQueryArgs = { key, context: AWS_CONTEXT } - - const noSearch = definition.getQueryKey(base) - const withSearch = definition.getQueryKey({ ...base, search: 'api' }) - const otherSearch = definition.getQueryKey({ ...base, search: 'worker' }) - - expect(withSearch).not.toEqual(noSearch) - expect(withSearch).not.toEqual(otherSearch) - } - ) -}) diff --git a/apps/sim/hooks/selectors/providers/cloudwatch/selectors.ts b/apps/sim/hooks/selectors/providers/cloudwatch/selectors.ts deleted file mode 100644 index 6d7b7480183..00000000000 --- a/apps/sim/hooks/selectors/providers/cloudwatch/selectors.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { requestJson } from '@/lib/api/client/request' -import * as selectorContracts from '@/lib/api/contracts/selectors' -import { SELECTOR_STALE } from '@/hooks/selectors/providers/shared' -import type { SelectorDefinition, SelectorKey, SelectorQueryArgs } from '@/hooks/selectors/types' - -function ensureAwsSelectorCredentials(context: SelectorQueryArgs['context'], key: SelectorKey) { - if (!context.awsAccessKeyId || !context.awsSecretAccessKey || !context.awsRegion) { - throw new Error(`Missing AWS credentials for selector ${key}`) - } - - return { - accessKeyId: context.awsAccessKeyId, - secretAccessKey: context.awsSecretAccessKey, - region: context.awsRegion, - } -} - -export const cloudwatchSelectors = { - 'cloudwatch.logGroups': { - key: 'cloudwatch.logGroups', - contracts: [selectorContracts.cloudwatchLogGroupsSelectorContract], - staleTime: SELECTOR_STALE, - getQueryKey: ({ context, search }: SelectorQueryArgs) => [ - 'selectors', - 'cloudwatch.logGroups', - context.awsAccessKeyId ?? 'none', - context.awsRegion ?? 'none', - search ?? '', - ], - enabled: ({ context }) => - Boolean(context.awsAccessKeyId && context.awsSecretAccessKey && context.awsRegion), - fetchList: async ({ context, search, signal }: SelectorQueryArgs) => { - const awsCredentials = ensureAwsSelectorCredentials(context, 'cloudwatch.logGroups') - const data = await requestJson(selectorContracts.cloudwatchLogGroupsSelectorContract, { - body: { - ...awsCredentials, - prefix: search, - }, - signal, - }) - return (data.output?.logGroups || []).map((lg) => ({ - id: lg.logGroupName, - label: lg.logGroupName, - })) - }, - fetchById: async ({ detailId, signal }: SelectorQueryArgs) => { - if (!detailId) return null - return { id: detailId, label: detailId } - }, - }, - 'cloudwatch.logStreams': { - key: 'cloudwatch.logStreams', - contracts: [selectorContracts.cloudwatchLogStreamsSelectorContract], - staleTime: SELECTOR_STALE, - getQueryKey: ({ context, search }: SelectorQueryArgs) => [ - 'selectors', - 'cloudwatch.logStreams', - context.awsAccessKeyId ?? 'none', - context.awsRegion ?? 'none', - context.logGroupName ?? 'none', - search ?? '', - ], - enabled: ({ context }) => - Boolean( - context.awsAccessKeyId && - context.awsSecretAccessKey && - context.awsRegion && - context.logGroupName - ), - fetchList: async ({ context, search, signal }: SelectorQueryArgs) => { - const awsCredentials = ensureAwsSelectorCredentials(context, 'cloudwatch.logStreams') - if (!context.logGroupName) { - throw new Error('Missing log group name for cloudwatch.logStreams selector') - } - const data = await requestJson(selectorContracts.cloudwatchLogStreamsSelectorContract, { - body: { - ...awsCredentials, - logGroupName: context.logGroupName, - prefix: search, - }, - signal, - }) - return (data.output?.logStreams || []).map((ls) => ({ - id: ls.logStreamName, - label: ls.logStreamName, - })) - }, - fetchById: async ({ detailId, signal }: SelectorQueryArgs) => { - if (!detailId) return null - return { id: detailId, label: detailId } - }, - }, -} satisfies Record< - Extract, - SelectorDefinition -> diff --git a/apps/sim/hooks/selectors/providers/confluence/selectors.ts b/apps/sim/hooks/selectors/providers/confluence/selectors.ts deleted file mode 100644 index a0c2d352142..00000000000 --- a/apps/sim/hooks/selectors/providers/confluence/selectors.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { requestJson } from '@/lib/api/client/request' -import * as selectorContracts from '@/lib/api/contracts/selectors' -import { fetchOAuthToken } from '@/hooks/selectors/helpers' -import { ensureCredential, ensureDomain, SELECTOR_STALE } from '@/hooks/selectors/providers/shared' -import type { SelectorDefinition, SelectorKey, SelectorQueryArgs } from '@/hooks/selectors/types' - -function formatConfluenceSpaceLabel(space: { name: string; key: string; status?: string }): string { - const base = `${space.name} (${space.key})` - return space.status === 'archived' ? `${base} — archived` : base -} - -function toSpaceOption(space: { name: string; key: string; status?: string }): { - id: string - label: string -} { - return { id: space.key, label: formatConfluenceSpaceLabel(space) } -} - -export const confluenceSelectors = { - 'confluence.spaces': { - key: 'confluence.spaces', - contracts: [selectorContracts.confluenceSpacesSelectorContract], - staleTime: SELECTOR_STALE, - getQueryKey: ({ context }: SelectorQueryArgs) => [ - 'selectors', - 'confluence.spaces', - context.oauthCredential ?? 'none', - context.domain ?? 'none', - ], - enabled: ({ context }) => Boolean(context.oauthCredential && context.domain), - /** - * Drives pagination through {@link useSelectorOptions}, which drains every - * page via this callback. No `fetchList` — the paged path supersedes it. - */ - fetchPage: async ({ context, cursor, signal }) => { - const credentialId = ensureCredential(context, 'confluence.spaces') - const domain = ensureDomain(context, 'confluence.spaces') - const data = await requestJson(selectorContracts.confluenceSpacesSelectorContract, { - body: { - credential: credentialId, - workflowId: context.workflowId, - domain, - cursor, - }, - signal, - }) - return { - items: (data.spaces || []).map(toSpaceOption), - nextCursor: data.nextCursor, - } - }, - /** The server filters by key and returns nothing for a key that does not exist. */ - resolvesUnknownIds: true, - /** - * Resolves a single space by key via the server's exact-key lookup, independent - * of how far the page drain has run — a space sorting beyond page 1 would - * otherwise never resolve, which on a large site is most of them. - */ - fetchById: async ({ context, detailId, signal }: SelectorQueryArgs) => { - if (!detailId) return null - const credentialId = ensureCredential(context, 'confluence.spaces') - const domain = ensureDomain(context, 'confluence.spaces') - const data = await requestJson(selectorContracts.confluenceSpacesSelectorContract, { - body: { - credential: credentialId, - workflowId: context.workflowId, - domain, - spaceKey: detailId, - }, - signal, - }) - const space = (data.spaces || []).find((s) => s.key === detailId) ?? null - if (!space) return null - return toSpaceOption(space) - }, - }, - 'confluence.pages': { - key: 'confluence.pages', - contracts: [ - selectorContracts.confluencePagesSelectorContract, - selectorContracts.confluencePageSelectorContract, - ], - staleTime: SELECTOR_STALE, - getQueryKey: ({ context, search }: SelectorQueryArgs) => [ - 'selectors', - 'confluence.pages', - context.oauthCredential ?? 'none', - context.domain ?? 'none', - search ?? '', - ], - enabled: ({ context }) => Boolean(context.oauthCredential && context.domain), - /** - * Deliberately a single request, not a drain. `/pages` is cursor-paginated and - * this list is therefore capped at `limit`, which is a real gap — but draining it - * is worse: with no search term `title` is unset, so the drain would walk the - * entire site (up to `MAX_AUTO_DRAIN_PAGES` requests) every time the dropdown - * opens, and the route does not forward an abort signal upstream, so superseded - * drains still bill the tenant's rate limit. Fixing this properly needs - * server-side search whose `title` semantics have been confirmed against a live - * instance, not brute-force loading. - */ - fetchList: async ({ context, search, signal }: SelectorQueryArgs) => { - const credentialId = ensureCredential(context, 'confluence.pages') - const domain = ensureDomain(context, 'confluence.pages') - const bundle = await fetchOAuthToken(credentialId, context.workflowId) - if (!bundle) { - throw new Error('Missing Confluence access token') - } - const data = await requestJson(selectorContracts.confluencePagesSelectorContract, { - body: { - domain, - accessToken: bundle.accessToken, - cloudId: bundle.cloudId, - title: search, - }, - signal, - }) - return (data.files || []).map((file) => ({ - id: file.id, - label: file.name, - })) - }, - fetchById: async ({ context, detailId, signal }: SelectorQueryArgs) => { - if (!detailId) return null - const credentialId = ensureCredential(context, 'confluence.pages') - const domain = ensureDomain(context, 'confluence.pages') - const bundle = await fetchOAuthToken(credentialId, context.workflowId) - if (!bundle) { - throw new Error('Missing Confluence access token') - } - const data = await requestJson(selectorContracts.confluencePageSelectorContract, { - body: { - domain, - accessToken: bundle.accessToken, - cloudId: bundle.cloudId, - pageId: detailId, - }, - signal, - }) - return { id: data.id, label: data.title } - }, - }, -} satisfies Record< - Extract, - SelectorDefinition -> diff --git a/apps/sim/hooks/selectors/providers/google/selectors.ts b/apps/sim/hooks/selectors/providers/google/selectors.ts deleted file mode 100644 index 6ad9c5540dc..00000000000 --- a/apps/sim/hooks/selectors/providers/google/selectors.ts +++ /dev/null @@ -1,192 +0,0 @@ -import { requestJson } from '@/lib/api/client/request' -import * as selectorContracts from '@/lib/api/contracts/selectors' -import { - ensureCredential, - SELECTOR_SEARCH_STALE, - SELECTOR_STALE, -} from '@/hooks/selectors/providers/shared' -import type { SelectorDefinition, SelectorKey, SelectorQueryArgs } from '@/hooks/selectors/types' - -export const googleSelectors = { - 'google.tasks.lists': { - key: 'google.tasks.lists', - contracts: [selectorContracts.googleTasksTaskListsSelectorContract], - staleTime: SELECTOR_STALE, - getQueryKey: ({ context }: SelectorQueryArgs) => [ - 'selectors', - 'google.tasks.lists', - context.oauthCredential ?? 'none', - context.impersonateUserEmail ?? 'none', - ], - enabled: ({ context }) => Boolean(context.oauthCredential), - fetchList: async ({ context, signal }: SelectorQueryArgs) => { - const credentialId = ensureCredential(context, 'google.tasks.lists') - const data = await requestJson(selectorContracts.googleTasksTaskListsSelectorContract, { - body: { - credential: credentialId, - workflowId: context.workflowId, - impersonateEmail: context.impersonateUserEmail, - }, - signal, - }) - return (data.taskLists || []).map((tl) => ({ id: tl.id, label: tl.title })) - }, - fetchById: async ({ context, detailId, signal }: SelectorQueryArgs) => { - if (!detailId) return null - const credentialId = ensureCredential(context, 'google.tasks.lists') - const data = await requestJson(selectorContracts.googleTasksTaskListsSelectorContract, { - body: { - credential: credentialId, - workflowId: context.workflowId, - impersonateEmail: context.impersonateUserEmail, - }, - signal, - }) - const tl = (data.taskLists || []).find((t) => t.id === detailId) ?? null - if (!tl) return null - return { id: tl.id, label: tl.title } - }, - }, - 'gmail.labels': { - key: 'gmail.labels', - contracts: [selectorContracts.gmailLabelsSelectorContract], - staleTime: SELECTOR_STALE, - getQueryKey: ({ context }: SelectorQueryArgs) => [ - 'selectors', - 'gmail.labels', - context.oauthCredential ?? 'none', - context.impersonateUserEmail ?? 'none', - ], - enabled: ({ context }) => Boolean(context.oauthCredential), - fetchList: async ({ context, signal }: SelectorQueryArgs) => { - const credentialId = ensureCredential(context, 'gmail.labels') - const data = await requestJson(selectorContracts.gmailLabelsSelectorContract, { - query: { - credentialId, - impersonateEmail: context.impersonateUserEmail, - }, - signal, - }) - return (data.labels || []).map((label) => ({ - id: label.id, - label: label.name, - })) - }, - }, - 'google.calendar': { - key: 'google.calendar', - contracts: [selectorContracts.googleCalendarSelectorContract], - staleTime: SELECTOR_STALE, - getQueryKey: ({ context }: SelectorQueryArgs) => [ - 'selectors', - 'google.calendar', - context.oauthCredential ?? 'none', - context.impersonateUserEmail ?? 'none', - ], - enabled: ({ context }) => Boolean(context.oauthCredential), - fetchList: async ({ context, signal }: SelectorQueryArgs) => { - const credentialId = ensureCredential(context, 'google.calendar') - const data = await requestJson(selectorContracts.googleCalendarSelectorContract, { - query: { - credentialId, - impersonateEmail: context.impersonateUserEmail, - }, - signal, - }) - return (data.calendars || []).map((calendar) => ({ - id: calendar.id, - label: calendar.summary, - })) - }, - }, - 'google.drive': { - key: 'google.drive', - contracts: [ - selectorContracts.googleDriveFilesSelectorContract, - selectorContracts.googleDriveFileSelectorContract, - ], - staleTime: SELECTOR_SEARCH_STALE, - getQueryKey: ({ context, search }: SelectorQueryArgs) => [ - 'selectors', - 'google.drive', - context.oauthCredential ?? 'none', - context.mimeType ?? 'any', - context.fileId ?? 'root', - search ?? '', - context.impersonateUserEmail ?? 'none', - ], - enabled: ({ context }) => Boolean(context.oauthCredential), - fetchList: async ({ context, search, signal }: SelectorQueryArgs) => { - const credentialId = ensureCredential(context, 'google.drive') - const data = await requestJson(selectorContracts.googleDriveFilesSelectorContract, { - query: { - credentialId, - mimeType: context.mimeType, - parentId: context.fileId, - query: search, - workflowId: context.workflowId, - impersonateEmail: context.impersonateUserEmail, - }, - signal, - }) - return (data.files || []).map((file) => ({ - id: file.id, - label: file.name, - })) - }, - fetchById: async ({ context, detailId, signal }: SelectorQueryArgs) => { - if (!detailId) return null - const credentialId = ensureCredential(context, 'google.drive') - const data = await requestJson(selectorContracts.googleDriveFileSelectorContract, { - query: { - credentialId, - fileId: detailId, - workflowId: context.workflowId, - impersonateEmail: context.impersonateUserEmail, - }, - signal, - }) - const file = data.file - if (!file) return null - return { id: file.id, label: file.name } - }, - }, - 'google.sheets': { - key: 'google.sheets', - contracts: [selectorContracts.googleSheetsSelectorContract], - staleTime: SELECTOR_STALE, - getQueryKey: ({ context }: SelectorQueryArgs) => [ - 'selectors', - 'google.sheets', - context.oauthCredential ?? 'none', - context.spreadsheetId ?? 'none', - context.impersonateUserEmail ?? 'none', - ], - enabled: ({ context }) => Boolean(context.oauthCredential && context.spreadsheetId), - fetchList: async ({ context, signal }: SelectorQueryArgs) => { - const credentialId = ensureCredential(context, 'google.sheets') - if (!context.spreadsheetId) { - throw new Error('Missing spreadsheet ID for google.sheets selector') - } - const data = await requestJson(selectorContracts.googleSheetsSelectorContract, { - query: { - credentialId, - spreadsheetId: context.spreadsheetId, - workflowId: context.workflowId, - impersonateEmail: context.impersonateUserEmail, - }, - signal, - }) - return (data.sheets || []).map((sheet) => ({ - id: sheet.id, - label: sheet.name, - })) - }, - }, -} satisfies Record< - Extract< - SelectorKey, - 'google.tasks.lists' | 'gmail.labels' | 'google.calendar' | 'google.drive' | 'google.sheets' - >, - SelectorDefinition -> diff --git a/apps/sim/hooks/selectors/providers/harmonic/selectors.test.ts b/apps/sim/hooks/selectors/providers/harmonic/selectors.test.ts deleted file mode 100644 index 96e4fa584c8..00000000000 --- a/apps/sim/hooks/selectors/providers/harmonic/selectors.test.ts +++ /dev/null @@ -1,151 +0,0 @@ -/** - * @vitest-environment node - */ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { mockRequestJson } = vi.hoisted(() => ({ mockRequestJson: vi.fn() })) - -vi.mock('@/lib/api/client/request', () => ({ requestJson: mockRequestJson })) - -import { selectorContractsByPath } from '@/lib/api/contracts/selectors' -import { getSelectorDefinition } from '@/hooks/selectors/registry' -import type { SelectorQueryArgs } from '@/hooks/selectors/types' - -const selector = getSelectorDefinition('harmonic.savedSearches') - -function selectorArgs( - overrides: Partial = {}, - contextOverrides: Partial = {} -): SelectorQueryArgs { - return { - key: 'harmonic.savedSearches', - context: { - oauthCredential: 'credential-1', - workflowId: 'workflow-1', - ...contextOverrides, - }, - ...overrides, - } -} - -const savedSearches = [ - { id: '17', urn: 'urn:harmonic:saved_search:17', name: 'FDE candidates' }, - { id: '28', urn: 'urn:harmonic:saved_search:28', name: 'Enterprise operators' }, -] - -describe('harmonic.savedSearches selector', () => { - beforeEach(() => { - vi.clearAllMocks() - mockRequestJson.mockResolvedValue({ savedSearches }) - }) - - it('is registered with the canonical contract and waits for both authorization dependencies', () => { - expect(selector.key).toBe('harmonic.savedSearches') - expect(selector.contracts).toEqual([ - expect.objectContaining({ - method: 'POST', - path: '/api/tools/harmonic/saved-searches', - }), - ]) - expect(selector.contracts?.[0]).toBe( - selectorContractsByPath['/api/tools/harmonic/saved-searches'] - ) - expect(selector.enabled?.(selectorArgs())).toBe(true) - expect(selector.enabled?.(selectorArgs({}, { oauthCredential: undefined }))).toBe(false) - expect(selector.enabled?.(selectorArgs({}, { workflowId: undefined }))).toBe(false) - }) - - it('isolates the query cache by workflow and credential', () => { - expect(selector.getQueryKey(selectorArgs())).toEqual([ - 'selectors', - 'harmonic.savedSearches', - 'workflow-1', - 'credential-1', - ]) - expect(selector.getQueryKey(selectorArgs({}, { oauthCredential: 'credential-2' }))).toEqual([ - 'selectors', - 'harmonic.savedSearches', - 'workflow-1', - 'credential-2', - ]) - expect(selector.getQueryKey(selectorArgs({}, { workflowId: 'workflow-2' }))).toEqual([ - 'selectors', - 'harmonic.savedSearches', - 'workflow-2', - 'credential-1', - ]) - }) - - it('loads safe options through requestJson and uses the full URN as the selected value', async () => { - const controller = new AbortController() - const options = await selector.fetchList?.(selectorArgs({ signal: controller.signal })) - - expect(mockRequestJson).toHaveBeenCalledWith( - expect.objectContaining({ - method: 'POST', - path: '/api/tools/harmonic/saved-searches', - }), - { - body: { credential: 'credential-1', workflowId: 'workflow-1' }, - signal: controller.signal, - } - ) - expect(options).toEqual([ - { - id: 'urn:harmonic:saved_search:17', - label: 'FDE candidates', - meta: { - id: '17', - urn: 'urn:harmonic:saved_search:17', - name: 'FDE candidates', - }, - }, - { - id: 'urn:harmonic:saved_search:28', - label: 'Enterprise operators', - meta: { - id: '28', - urn: 'urn:harmonic:saved_search:28', - name: 'Enterprise operators', - }, - }, - ]) - }) - - it.each([ - ['numeric ID', '17'], - ['full URN', 'urn:harmonic:saved_search:17'], - ])('resolves a persisted %s to the canonical full-URN option', async (_label, detailId) => { - const option = await selector.fetchById?.(selectorArgs({ detailId })) - - expect(option).toEqual({ - id: 'urn:harmonic:saved_search:17', - label: 'FDE candidates', - meta: { - id: '17', - urn: 'urn:harmonic:saved_search:17', - name: 'FDE candidates', - }, - }) - }) - - it('returns null for an unavailable ID and declares speculative resolution safe', async () => { - expect(selector.resolvesUnknownIds).toBe(true) - await expect(selector.fetchById?.(selectorArgs({ detailId: '999' }))).resolves.toBeNull() - }) - - it('does not request options when detail resolution is missing its credential scope', async () => { - await expect( - selector.fetchById?.(selectorArgs({ detailId: '17' }, { oauthCredential: undefined })) - ).resolves.toBeNull() - expect(mockRequestJson).not.toHaveBeenCalled() - }) - - it.each([ - ['credential', { oauthCredential: undefined }], - ['workflow ID', { workflowId: undefined }], - ])('rejects a missing %s before issuing a request', async (_label, context) => { - await expect(selector.fetchList?.(selectorArgs({}, context))).rejects.toThrow(/Missing/) - expect(mockRequestJson).not.toHaveBeenCalled() - }) -}) diff --git a/apps/sim/hooks/selectors/providers/harmonic/selectors.ts b/apps/sim/hooks/selectors/providers/harmonic/selectors.ts deleted file mode 100644 index e760fb582e6..00000000000 --- a/apps/sim/hooks/selectors/providers/harmonic/selectors.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { requestJson } from '@/lib/api/client/request' -import { - type HarmonicSavedSearchesSelectorResponse, - harmonicSavedSearchesSelectorContract, -} from '@/lib/api/contracts/selectors/harmonic' -import { ensureCredential, SELECTOR_STALE } from '@/hooks/selectors/providers/shared' -import type { - SelectorDefinition, - SelectorKey, - SelectorOption, - SelectorQueryArgs, -} from '@/hooks/selectors/types' - -type HarmonicSavedSearch = HarmonicSavedSearchesSelectorResponse['savedSearches'][number] -type HarmonicSelectorKey = Extract - -function scopeSatisfied({ context }: SelectorQueryArgs): boolean { - return Boolean(context.oauthCredential && context.workflowId) -} - -function toOption(savedSearch: HarmonicSavedSearch): SelectorOption { - return { - id: savedSearch.urn, - label: savedSearch.name, - meta: { - id: savedSearch.id, - urn: savedSearch.urn, - name: savedSearch.name, - }, - } -} - -async function fetchSavedSearches({ context, signal }: SelectorQueryArgs) { - const credential = ensureCredential(context, 'harmonic.savedSearches') - if (!context.workflowId) { - throw new Error('Missing workflow ID for selector harmonic.savedSearches') - } - - return requestJson(harmonicSavedSearchesSelectorContract, { - body: { credential, workflowId: context.workflowId }, - signal, - }) -} - -export const harmonicSelectors = { - 'harmonic.savedSearches': { - key: 'harmonic.savedSearches', - contracts: [harmonicSavedSearchesSelectorContract], - staleTime: SELECTOR_STALE, - getQueryKey: ({ context }: SelectorQueryArgs) => [ - 'selectors', - 'harmonic.savedSearches', - context.workflowId ?? 'none', - context.oauthCredential ?? 'none', - ], - enabled: scopeSatisfied, - fetchList: async (args: SelectorQueryArgs) => - (await fetchSavedSearches(args)).savedSearches.map(toOption), - fetchById: async (args: SelectorQueryArgs) => { - const detailId = args.detailId?.trim() - if (!detailId || !scopeSatisfied(args)) return null - const match = (await fetchSavedSearches(args)).savedSearches.find( - (savedSearch) => savedSearch.urn === detailId || savedSearch.id === detailId - ) - return match ? toOption(match) : null - }, - resolvesUnknownIds: true, - }, -} satisfies Record diff --git a/apps/sim/hooks/selectors/providers/hubspot/selectors.ts b/apps/sim/hooks/selectors/providers/hubspot/selectors.ts deleted file mode 100644 index 956210cc123..00000000000 --- a/apps/sim/hooks/selectors/providers/hubspot/selectors.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { requestJson } from '@/lib/api/client/request' -import * as selectorContracts from '@/lib/api/contracts/selectors' -import { ensureCredential, SELECTOR_STALE } from '@/hooks/selectors/providers/shared' -import type { - SelectorContext, - SelectorDefinition, - SelectorKey, - SelectorQueryArgs, -} from '@/hooks/selectors/types' - -/** - * HubSpot's default CRM object. A picker renders before the user has touched the object-type - * dropdown, and the dropdown itself already displays `contact` — so resolving to nothing there - * would render every dependent picker empty against a control that visibly shows a selection. - */ -const DEFAULT_OBJECT_TYPE = 'contact' - -/** - * The object type a picker is scoped to. - * - * `custom` is an indirection rather than a type: the real id lives in a sibling field, and - * until that is filled in there is no object to query. Returning `null` for that case keeps - * the dependent pickers empty instead of querying HubSpot for an object called "custom". - */ -function resolveObjectType(context: SelectorContext): string | null { - const selected = context.objectType ?? DEFAULT_OBJECT_TYPE - if (selected !== 'custom') return selected - const customId = context.customObjectTypeId?.trim() - return customId ? customId : null -} - -export const hubspotSelectors = { - 'hubspot.properties': { - key: 'hubspot.properties', - contracts: [selectorContracts.hubspotPropertiesSelectorContract], - staleTime: SELECTOR_STALE, - getQueryKey: ({ context }: SelectorQueryArgs) => [ - 'selectors', - 'hubspot.properties', - context.oauthCredential ?? 'none', - resolveObjectType(context) ?? 'none', - ], - enabled: ({ context }) => - Boolean(context.oauthCredential) && resolveObjectType(context) !== null, - fetchList: async ({ context, signal }: SelectorQueryArgs) => { - const credentialId = ensureCredential(context, 'hubspot.properties') - const objectType = resolveObjectType(context) - if (!objectType) return [] - const data = await requestJson(selectorContracts.hubspotPropertiesSelectorContract, { - query: { credentialId, objectType }, - signal, - }) - return data.properties.map((property) => ({ id: property.id, label: property.name })) - }, - }, - 'hubspot.lists': { - key: 'hubspot.lists', - contracts: [selectorContracts.hubspotListsSelectorContract], - staleTime: SELECTOR_STALE, - getQueryKey: ({ context }: SelectorQueryArgs) => [ - 'selectors', - 'hubspot.lists', - context.oauthCredential ?? 'none', - ], - enabled: ({ context }) => Boolean(context.oauthCredential), - fetchList: async ({ context, signal }: SelectorQueryArgs) => { - const credentialId = ensureCredential(context, 'hubspot.lists') - const data = await requestJson(selectorContracts.hubspotListsSelectorContract, { - query: { credentialId }, - signal, - }) - return data.lists.map((list) => ({ id: list.id, label: list.name })) - }, - }, - 'hubspot.pipelines': { - key: 'hubspot.pipelines', - contracts: [selectorContracts.hubspotPipelinesSelectorContract], - staleTime: SELECTOR_STALE, - getQueryKey: ({ context }: SelectorQueryArgs) => [ - 'selectors', - 'hubspot.pipelines', - context.oauthCredential ?? 'none', - resolveObjectType(context) ?? 'none', - ], - enabled: ({ context }) => - Boolean(context.oauthCredential) && resolveObjectType(context) !== null, - fetchList: async ({ context, signal }: SelectorQueryArgs) => { - const credentialId = ensureCredential(context, 'hubspot.pipelines') - const objectType = resolveObjectType(context) - if (!objectType) return [] - const data = await requestJson(selectorContracts.hubspotPipelinesSelectorContract, { - query: { credentialId, objectType }, - signal, - }) - return data.pipelines.map((pipeline) => ({ id: pipeline.id, label: pipeline.name })) - }, - }, - /** - * Stages live INSIDE the pipelines payload rather than behind an endpoint of their own, so - * this reads the same contract and narrows to the selected pipeline. Sharing HubSpot's one - * response is also why both selectors stay in step — a stage list can never describe a - * pipeline the sibling picker is not showing. - */ - 'hubspot.pipelineStages': { - key: 'hubspot.pipelineStages', - contracts: [selectorContracts.hubspotPipelinesSelectorContract], - staleTime: SELECTOR_STALE, - getQueryKey: ({ context }: SelectorQueryArgs) => [ - 'selectors', - 'hubspot.pipelineStages', - context.oauthCredential ?? 'none', - resolveObjectType(context) ?? 'none', - context.pipelineId ?? 'none', - ], - enabled: ({ context }) => - Boolean(context.oauthCredential && context.pipelineId) && resolveObjectType(context) !== null, - fetchList: async ({ context, signal }: SelectorQueryArgs) => { - const credentialId = ensureCredential(context, 'hubspot.pipelineStages') - const objectType = resolveObjectType(context) - if (!objectType || !context.pipelineId) return [] - const data = await requestJson(selectorContracts.hubspotPipelinesSelectorContract, { - query: { credentialId, objectType }, - signal, - }) - const pipeline = data.pipelines.find((entry) => entry.id === context.pipelineId) - return (pipeline?.stages ?? []).map((stage) => ({ id: stage.id, label: stage.label })) - }, - }, - 'hubspot.owners': { - key: 'hubspot.owners', - contracts: [selectorContracts.hubspotOwnersSelectorContract], - staleTime: SELECTOR_STALE, - getQueryKey: ({ context }: SelectorQueryArgs) => [ - 'selectors', - 'hubspot.owners', - context.oauthCredential ?? 'none', - ], - enabled: ({ context }) => Boolean(context.oauthCredential), - fetchList: async ({ context, signal }: SelectorQueryArgs) => { - const credentialId = ensureCredential(context, 'hubspot.owners') - const data = await requestJson(selectorContracts.hubspotOwnersSelectorContract, { - query: { credentialId }, - signal, - }) - return data.owners.map((owner) => ({ id: owner.id, label: owner.name })) - }, - }, -} satisfies Partial> diff --git a/apps/sim/hooks/selectors/providers/imap/selectors.ts b/apps/sim/hooks/selectors/providers/imap/selectors.ts deleted file mode 100644 index 8d706121c48..00000000000 --- a/apps/sim/hooks/selectors/providers/imap/selectors.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { requestJson } from '@/lib/api/client/request' -import { imapMailboxesContract } from '@/lib/api/contracts/tools/imap' -import { SELECTOR_STALE } from '@/hooks/selectors/providers/shared' -import type { SelectorDefinition, SelectorKey, SelectorQueryArgs } from '@/hooks/selectors/types' - -export const imapSelectors = { - /** - * Mailboxes on a self-described IMAP server. Unlike every other selector here the account is - * not a stored credential the server can resolve by id — the user types the connection in, - * so the parameters travel on the context. - * - * **The password is deliberately absent from the query key.** A query key identifies a - * resource; a credential authorizes access to it. `oauthCredential` is safe in a key because - * it is only an id, but a typed password is a secret, and React Query keys are held in cache - * and surfaced by devtools. Host, port, TLS and username already identify the mailbox list - * uniquely — the password only proves the caller may read it, and it rides the request body - * exactly as it did before. - * - * The consequence is intentional: correcting a wrong password re-runs the request (the - * previous attempt failed and cached nothing), while changing ONLY the password on an - * otherwise identical connection reuses the cached list, which is the same list. - */ - 'imap.mailboxes': { - key: 'imap.mailboxes', - contracts: [imapMailboxesContract], - staleTime: SELECTOR_STALE, - getQueryKey: ({ context }: SelectorQueryArgs) => [ - 'selectors', - 'imap.mailboxes', - context.host ?? 'none', - context.port ?? 'default', - context.secure ?? 'default', - context.username ?? 'none', - ], - enabled: ({ context }) => Boolean(context.host && context.username && context.password), - fetchList: async ({ context, signal }: SelectorQueryArgs) => { - if (!context.host || !context.username || !context.password) return [] - const data = await requestJson(imapMailboxesContract, { - body: { - host: context.host, - port: context.port, - secure: context.secure, - username: context.username, - password: context.password, - }, - signal, - }) - return data.mailboxes.map((mailbox) => ({ id: mailbox.path, label: mailbox.name })) - }, - }, -} satisfies Partial> diff --git a/apps/sim/hooks/selectors/providers/jira/selectors.ts b/apps/sim/hooks/selectors/providers/jira/selectors.ts deleted file mode 100644 index f8743641b50..00000000000 --- a/apps/sim/hooks/selectors/providers/jira/selectors.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { requestJson } from '@/lib/api/client/request' -import * as selectorContracts from '@/lib/api/contracts/selectors' -import { fetchOAuthToken } from '@/hooks/selectors/helpers' -import { - ensureCredential, - ensureDomain, - SELECTOR_SEARCH_STALE, - SELECTOR_STALE, -} from '@/hooks/selectors/providers/shared' -import type { SelectorDefinition, SelectorKey, SelectorQueryArgs } from '@/hooks/selectors/types' - -export const jiraSelectors = { - 'jira.projects': { - key: 'jira.projects', - contracts: [ - selectorContracts.jiraProjectsSelectorContract, - selectorContracts.jiraProjectSelectorContract, - ], - staleTime: SELECTOR_STALE, - getQueryKey: ({ context, search }: SelectorQueryArgs) => [ - 'selectors', - 'jira.projects', - context.oauthCredential ?? 'none', - context.domain ?? 'none', - search ?? '', - ], - enabled: ({ context }) => Boolean(context.oauthCredential && context.domain), - fetchList: async ({ context, search, signal }: SelectorQueryArgs) => { - const credentialId = ensureCredential(context, 'jira.projects') - const domain = ensureDomain(context, 'jira.projects') - const bundle = await fetchOAuthToken(credentialId, context.workflowId) - if (!bundle) { - throw new Error('Missing Jira access token') - } - const data = await requestJson(selectorContracts.jiraProjectsSelectorContract, { - query: { - domain, - accessToken: bundle.accessToken, - cloudId: bundle.cloudId, - query: search, - }, - signal, - }) - return (data.projects || []).map((project) => ({ - id: project.id, - label: project.name, - })) - }, - fetchById: async ({ context, detailId, signal }: SelectorQueryArgs) => { - if (!detailId) return null - const credentialId = ensureCredential(context, 'jira.projects') - const domain = ensureDomain(context, 'jira.projects') - const bundle = await fetchOAuthToken(credentialId, context.workflowId) - if (!bundle) { - throw new Error('Missing Jira access token') - } - const data = await requestJson(selectorContracts.jiraProjectSelectorContract, { - body: { - domain, - accessToken: bundle.accessToken, - cloudId: bundle.cloudId, - projectId: detailId, - }, - signal, - }) - if (!data.project) return null - return { - id: data.project.id, - label: data.project.name, - } - }, - }, - 'jira.issues': { - key: 'jira.issues', - contracts: [ - selectorContracts.jiraIssuesSelectorContract, - selectorContracts.jiraIssueSelectorContract, - ], - staleTime: SELECTOR_SEARCH_STALE, - getQueryKey: ({ context, search }: SelectorQueryArgs) => [ - 'selectors', - 'jira.issues', - context.oauthCredential ?? 'none', - context.domain ?? 'none', - context.projectId ?? 'none', - search ?? '', - ], - enabled: ({ context }) => Boolean(context.oauthCredential && context.domain), - fetchList: async ({ context, search, signal }: SelectorQueryArgs) => { - const credentialId = ensureCredential(context, 'jira.issues') - const domain = ensureDomain(context, 'jira.issues') - const bundle = await fetchOAuthToken(credentialId, context.workflowId) - if (!bundle) { - throw new Error('Missing Jira access token') - } - const data = await requestJson(selectorContracts.jiraIssuesSelectorContract, { - query: { - domain, - accessToken: bundle.accessToken, - cloudId: bundle.cloudId, - projectId: context.projectId, - query: search, - }, - signal, - }) - const issues = - data.sections?.flatMap((section) => - (section.issues || []).map((issue) => ({ - id: issue.id || issue.key || '', - name: issue.summary || issue.key || '', - })) - ) || [] - return issues - .filter((issue) => issue.id) - .map((issue) => ({ id: issue.id, label: issue.name || issue.id })) - }, - fetchById: async ({ context, detailId, signal }: SelectorQueryArgs) => { - if (!detailId) return null - const credentialId = ensureCredential(context, 'jira.issues') - const domain = ensureDomain(context, 'jira.issues') - const bundle = await fetchOAuthToken(credentialId, context.workflowId) - if (!bundle) { - throw new Error('Missing Jira access token') - } - const data = await requestJson(selectorContracts.jiraIssueSelectorContract, { - body: { - domain, - accessToken: bundle.accessToken, - cloudId: bundle.cloudId, - issueKeys: [detailId], - }, - signal, - }) - const issue = data.issues?.[0] - if (!issue) return null - return { id: issue.id, label: issue.name } - }, - }, -} satisfies Record, SelectorDefinition> diff --git a/apps/sim/hooks/selectors/providers/jsm/selectors.ts b/apps/sim/hooks/selectors/providers/jsm/selectors.ts deleted file mode 100644 index 5af8d63c03c..00000000000 --- a/apps/sim/hooks/selectors/providers/jsm/selectors.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { requestJson } from '@/lib/api/client/request' -import * as selectorContracts from '@/lib/api/contracts/selectors' -import { ensureCredential, ensureDomain, SELECTOR_STALE } from '@/hooks/selectors/providers/shared' -import type { SelectorDefinition, SelectorKey, SelectorQueryArgs } from '@/hooks/selectors/types' - -export const jsmSelectors = { - 'jsm.serviceDesks': { - key: 'jsm.serviceDesks', - contracts: [selectorContracts.jsmServiceDesksSelectorContract], - staleTime: SELECTOR_STALE, - getQueryKey: ({ context }: SelectorQueryArgs) => [ - 'selectors', - 'jsm.serviceDesks', - context.oauthCredential ?? 'none', - context.domain ?? 'none', - ], - enabled: ({ context }) => Boolean(context.oauthCredential && context.domain), - fetchList: async ({ context, signal }: SelectorQueryArgs) => { - const credentialId = ensureCredential(context, 'jsm.serviceDesks') - const domain = ensureDomain(context, 'jsm.serviceDesks') - const data = await requestJson(selectorContracts.jsmServiceDesksSelectorContract, { - body: { - credential: credentialId, - workflowId: context.workflowId, - domain, - }, - signal, - }) - return (data.serviceDesks || []).map((sd) => ({ - id: sd.id, - label: sd.name, - })) - }, - fetchById: async ({ context, detailId, signal }: SelectorQueryArgs) => { - if (!detailId) return null - const credentialId = ensureCredential(context, 'jsm.serviceDesks') - const domain = ensureDomain(context, 'jsm.serviceDesks') - const data = await requestJson(selectorContracts.jsmServiceDesksSelectorContract, { - body: { - credential: credentialId, - workflowId: context.workflowId, - domain, - }, - signal, - }) - const sd = (data.serviceDesks || []).find((s) => s.id === detailId) ?? null - if (!sd) return null - return { id: sd.id, label: sd.name } - }, - }, - 'jsm.requestTypes': { - key: 'jsm.requestTypes', - contracts: [selectorContracts.jsmRequestTypesSelectorContract], - staleTime: SELECTOR_STALE, - getQueryKey: ({ context }: SelectorQueryArgs) => [ - 'selectors', - 'jsm.requestTypes', - context.oauthCredential ?? 'none', - context.domain ?? 'none', - context.serviceDeskId ?? 'none', - ], - enabled: ({ context }) => - Boolean(context.oauthCredential && context.domain && context.serviceDeskId), - fetchList: async ({ context, signal }: SelectorQueryArgs) => { - const credentialId = ensureCredential(context, 'jsm.requestTypes') - const domain = ensureDomain(context, 'jsm.requestTypes') - if (!context.serviceDeskId) throw new Error('Missing serviceDeskId for jsm.requestTypes') - const data = await requestJson(selectorContracts.jsmRequestTypesSelectorContract, { - body: { - credential: credentialId, - workflowId: context.workflowId, - domain, - serviceDeskId: context.serviceDeskId, - }, - signal, - }) - return (data.requestTypes || []).map((rt) => ({ - id: rt.id, - label: rt.name, - })) - }, - fetchById: async ({ context, detailId, signal }: SelectorQueryArgs) => { - if (!detailId) return null - const credentialId = ensureCredential(context, 'jsm.requestTypes') - const domain = ensureDomain(context, 'jsm.requestTypes') - if (!context.serviceDeskId) return null - const data = await requestJson(selectorContracts.jsmRequestTypesSelectorContract, { - body: { - credential: credentialId, - workflowId: context.workflowId, - domain, - serviceDeskId: context.serviceDeskId, - }, - signal, - }) - const rt = (data.requestTypes || []).find((r) => r.id === detailId) ?? null - if (!rt) return null - return { id: rt.id, label: rt.name } - }, - }, -} satisfies Record< - Extract, - SelectorDefinition -> diff --git a/apps/sim/hooks/selectors/providers/knowledge/selectors.ts b/apps/sim/hooks/selectors/providers/knowledge/selectors.ts deleted file mode 100644 index c379d96c916..00000000000 --- a/apps/sim/hooks/selectors/providers/knowledge/selectors.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { requestJson } from '@/lib/api/client/request' -import * as selectorContracts from '@/lib/api/contracts/selectors' -import { ensureKnowledgeBase, SELECTOR_STALE } from '@/hooks/selectors/providers/shared' -import type { SelectorDefinition, SelectorKey, SelectorQueryArgs } from '@/hooks/selectors/types' - -const KNOWLEDGE_DOCUMENTS_PAGE_LIMIT = 100 - -export const knowledgeSelectors = { - 'knowledge.documents': { - key: 'knowledge.documents', - contracts: [ - selectorContracts.listKnowledgeSelectorDocumentsContract, - selectorContracts.getKnowledgeSelectorDocumentContract, - ], - staleTime: SELECTOR_STALE, - getQueryKey: ({ context, search }: SelectorQueryArgs) => [ - 'selectors', - 'knowledge.documents', - context.knowledgeBaseId ?? 'none', - search ?? '', - ], - enabled: ({ context }) => Boolean(context.knowledgeBaseId), - /** - * Drives pagination through {@link useSelectorOptions}, which drains every - * page via this callback. The `pagination.hasMore` flag from the route - * decides when to stop; `nextCursor` encodes the next `offset`. - */ - fetchPage: async ({ context, search, cursor, signal }) => { - const knowledgeBaseId = ensureKnowledgeBase(context) - const offset = cursor ? Number(cursor) : 0 - const result = await requestJson(selectorContracts.listKnowledgeSelectorDocumentsContract, { - params: { id: knowledgeBaseId }, - query: { - limit: KNOWLEDGE_DOCUMENTS_PAGE_LIMIT, - offset, - search, - }, - signal, - }) - const { pagination } = result.data - const nextOffset = pagination.offset + pagination.limit - return { - items: result.data.documents.map((doc) => ({ - id: doc.id, - label: doc.filename, - })), - nextCursor: pagination.hasMore ? String(nextOffset) : undefined, - } - }, - fetchById: async ({ context, detailId, signal }: SelectorQueryArgs) => { - if (!detailId) return null - const knowledgeBaseId = ensureKnowledgeBase(context) - const result = await requestJson(selectorContracts.getKnowledgeSelectorDocumentContract, { - params: { id: knowledgeBaseId, documentId: detailId }, - query: { includeDisabled: 'true' }, - signal, - }) - return { id: result.data.id, label: result.data.filename } - }, - }, -} satisfies Record, SelectorDefinition> diff --git a/apps/sim/hooks/selectors/providers/linear/selectors.ts b/apps/sim/hooks/selectors/providers/linear/selectors.ts deleted file mode 100644 index ab552b14535..00000000000 --- a/apps/sim/hooks/selectors/providers/linear/selectors.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { requestJson } from '@/lib/api/client/request' -import * as selectorContracts from '@/lib/api/contracts/selectors' -import { ensureCredential, SELECTOR_STALE } from '@/hooks/selectors/providers/shared' -import type { SelectorDefinition, SelectorKey, SelectorQueryArgs } from '@/hooks/selectors/types' - -export const linearSelectors = { - 'linear.teams': { - key: 'linear.teams', - contracts: [selectorContracts.linearTeamsSelectorContract], - staleTime: SELECTOR_STALE, - getQueryKey: ({ context }: SelectorQueryArgs) => [ - 'selectors', - 'linear.teams', - context.oauthCredential ?? 'none', - ], - enabled: ({ context }) => Boolean(context.oauthCredential), - fetchList: async ({ context, signal }: SelectorQueryArgs) => { - const credentialId = ensureCredential(context, 'linear.teams') - const data = await requestJson(selectorContracts.linearTeamsSelectorContract, { - body: { credential: credentialId, workflowId: context.workflowId }, - signal, - }) - return (data.teams || []).map((team) => ({ - id: team.id, - label: team.name, - })) - }, - }, - 'linear.projects': { - key: 'linear.projects', - contracts: [selectorContracts.linearProjectsSelectorContract], - staleTime: SELECTOR_STALE, - getQueryKey: ({ context }: SelectorQueryArgs) => [ - 'selectors', - 'linear.projects', - context.oauthCredential ?? 'none', - context.teamId ?? 'none', - ], - enabled: ({ context }) => Boolean(context.oauthCredential && context.teamId), - fetchList: async ({ context, signal }: SelectorQueryArgs) => { - const credentialId = ensureCredential(context, 'linear.projects') - if (!context.teamId) { - throw new Error('Missing team ID for linear.projects selector') - } - const data = await requestJson(selectorContracts.linearProjectsSelectorContract, { - body: { - credential: credentialId, - teamId: context.teamId, - workflowId: context.workflowId, - }, - signal, - }) - return (data.projects || []).map((project) => ({ - id: project.id, - label: project.name, - })) - }, - }, -} satisfies Record, SelectorDefinition> diff --git a/apps/sim/hooks/selectors/providers/managed-agent/selectors.ts b/apps/sim/hooks/selectors/providers/managed-agent/selectors.ts deleted file mode 100644 index cc452e0e7c2..00000000000 --- a/apps/sim/hooks/selectors/providers/managed-agent/selectors.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { requestJson } from '@/lib/api/client/request' -import { - listManagedAgentOptionsContract, - type ManagedAgentResource, -} from '@/lib/api/contracts/managed-agents' -import { ensureCredential, SELECTOR_STALE } from '@/hooks/selectors/providers/shared' -import type { - SelectorDefinition, - SelectorKey, - SelectorOption, - SelectorQueryArgs, -} from '@/hooks/selectors/types' - -/** - * All four Managed Agent pickers read one route, distinguished only by `resource`. The route - * decrypts the selected Claude Platform credential server-side, so the API key never reaches - * the browser — which is why these cannot fall back to a plain client fetch. - */ -async function listResource( - key: SelectorKey, - resource: ManagedAgentResource, - { context, signal }: SelectorQueryArgs -): Promise { - const credentialId = ensureCredential(context, key) - const { options } = await requestJson(listManagedAgentOptionsContract, { - query: { credentialId, resource }, - signal, - }) - return options -} - -function resourceSelector(key: SelectorKey, resource: ManagedAgentResource): SelectorDefinition { - return { - key, - contracts: [listManagedAgentOptionsContract], - staleTime: SELECTOR_STALE, - getQueryKey: ({ context }: SelectorQueryArgs) => [ - 'selectors', - key, - context.oauthCredential ?? 'none', - ], - enabled: ({ context }) => Boolean(context.oauthCredential), - fetchList: (args: SelectorQueryArgs) => listResource(key, resource, args), - } -} - -export const managedAgentSelectors = { - 'managedAgent.agents': resourceSelector('managedAgent.agents', 'agents'), - 'managedAgent.vaults': resourceSelector('managedAgent.vaults', 'vaults'), - 'managedAgent.memoryStores': resourceSelector('managedAgent.memoryStores', 'memory-stores'), - /** - * Environments are filtered to the selected deployment mode: cloud and self-hosted expose - * different fields (self-hosted rejects `resources`), so mixing them offers choices the rest - * of the form cannot honour. An option whose type the API leaves unset is kept either way. - */ - 'managedAgent.environments': { - key: 'managedAgent.environments', - contracts: [listManagedAgentOptionsContract], - staleTime: SELECTOR_STALE, - getQueryKey: ({ context }: SelectorQueryArgs) => [ - 'selectors', - 'managedAgent.environments', - context.oauthCredential ?? 'none', - context.environmentType ?? 'any', - ], - enabled: ({ context }) => Boolean(context.oauthCredential), - fetchList: async (args: SelectorQueryArgs) => { - const options = await listResource('managedAgent.environments', 'environments', args) - const mode = args.context.environmentType - if (mode !== 'cloud' && mode !== 'self_hosted') return options - return options.filter((option) => { - const type = (option as { type?: string }).type - return type === undefined || type === mode - }) - }, - }, -} satisfies Partial> diff --git a/apps/sim/hooks/selectors/providers/microsoft/selectors.ts b/apps/sim/hooks/selectors/providers/microsoft/selectors.ts deleted file mode 100644 index 2b93c6f4a4f..00000000000 --- a/apps/sim/hooks/selectors/providers/microsoft/selectors.ts +++ /dev/null @@ -1,404 +0,0 @@ -import { requestJson } from '@/lib/api/client/request' -import * as selectorContracts from '@/lib/api/contracts/selectors' -import { ensureCredential, SELECTOR_STALE } from '@/hooks/selectors/providers/shared' -import type { SelectorDefinition, SelectorKey, SelectorQueryArgs } from '@/hooks/selectors/types' - -export const microsoftSelectors = { - 'microsoft.planner.plans': { - key: 'microsoft.planner.plans', - contracts: [selectorContracts.microsoftPlannerPlansSelectorContract], - staleTime: SELECTOR_STALE, - getQueryKey: ({ context }: SelectorQueryArgs) => [ - 'selectors', - 'microsoft.planner.plans', - context.oauthCredential ?? 'none', - ], - enabled: ({ context }) => Boolean(context.oauthCredential), - fetchList: async ({ context, signal }: SelectorQueryArgs) => { - const credentialId = ensureCredential(context, 'microsoft.planner.plans') - const data = await requestJson(selectorContracts.microsoftPlannerPlansSelectorContract, { - body: { credential: credentialId, workflowId: context.workflowId }, - signal, - }) - return (data.plans || []).map((plan) => ({ id: plan.id, label: plan.title })) - }, - fetchById: async ({ context, detailId, signal }: SelectorQueryArgs) => { - if (!detailId) return null - const credentialId = ensureCredential(context, 'microsoft.planner.plans') - const data = await requestJson(selectorContracts.microsoftPlannerPlansSelectorContract, { - body: { credential: credentialId, workflowId: context.workflowId }, - signal, - }) - const plan = (data.plans || []).find((p) => p.id === detailId) ?? null - if (!plan) return null - return { id: plan.id, label: plan.title } - }, - }, - 'outlook.folders': { - key: 'outlook.folders', - contracts: [selectorContracts.outlookFoldersSelectorContract], - staleTime: SELECTOR_STALE, - getQueryKey: ({ context }: SelectorQueryArgs) => [ - 'selectors', - 'outlook.folders', - context.oauthCredential ?? 'none', - ], - enabled: ({ context }) => Boolean(context.oauthCredential), - fetchList: async ({ context, signal }: SelectorQueryArgs) => { - const credentialId = ensureCredential(context, 'outlook.folders') - const data = await requestJson(selectorContracts.outlookFoldersSelectorContract, { - query: { credentialId }, - signal, - }) - return (data.folders || []).map((folder) => ({ - id: folder.id, - label: folder.name, - })) - }, - }, - 'outlook.calendars': { - key: 'outlook.calendars', - contracts: [selectorContracts.outlookCalendarsSelectorContract], - staleTime: SELECTOR_STALE, - getQueryKey: ({ context }: SelectorQueryArgs) => [ - 'selectors', - 'outlook.calendars', - context.oauthCredential ?? 'none', - ], - enabled: ({ context }) => Boolean(context.oauthCredential), - fetchList: async ({ context, signal }: SelectorQueryArgs) => { - const credentialId = ensureCredential(context, 'outlook.calendars') - const data = await requestJson(selectorContracts.outlookCalendarsSelectorContract, { - query: { credentialId }, - signal, - }) - return (data.calendars || []).map((calendar) => ({ - id: calendar.id, - label: calendar.name, - })) - }, - }, - 'microsoft.teams': { - key: 'microsoft.teams', - contracts: [selectorContracts.microsoftTeamsSelectorContract], - staleTime: SELECTOR_STALE, - getQueryKey: ({ context }: SelectorQueryArgs) => [ - 'selectors', - 'microsoft.teams', - context.oauthCredential ?? 'none', - ], - enabled: ({ context }) => Boolean(context.oauthCredential), - fetchList: async ({ context, signal }: SelectorQueryArgs) => { - const credentialId = ensureCredential(context, 'microsoft.teams') - const data = await requestJson(selectorContracts.microsoftTeamsSelectorContract, { - body: { credential: credentialId, workflowId: context.workflowId }, - signal, - }) - return (data.teams || []).map((team) => ({ - id: team.id, - label: team.displayName, - })) - }, - }, - 'microsoft.chats': { - key: 'microsoft.chats', - contracts: [selectorContracts.microsoftChatsSelectorContract], - staleTime: SELECTOR_STALE, - getQueryKey: ({ context }: SelectorQueryArgs) => [ - 'selectors', - 'microsoft.chats', - context.oauthCredential ?? 'none', - ], - enabled: ({ context }) => Boolean(context.oauthCredential), - fetchList: async ({ context, signal }: SelectorQueryArgs) => { - const credentialId = ensureCredential(context, 'microsoft.chats') - const data = await requestJson(selectorContracts.microsoftChatsSelectorContract, { - body: { credential: credentialId, workflowId: context.workflowId }, - signal, - }) - return (data.chats || []).map((chat) => ({ - id: chat.id, - label: chat.displayName, - })) - }, - }, - 'microsoft.channels': { - key: 'microsoft.channels', - contracts: [selectorContracts.microsoftChannelsSelectorContract], - staleTime: SELECTOR_STALE, - getQueryKey: ({ context }: SelectorQueryArgs) => [ - 'selectors', - 'microsoft.channels', - context.oauthCredential ?? 'none', - context.teamId ?? 'none', - ], - enabled: ({ context }) => Boolean(context.oauthCredential && context.teamId), - fetchList: async ({ context, signal }: SelectorQueryArgs) => { - const credentialId = ensureCredential(context, 'microsoft.channels') - if (!context.teamId) { - throw new Error('Missing team ID for microsoft.channels selector') - } - const data = await requestJson(selectorContracts.microsoftChannelsSelectorContract, { - body: { - credential: credentialId, - workflowId: context.workflowId, - teamId: context.teamId, - }, - signal, - }) - return (data.channels || []).map((channel) => ({ - id: channel.id, - label: channel.displayName, - })) - }, - }, - 'microsoft.planner': { - key: 'microsoft.planner', - contracts: [selectorContracts.microsoftPlannerTasksSelectorContract], - staleTime: SELECTOR_STALE, - getQueryKey: ({ context }: SelectorQueryArgs) => [ - 'selectors', - 'microsoft.planner', - context.oauthCredential ?? 'none', - context.planId ?? 'none', - ], - enabled: ({ context }) => Boolean(context.oauthCredential && context.planId), - fetchList: async ({ context, signal }: SelectorQueryArgs) => { - const credentialId = ensureCredential(context, 'microsoft.planner') - if (!context.planId) { - throw new Error('Missing plan ID for microsoft.planner selector') - } - const data = await requestJson(selectorContracts.microsoftPlannerTasksSelectorContract, { - body: { - credential: credentialId, - workflowId: context.workflowId, - planId: context.planId, - }, - signal, - }) - return (data.tasks || []).map((task) => ({ - id: task.id, - label: task.title, - })) - }, - fetchById: async ({ context, detailId, signal }: SelectorQueryArgs) => { - if (!detailId || !context.planId) return null - const credentialId = ensureCredential(context, 'microsoft.planner') - const data = await requestJson(selectorContracts.microsoftPlannerTasksSelectorContract, { - body: { - credential: credentialId, - workflowId: context.workflowId, - planId: context.planId, - }, - signal, - }) - const task = (data.tasks || []).find((t) => t.id === detailId) ?? null - if (!task) return null - return { id: task.id, label: task.title } - }, - }, - 'onedrive.files': { - key: 'onedrive.files', - contracts: [selectorContracts.onedriveFilesSelectorContract], - staleTime: SELECTOR_STALE, - getQueryKey: ({ context }: SelectorQueryArgs) => [ - 'selectors', - 'onedrive.files', - context.oauthCredential ?? 'none', - ], - enabled: ({ context }) => Boolean(context.oauthCredential), - fetchList: async ({ context, signal }: SelectorQueryArgs) => { - const credentialId = ensureCredential(context, 'onedrive.files') - const data = await requestJson(selectorContracts.onedriveFilesSelectorContract, { - query: { credentialId }, - signal, - }) - return (data.files || []).map((file) => ({ - id: file.id, - label: file.name, - })) - }, - }, - 'onedrive.folders': { - key: 'onedrive.folders', - contracts: [selectorContracts.onedriveFoldersSelectorContract], - staleTime: SELECTOR_STALE, - getQueryKey: ({ context }: SelectorQueryArgs) => [ - 'selectors', - 'onedrive.folders', - context.oauthCredential ?? 'none', - context.driveId ?? 'none', - ], - enabled: ({ context }) => Boolean(context.oauthCredential), - fetchList: async ({ context, signal }: SelectorQueryArgs) => { - const credentialId = ensureCredential(context, 'onedrive.folders') - const data = await requestJson(selectorContracts.onedriveFoldersSelectorContract, { - query: { credentialId, driveId: context.driveId }, - signal, - }) - return (data.files || []).map((file) => ({ - id: file.id, - label: file.name, - })) - }, - }, - 'microsoft.excel.sheets': { - key: 'microsoft.excel.sheets', - contracts: [selectorContracts.microsoftExcelSheetsSelectorContract], - staleTime: SELECTOR_STALE, - getQueryKey: ({ context }: SelectorQueryArgs) => [ - 'selectors', - 'microsoft.excel.sheets', - context.oauthCredential ?? 'none', - context.spreadsheetId ?? 'none', - context.driveId ?? 'none', - ], - enabled: ({ context }) => Boolean(context.oauthCredential && context.spreadsheetId), - fetchList: async ({ context, signal }: SelectorQueryArgs) => { - const credentialId = ensureCredential(context, 'microsoft.excel.sheets') - if (!context.spreadsheetId) { - throw new Error('Missing spreadsheet ID for microsoft.excel.sheets selector') - } - const data = await requestJson(selectorContracts.microsoftExcelSheetsSelectorContract, { - query: { - credentialId, - spreadsheetId: context.spreadsheetId, - driveId: context.driveId, - workflowId: context.workflowId, - }, - signal, - }) - return (data.sheets || []).map((sheet) => ({ - id: sheet.id, - label: sheet.name, - })) - }, - }, - 'microsoft.excel.drives': { - key: 'microsoft.excel.drives', - contracts: [ - selectorContracts.microsoftExcelDrivesSelectorContract, - selectorContracts.microsoftExcelDriveSelectorContract, - ], - staleTime: SELECTOR_STALE, - getQueryKey: ({ context }: SelectorQueryArgs) => [ - 'selectors', - 'microsoft.excel.drives', - context.oauthCredential ?? 'none', - context.siteId ?? 'none', - ], - enabled: ({ context }) => Boolean(context.oauthCredential && context.siteId), - fetchList: async ({ context, signal }: SelectorQueryArgs) => { - const credentialId = ensureCredential(context, 'microsoft.excel.drives') - if (!context.siteId) { - throw new Error('Missing site ID for microsoft.excel.drives selector') - } - const data = await requestJson(selectorContracts.microsoftExcelDrivesSelectorContract, { - body: { - credential: credentialId, - workflowId: context.workflowId, - siteId: context.siteId, - }, - signal, - }) - return data.drives.map((drive) => ({ - id: drive.id, - label: drive.name, - })) - }, - fetchById: async ({ context, detailId, signal }: SelectorQueryArgs) => { - if (!detailId || !context.siteId) return null - const credentialId = ensureCredential(context, 'microsoft.excel.drives') - const data = await requestJson(selectorContracts.microsoftExcelDriveSelectorContract, { - body: { - credential: credentialId, - workflowId: context.workflowId, - siteId: context.siteId, - driveId: detailId, - }, - signal, - }) - const { drive } = data - if (!drive) return null - return { id: drive.id, label: drive.name } - }, - }, - 'microsoft.excel': { - key: 'microsoft.excel', - contracts: [selectorContracts.microsoftFilesSelectorContract], - staleTime: SELECTOR_STALE, - getQueryKey: ({ context, search }: SelectorQueryArgs) => [ - 'selectors', - 'microsoft.excel', - context.oauthCredential ?? 'none', - context.driveId ?? 'none', - search ?? '', - ], - enabled: ({ context }) => Boolean(context.oauthCredential), - fetchList: async ({ context, search, signal }: SelectorQueryArgs) => { - const credentialId = ensureCredential(context, 'microsoft.excel') - const data = await requestJson(selectorContracts.microsoftFilesSelectorContract, { - query: { - credentialId, - query: search, - driveId: context.driveId, - workflowId: context.workflowId, - fileType: 'excel', - }, - signal, - }) - return (data.files || []).map((file) => ({ - id: file.id, - label: file.name, - })) - }, - }, - 'microsoft.word': { - key: 'microsoft.word', - contracts: [selectorContracts.microsoftFilesSelectorContract], - staleTime: SELECTOR_STALE, - getQueryKey: ({ context, search }: SelectorQueryArgs) => [ - 'selectors', - 'microsoft.word', - context.oauthCredential ?? 'none', - context.driveId ?? 'none', - search ?? '', - ], - enabled: ({ context }) => Boolean(context.oauthCredential), - fetchList: async ({ context, search, signal }: SelectorQueryArgs) => { - const credentialId = ensureCredential(context, 'microsoft.word') - const data = await requestJson(selectorContracts.microsoftFilesSelectorContract, { - query: { - credentialId, - query: search, - driveId: context.driveId, - workflowId: context.workflowId, - fileType: 'word', - }, - signal, - }) - return (data.files || []).map((file) => ({ - id: file.id, - label: file.name, - })) - }, - }, -} satisfies Record< - Extract< - SelectorKey, - | 'microsoft.planner.plans' - | 'outlook.folders' - | 'outlook.calendars' - | 'microsoft.teams' - | 'microsoft.chats' - | 'microsoft.channels' - | 'microsoft.planner' - | 'onedrive.files' - | 'onedrive.folders' - | 'microsoft.excel.sheets' - | 'microsoft.excel.drives' - | 'microsoft.excel' - | 'microsoft.word' - >, - SelectorDefinition -> diff --git a/apps/sim/hooks/selectors/providers/monday/selectors.ts b/apps/sim/hooks/selectors/providers/monday/selectors.ts deleted file mode 100644 index 4822988aca9..00000000000 --- a/apps/sim/hooks/selectors/providers/monday/selectors.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { requestJson } from '@/lib/api/client/request' -import * as selectorContracts from '@/lib/api/contracts/selectors' -import { ensureCredential, SELECTOR_STALE } from '@/hooks/selectors/providers/shared' -import type { SelectorDefinition, SelectorKey, SelectorQueryArgs } from '@/hooks/selectors/types' - -export const mondaySelectors = { - 'monday.boards': { - key: 'monday.boards', - contracts: [selectorContracts.mondayBoardsSelectorContract], - staleTime: SELECTOR_STALE, - getQueryKey: ({ context }: SelectorQueryArgs) => [ - 'selectors', - 'monday.boards', - context.oauthCredential ?? 'none', - ], - enabled: ({ context }) => Boolean(context.oauthCredential), - fetchList: async ({ context, signal }: SelectorQueryArgs) => { - const credentialId = ensureCredential(context, 'monday.boards') - const data = await requestJson(selectorContracts.mondayBoardsSelectorContract, { - body: { credential: credentialId, workflowId: context.workflowId }, - signal, - }) - return (data.boards || []).map((board) => ({ - id: board.id, - label: board.name, - })) - }, - fetchById: async ({ context, detailId, signal }: SelectorQueryArgs) => { - if (!detailId) return null - const credentialId = ensureCredential(context, 'monday.boards') - const data = await requestJson(selectorContracts.mondayBoardsSelectorContract, { - body: { credential: credentialId, workflowId: context.workflowId }, - signal, - }) - const board = (data.boards || []).find((b) => b.id === detailId) ?? null - if (!board) return null - return { id: board.id, label: board.name } - }, - }, - 'monday.groups': { - key: 'monday.groups', - contracts: [selectorContracts.mondayGroupsSelectorContract], - staleTime: SELECTOR_STALE, - getQueryKey: ({ context }: SelectorQueryArgs) => [ - 'selectors', - 'monday.groups', - context.oauthCredential ?? 'none', - context.boardId ?? 'none', - ], - enabled: ({ context }) => Boolean(context.oauthCredential && context.boardId), - fetchList: async ({ context, signal }: SelectorQueryArgs) => { - const credentialId = ensureCredential(context, 'monday.groups') - if (!context.boardId) { - throw new Error('Missing board ID for monday.groups selector') - } - const data = await requestJson(selectorContracts.mondayGroupsSelectorContract, { - body: { - credential: credentialId, - boardId: context.boardId, - workflowId: context.workflowId, - }, - signal, - }) - return (data.groups || []).map((group) => ({ - id: group.id, - label: group.name, - })) - }, - fetchById: async ({ context, detailId, signal }: SelectorQueryArgs) => { - if (!detailId) return null - const credentialId = ensureCredential(context, 'monday.groups') - if (!context.boardId) return null - const data = await requestJson(selectorContracts.mondayGroupsSelectorContract, { - body: { - credential: credentialId, - boardId: context.boardId, - workflowId: context.workflowId, - }, - signal, - }) - const group = (data.groups || []).find((g) => g.id === detailId) ?? null - if (!group) return null - return { id: group.id, label: group.name } - }, - }, -} satisfies Record, SelectorDefinition> diff --git a/apps/sim/hooks/selectors/providers/netsuite/selectors.ts b/apps/sim/hooks/selectors/providers/netsuite/selectors.ts deleted file mode 100644 index f276b6a748b..00000000000 --- a/apps/sim/hooks/selectors/providers/netsuite/selectors.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { requestJson } from '@/lib/api/client/request' -import { - type NetSuiteObjectsSelectorBody, - type NetSuiteSelectorKind, - netsuiteObjectsSelectorContract, -} from '@/lib/api/contracts/selectors/netsuite' -import { ensureCredential, SELECTOR_STALE } from '@/hooks/selectors/providers/shared' -import type { - SelectorDefinition, - SelectorKey, - SelectorOption, - SelectorQueryArgs, -} from '@/hooks/selectors/types' - -type NetSuiteSelectorKey = Extract - -interface NetSuiteSelectorSpec { - kind: NetSuiteSelectorKind - requiresJob: boolean -} - -const NETSUITE_SELECTOR_SPECS: Record = { - 'netsuite.recordTypes': { kind: 'record_types', requiresJob: false }, - 'netsuite.asyncTasks': { kind: 'async_tasks', requiresJob: true }, -} - -function scopeSatisfied(spec: NetSuiteSelectorSpec, args: SelectorQueryArgs): boolean { - return Boolean( - args.context.oauthCredential && - args.context.workflowId && - (!spec.requiresJob || args.context.jobId) - ) -} - -function toOption(object: { id: string; label: string; detail: string | null }): SelectorOption { - return { - id: object.id, - label: object.label, - ...(object.detail ? { meta: { detail: object.detail } } : {}), - } -} - -function buildSelector(key: NetSuiteSelectorKey): SelectorDefinition { - const spec = NETSUITE_SELECTOR_SPECS[key] - - const fetchObjects = async ({ context, signal }: SelectorQueryArgs) => { - const credential = ensureCredential(context, key) - if (!context.workflowId) throw new Error(`Missing workflow ID for selector ${key}`) - - let body: NetSuiteObjectsSelectorBody - if (spec.kind === 'async_tasks') { - if (!context.jobId) throw new Error(`Missing job ID for selector ${key}`) - body = { - credential, - workflowId: context.workflowId, - kind: spec.kind, - jobId: context.jobId, - } - } else { - body = { credential, workflowId: context.workflowId, kind: spec.kind } - } - - return requestJson(netsuiteObjectsSelectorContract, { body, signal }) - } - - return { - key, - contracts: [netsuiteObjectsSelectorContract], - staleTime: SELECTOR_STALE, - getQueryKey: ({ context }: SelectorQueryArgs) => [ - 'selectors', - key, - context.workflowId ?? 'none', - context.oauthCredential ?? 'none', - ...(spec.requiresJob ? [context.jobId ?? 'none'] : []), - ], - enabled: (args) => scopeSatisfied(spec, args), - fetchList: async (args) => (await fetchObjects(args)).objects.map(toOption), - fetchById: async (args) => { - if (!args.detailId || !scopeSatisfied(spec, args)) return null - const match = (await fetchObjects(args)).objects.find((object) => object.id === args.detailId) - return match ? toOption(match) : null - }, - resolvesUnknownIds: true, - } -} - -export const netsuiteSelectors = { - 'netsuite.recordTypes': buildSelector('netsuite.recordTypes'), - 'netsuite.asyncTasks': buildSelector('netsuite.asyncTasks'), -} satisfies Record diff --git a/apps/sim/hooks/selectors/providers/notion/selectors.ts b/apps/sim/hooks/selectors/providers/notion/selectors.ts deleted file mode 100644 index 786a82e4a70..00000000000 --- a/apps/sim/hooks/selectors/providers/notion/selectors.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { requestJson } from '@/lib/api/client/request' -import * as selectorContracts from '@/lib/api/contracts/selectors' -import { ensureCredential, SELECTOR_STALE } from '@/hooks/selectors/providers/shared' -import type { SelectorDefinition, SelectorKey, SelectorQueryArgs } from '@/hooks/selectors/types' - -export const notionSelectors = { - 'notion.databases': { - key: 'notion.databases', - contracts: [selectorContracts.notionDatabasesSelectorContract], - staleTime: SELECTOR_STALE, - getQueryKey: ({ context }: SelectorQueryArgs) => [ - 'selectors', - 'notion.databases', - context.oauthCredential ?? 'none', - ], - enabled: ({ context }) => Boolean(context.oauthCredential), - fetchList: async ({ context, signal }: SelectorQueryArgs) => { - const credentialId = ensureCredential(context, 'notion.databases') - const data = await requestJson(selectorContracts.notionDatabasesSelectorContract, { - body: { credential: credentialId, workflowId: context.workflowId }, - signal, - }) - return (data.databases || []).map((db) => ({ - id: db.id, - label: db.name, - })) - }, - fetchById: async ({ context, detailId, signal }: SelectorQueryArgs) => { - if (!detailId) return null - const credentialId = ensureCredential(context, 'notion.databases') - const data = await requestJson(selectorContracts.notionDatabasesSelectorContract, { - body: { credential: credentialId, workflowId: context.workflowId }, - signal, - }) - const db = (data.databases || []).find((d) => d.id === detailId) ?? null - if (!db) return null - return { id: db.id, label: db.name } - }, - }, - 'notion.pages': { - key: 'notion.pages', - contracts: [selectorContracts.notionPagesSelectorContract], - staleTime: SELECTOR_STALE, - getQueryKey: ({ context }: SelectorQueryArgs) => [ - 'selectors', - 'notion.pages', - context.oauthCredential ?? 'none', - ], - enabled: ({ context }) => Boolean(context.oauthCredential), - fetchList: async ({ context, signal }: SelectorQueryArgs) => { - const credentialId = ensureCredential(context, 'notion.pages') - const data = await requestJson(selectorContracts.notionPagesSelectorContract, { - body: { credential: credentialId, workflowId: context.workflowId }, - signal, - }) - return (data.pages || []).map((page) => ({ - id: page.id, - label: page.name, - })) - }, - fetchById: async ({ context, detailId, signal }: SelectorQueryArgs) => { - if (!detailId) return null - const credentialId = ensureCredential(context, 'notion.pages') - const data = await requestJson(selectorContracts.notionPagesSelectorContract, { - body: { credential: credentialId, workflowId: context.workflowId }, - signal, - }) - const page = (data.pages || []).find((p) => p.id === detailId) ?? null - if (!page) return null - return { id: page.id, label: page.name } - }, - }, -} satisfies Record, SelectorDefinition> diff --git a/apps/sim/hooks/selectors/providers/pipedrive/selectors.ts b/apps/sim/hooks/selectors/providers/pipedrive/selectors.ts deleted file mode 100644 index 49f15bf9f3b..00000000000 --- a/apps/sim/hooks/selectors/providers/pipedrive/selectors.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { requestJson } from '@/lib/api/client/request' -import * as selectorContracts from '@/lib/api/contracts/selectors' -import { ensureCredential, SELECTOR_STALE } from '@/hooks/selectors/providers/shared' -import type { SelectorDefinition, SelectorKey, SelectorQueryArgs } from '@/hooks/selectors/types' - -export const pipedriveSelectors = { - 'pipedrive.pipelines': { - key: 'pipedrive.pipelines', - contracts: [selectorContracts.pipedrivePipelinesSelectorContract], - staleTime: SELECTOR_STALE, - getQueryKey: ({ context }: SelectorQueryArgs) => [ - 'selectors', - 'pipedrive.pipelines', - context.oauthCredential ?? 'none', - ], - enabled: ({ context }) => Boolean(context.oauthCredential), - fetchList: async ({ context, signal }: SelectorQueryArgs) => { - const credentialId = ensureCredential(context, 'pipedrive.pipelines') - const data = await requestJson(selectorContracts.pipedrivePipelinesSelectorContract, { - body: { credential: credentialId, workflowId: context.workflowId }, - signal, - }) - return (data.pipelines || []).map((p) => ({ - id: p.id, - label: p.name, - })) - }, - fetchById: async ({ context, detailId, signal }: SelectorQueryArgs) => { - if (!detailId) return null - const credentialId = ensureCredential(context, 'pipedrive.pipelines') - const data = await requestJson(selectorContracts.pipedrivePipelinesSelectorContract, { - body: { credential: credentialId, workflowId: context.workflowId }, - signal, - }) - const p = (data.pipelines || []).find((pl) => pl.id === detailId) ?? null - if (!p) return null - return { id: p.id, label: p.name } - }, - }, -} satisfies Record, SelectorDefinition> diff --git a/apps/sim/hooks/selectors/providers/shared.ts b/apps/sim/hooks/selectors/providers/shared.ts deleted file mode 100644 index 58d5e3bd90f..00000000000 --- a/apps/sim/hooks/selectors/providers/shared.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { SelectorContext, SelectorKey } from '@/hooks/selectors/types' - -export const SELECTOR_STALE = 60 * 1000 - -/** - * The shorter stale window carried by `google.drive`, `jira.issues` and - * `webflow.items`, whose listings turn over faster than {@link SELECTOR_STALE} - * assumes. - * - * Not every search-backed selector uses it — several still sit on - * {@link SELECTOR_STALE} — so treat this as the value those three share rather - * than a rule about search. - */ -export const SELECTOR_SEARCH_STALE = 15 * 1000 - -export const ensureCredential = (context: SelectorContext, key: SelectorKey): string => { - if (!context.oauthCredential) { - throw new Error(`Missing credential for selector ${key}`) - } - return context.oauthCredential -} - -export const ensureDomain = (context: SelectorContext, key: SelectorKey): string => { - if (!context.domain) { - throw new Error(`Missing domain for selector ${key}`) - } - return context.domain -} - -export const ensureKnowledgeBase = (context: SelectorContext): string => { - if (!context.knowledgeBaseId) { - throw new Error('Missing knowledge base id') - } - return context.knowledgeBaseId -} diff --git a/apps/sim/hooks/selectors/providers/sharepoint/selectors.ts b/apps/sim/hooks/selectors/providers/sharepoint/selectors.ts deleted file mode 100644 index 371d2669750..00000000000 --- a/apps/sim/hooks/selectors/providers/sharepoint/selectors.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { requestJson } from '@/lib/api/client/request' -import * as selectorContracts from '@/lib/api/contracts/selectors' -import { ensureCredential, SELECTOR_STALE } from '@/hooks/selectors/providers/shared' -import type { SelectorDefinition, SelectorKey, SelectorQueryArgs } from '@/hooks/selectors/types' - -export const sharepointSelectors = { - 'sharepoint.lists': { - key: 'sharepoint.lists', - contracts: [selectorContracts.sharepointListsSelectorContract], - staleTime: SELECTOR_STALE, - getQueryKey: ({ context }: SelectorQueryArgs) => [ - 'selectors', - 'sharepoint.lists', - context.oauthCredential ?? 'none', - context.siteId ?? 'none', - ], - enabled: ({ context }) => Boolean(context.oauthCredential && context.siteId), - fetchList: async ({ context, signal }: SelectorQueryArgs) => { - const credentialId = ensureCredential(context, 'sharepoint.lists') - if (!context.siteId) throw new Error('Missing site ID for sharepoint.lists selector') - const data = await requestJson(selectorContracts.sharepointListsSelectorContract, { - body: { - credential: credentialId, - workflowId: context.workflowId, - siteId: context.siteId, - }, - signal, - }) - return (data.lists || []).map((list) => ({ id: list.id, label: list.displayName })) - }, - fetchById: async ({ context, detailId, signal }: SelectorQueryArgs) => { - if (!detailId || !context.siteId) return null - const credentialId = ensureCredential(context, 'sharepoint.lists') - const data = await requestJson(selectorContracts.sharepointListsSelectorContract, { - body: { - credential: credentialId, - workflowId: context.workflowId, - siteId: context.siteId, - }, - signal, - }) - const list = (data.lists || []).find((l) => l.id === detailId) ?? null - if (!list) return null - return { id: list.id, label: list.displayName } - }, - }, - 'sharepoint.sites': { - key: 'sharepoint.sites', - contracts: [selectorContracts.sharepointSitesSelectorContract], - staleTime: SELECTOR_STALE, - getQueryKey: ({ context }: SelectorQueryArgs) => [ - 'selectors', - 'sharepoint.sites', - context.oauthCredential ?? 'none', - ], - enabled: ({ context }) => Boolean(context.oauthCredential), - fetchList: async ({ context, signal }: SelectorQueryArgs) => { - const credentialId = ensureCredential(context, 'sharepoint.sites') - const data = await requestJson(selectorContracts.sharepointSitesSelectorContract, { - body: { - credential: credentialId, - workflowId: context.workflowId, - }, - signal, - }) - return (data.files || []).map((file) => ({ - id: file.id, - label: file.name, - })) - }, - fetchById: async ({ context, detailId, signal }: SelectorQueryArgs) => { - if (!detailId) return null - const credentialId = ensureCredential(context, 'sharepoint.sites') - const data = await requestJson(selectorContracts.sharepointSitesSelectorContract, { - body: { - credential: credentialId, - workflowId: context.workflowId, - }, - signal, - }) - const site = (data.files || []).find((f) => f.id === detailId) ?? null - if (!site) return null - return { id: site.id, label: site.name } - }, - }, -} satisfies Record< - Extract, - SelectorDefinition -> diff --git a/apps/sim/hooks/selectors/providers/sim/selectors.ts b/apps/sim/hooks/selectors/providers/sim/selectors.ts deleted file mode 100644 index 178948897d4..00000000000 --- a/apps/sim/hooks/selectors/providers/sim/selectors.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { getColumnId } from '@/lib/table/column-keys' -import { getQueryClient } from '@/app/_shell/providers/get-query-client' -import { getTableDetailQueryOptions } from '@/hooks/queries/tables' -import { getFolderMap } from '@/hooks/queries/utils/folder-cache' -import { collectDuplicateNames, disambiguateLabelByFolder } from '@/hooks/queries/utils/folder-tree' -import { getWorkflowById, getWorkflows } from '@/hooks/queries/utils/workflow-cache' -import { getWorkflowListQueryOptions } from '@/hooks/queries/utils/workflow-list-query' -import { SELECTOR_STALE } from '@/hooks/selectors/providers/shared' -import { selectorKeys } from '@/hooks/selectors/query-keys' -import type { - SelectorDefinition, - SelectorKey, - SelectorOption, - SelectorQueryArgs, -} from '@/hooks/selectors/types' -import type { WorkflowMetadata } from '@/stores/workflows/registry/types' - -/** Matches the workflow list's own fallback for an unnamed workflow. */ -function workflowBaseLabel(workflow: WorkflowMetadata): string { - return workflow.name || `Workflow ${workflow.id.slice(0, 8)}` -} - -/** - * The table's current columns. `fetchQuery` (not `ensureQueryData`) so a detail - * entry the column mutations have already invalidated is refetched instead of - * served as-is — otherwise a column deleted earlier in the session stays - * pickable until a reload. - */ -async function getTableColumns(context: SelectorQueryArgs['context']) { - if (!context.workspaceId || !context.tableId) return [] - const table = await getQueryClient().fetchQuery( - getTableDetailQueryOptions(context.workspaceId, context.tableId) - ) - return table.schema?.columns ?? [] -} - -/** - * `table.columns`/`table.outputColumns` derive their options from the table - * detail query, which already tracks staleness and invalidation. Caching the - * selector result on top of that with the shared `SELECTOR_STALE` would bring - * back the staleness `getTableColumns` exists to avoid: no column mutation - * invalidates the selector's own key, so a deleted column would stay pickable - * (and keep its label on the canvas) for up to a minute. Always defer to the - * inner fetch, which is a cache hit unless the detail was invalidated. - */ -const TABLE_COLUMN_SELECTOR_STALE = 0 - -export const simSelectors = { - 'sim.workflows': { - key: 'sim.workflows', - staleTime: SELECTOR_STALE, - getQueryKey: ({ context }: SelectorQueryArgs) => - context.workspaceId - ? selectorKeys.simWorkflows(context.workspaceId, context.excludeWorkflowId) - : [...selectorKeys.all, 'sim.workflows', 'none', context.excludeWorkflowId ?? 'none'], - enabled: ({ context }) => Boolean(context.workspaceId), - fetchList: async ({ context, signal }: SelectorQueryArgs): Promise => { - if (!context.workspaceId) return [] - await getQueryClient().ensureQueryData(getWorkflowListQueryOptions(context.workspaceId)) - const workflows = getWorkflows(context.workspaceId) - const folders = getFolderMap(context.workspaceId) - const duplicateNames = collectDuplicateNames(workflows.map(workflowBaseLabel)) - return workflows - .filter((w) => w.id !== context.excludeWorkflowId) - .map((w) => ({ - id: w.id, - label: disambiguateLabelByFolder( - workflowBaseLabel(w), - w.folderId, - folders, - duplicateNames - ), - })) - .sort((a, b) => a.label.localeCompare(b.label)) - }, - fetchById: async ({ - context, - detailId, - signal, - }: SelectorQueryArgs): Promise => { - if (!detailId || !context.workspaceId) return null - await getQueryClient().ensureQueryData(getWorkflowListQueryOptions(context.workspaceId)) - const workflow = getWorkflowById(context.workspaceId, detailId) - if (!workflow) return null - const workflows = getWorkflows(context.workspaceId) - const folders = getFolderMap(context.workspaceId) - const duplicateNames = collectDuplicateNames(workflows.map(workflowBaseLabel)) - return { - id: detailId, - label: disambiguateLabelByFolder( - workflowBaseLabel(workflow), - workflow.folderId, - folders, - duplicateNames - ), - } - }, - }, - 'table.columns': { - key: 'table.columns', - staleTime: TABLE_COLUMN_SELECTOR_STALE, - getQueryKey: ({ context, search }: SelectorQueryArgs) => [ - ...selectorKeys.all, - 'table.columns', - context.workspaceId ?? 'none', - context.tableId ?? 'none', - search ?? '', - ], - enabled: ({ context }) => Boolean(context.workspaceId && context.tableId), - fetchList: async ({ context }: SelectorQueryArgs): Promise => { - const columns = await getTableColumns(context) - return columns - .filter((col) => col.unique) - .map((col) => ({ id: getColumnId(col), label: col.name })) - }, - fetchById: async ({ context, detailId }: SelectorQueryArgs): Promise => { - if (!detailId) return null - const columns = await getTableColumns(context) - const col = columns.find((c) => getColumnId(c) === detailId) - return col ? { id: getColumnId(col), label: col.name } : null - }, - }, - 'table.outputColumns': { - key: 'table.outputColumns', - staleTime: TABLE_COLUMN_SELECTOR_STALE, - getQueryKey: ({ context }: SelectorQueryArgs) => [ - ...selectorKeys.all, - 'table.outputColumns', - context.workspaceId ?? 'none', - context.tableId ?? 'none', - ], - enabled: ({ context }) => Boolean(context.workspaceId && context.tableId), - fetchList: async ({ context }: SelectorQueryArgs): Promise => { - const columns = await getTableColumns(context) - return columns.map((col) => ({ id: getColumnId(col), label: col.name })) - }, - fetchById: async ({ context, detailId }: SelectorQueryArgs): Promise => { - if (!detailId) return null - const columns = await getTableColumns(context) - const col = columns.find((column) => getColumnId(column) === detailId) - return col ? { id: getColumnId(col), label: col.name } : null - }, - }, -} satisfies Record< - Extract, - SelectorDefinition -> diff --git a/apps/sim/hooks/selectors/providers/slack/selectors.ts b/apps/sim/hooks/selectors/providers/slack/selectors.ts deleted file mode 100644 index ad5474dc912..00000000000 --- a/apps/sim/hooks/selectors/providers/slack/selectors.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { requestJson } from '@/lib/api/client/request' -import * as selectorContracts from '@/lib/api/contracts/selectors' -import { ensureCredential, SELECTOR_STALE } from '@/hooks/selectors/providers/shared' -import type { SelectorDefinition, SelectorKey, SelectorQueryArgs } from '@/hooks/selectors/types' - -export const slackSelectors = { - 'slack.channels': { - key: 'slack.channels', - contracts: [selectorContracts.slackChannelsSelectorContract], - staleTime: SELECTOR_STALE, - getQueryKey: ({ context }: SelectorQueryArgs) => [ - 'selectors', - 'slack.channels', - context.oauthCredential ?? 'none', - ], - enabled: ({ context }) => Boolean(context.oauthCredential), - fetchList: async ({ context, signal }: SelectorQueryArgs) => { - const credentialId = ensureCredential(context, 'slack.channels') - const data = await requestJson(selectorContracts.slackChannelsSelectorContract, { - body: { - credential: credentialId, - workflowId: context.workflowId, - }, - signal, - }) - return (data.channels || []).map((channel) => ({ - id: channel.id, - label: `#${channel.name}`, - })) - }, - }, - 'slack.users': { - key: 'slack.users', - contracts: [selectorContracts.slackUsersSelectorContract], - staleTime: SELECTOR_STALE, - getQueryKey: ({ context }: SelectorQueryArgs) => [ - 'selectors', - 'slack.users', - context.oauthCredential ?? 'none', - ], - enabled: ({ context }) => Boolean(context.oauthCredential), - fetchList: async ({ context, signal }: SelectorQueryArgs) => { - const credentialId = ensureCredential(context, 'slack.users') - const data = await requestJson(selectorContracts.slackUsersSelectorContract, { - body: { - credential: credentialId, - workflowId: context.workflowId, - }, - signal, - }) - return data.users.map((user) => ({ - id: user.id, - label: user.real_name || user.name, - })) - }, - }, -} satisfies Record, SelectorDefinition> diff --git a/apps/sim/hooks/selectors/providers/snowflake/selectors.ts b/apps/sim/hooks/selectors/providers/snowflake/selectors.ts deleted file mode 100644 index 676ea0ffc3e..00000000000 --- a/apps/sim/hooks/selectors/providers/snowflake/selectors.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { requestJson } from '@/lib/api/client/request' -import { snowflakeObjectsSelectorContract } from '@/lib/api/contracts/selectors/snowflake' -import { ensureCredential, SELECTOR_STALE } from '@/hooks/selectors/providers/shared' -import type { - SelectorDefinition, - SelectorKey, - SelectorOption, - SelectorQueryArgs, -} from '@/hooks/selectors/types' -import type { SnowflakeSelectorKind } from '@/tools/snowflake/selector-kinds' - -type SnowflakeSelectorKey = Extract - -/** What each picker needs in scope before it can list anything. */ -type SnowflakeSelectorScopeLevel = 'account' | 'database' | 'schema' - -interface SnowflakeSelectorSpec { - kind: SnowflakeSelectorKind - scope: SnowflakeSelectorScopeLevel -} - -const SNOWFLAKE_SELECTOR_SPECS: Record = { - 'snowflake.databases': { kind: 'databases', scope: 'account' }, - 'snowflake.warehouses': { kind: 'warehouses', scope: 'account' }, - 'snowflake.roles': { kind: 'roles', scope: 'account' }, - 'snowflake.schemas': { kind: 'schemas', scope: 'database' }, - 'snowflake.tables': { kind: 'tables', scope: 'schema' }, - 'snowflake.fileFormats': { kind: 'file_formats', scope: 'schema' }, - 'snowflake.procedures': { kind: 'procedures', scope: 'schema' }, -} - -/** - * Snowflake object names are the values the tools send back as identifiers, so - * the option id IS the name. The detail column is shown only when it adds - * something the name doesn't already say. - */ -function toOption(object: { name: string; detail: string | null }): SelectorOption { - return { - id: object.name, - label: object.detail ? `${object.name} — ${object.detail}` : object.name, - meta: { name: object.name, ...(object.detail ? { detail: object.detail } : {}) }, - } -} - -function scopeSatisfied(spec: SnowflakeSelectorSpec, args: SelectorQueryArgs): boolean { - const { context } = args - if (!context.oauthCredential) return false - if (spec.scope === 'account') return true - if (!context.database) return false - return spec.scope === 'database' || Boolean(context.schema) -} - -function buildSelector(key: SnowflakeSelectorKey): SelectorDefinition { - const spec = SNOWFLAKE_SELECTOR_SPECS[key] - - const fetchObjects = async ({ context, signal }: SelectorQueryArgs) => { - const credentialId = ensureCredential(context, key) - const data = await requestJson(snowflakeObjectsSelectorContract, { - body: { - credential: credentialId, - workflowId: context.workflowId, - kind: spec.kind, - database: spec.scope === 'account' ? undefined : context.database, - schema: spec.scope === 'schema' ? context.schema : undefined, - }, - signal, - }) - return data.objects - } - - return { - key, - contracts: [snowflakeObjectsSelectorContract], - staleTime: SELECTOR_STALE, - getQueryKey: ({ context }: SelectorQueryArgs) => [ - 'selectors', - key, - context.oauthCredential ?? 'none', - context.database ?? 'none', - context.schema ?? 'none', - ], - enabled: (args: SelectorQueryArgs) => scopeSatisfied(spec, args), - fetchList: async (args: SelectorQueryArgs) => (await fetchObjects(args)).map(toOption), - fetchById: async (args: SelectorQueryArgs) => { - if (!args.detailId || !scopeSatisfied(spec, args)) return null - const match = (await fetchObjects(args)).find((object) => object.name === args.detailId) - return match ? toOption(match) : null - }, - // Snowflake exposes no lookup-by-name endpoint; `fetchById` filters the - // same listing, so an id that does not exist resolves to null rather than - // erroring. - resolvesUnknownIds: true, - } -} - -export const snowflakeSelectors = { - 'snowflake.databases': buildSelector('snowflake.databases'), - 'snowflake.schemas': buildSelector('snowflake.schemas'), - 'snowflake.tables': buildSelector('snowflake.tables'), - 'snowflake.warehouses': buildSelector('snowflake.warehouses'), - 'snowflake.roles': buildSelector('snowflake.roles'), - 'snowflake.fileFormats': buildSelector('snowflake.fileFormats'), - 'snowflake.procedures': buildSelector('snowflake.procedures'), -} satisfies Record diff --git a/apps/sim/hooks/selectors/providers/trello/selectors.ts b/apps/sim/hooks/selectors/providers/trello/selectors.ts deleted file mode 100644 index adb800411df..00000000000 --- a/apps/sim/hooks/selectors/providers/trello/selectors.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { requestJson } from '@/lib/api/client/request' -import * as selectorContracts from '@/lib/api/contracts/selectors' -import { ensureCredential, SELECTOR_STALE } from '@/hooks/selectors/providers/shared' -import type { SelectorDefinition, SelectorKey, SelectorQueryArgs } from '@/hooks/selectors/types' - -export const trelloSelectors = { - 'trello.boards': { - key: 'trello.boards', - contracts: [selectorContracts.trelloBoardsSelectorContract], - staleTime: SELECTOR_STALE, - getQueryKey: ({ context }: SelectorQueryArgs) => [ - 'selectors', - 'trello.boards', - context.oauthCredential ?? 'none', - ], - enabled: ({ context }) => Boolean(context.oauthCredential), - fetchList: async ({ context, signal }: SelectorQueryArgs) => { - const credentialId = ensureCredential(context, 'trello.boards') - const data = await requestJson(selectorContracts.trelloBoardsSelectorContract, { - body: { credential: credentialId, workflowId: context.workflowId }, - signal, - }) - return (data.boards || []) - .filter((board) => !board.closed) - .map((board) => ({ id: board.id, label: board.name })) - }, - fetchById: async ({ context, detailId, signal }: SelectorQueryArgs) => { - if (!detailId) return null - const credentialId = ensureCredential(context, 'trello.boards') - const data = await requestJson(selectorContracts.trelloBoardsSelectorContract, { - body: { credential: credentialId, workflowId: context.workflowId }, - signal, - }) - const board = (data.boards || []).find((b) => b.id === detailId) ?? null - if (!board) return null - return { id: board.id, label: board.name } - }, - }, -} satisfies Record, SelectorDefinition> diff --git a/apps/sim/hooks/selectors/providers/wealthbox/selectors.ts b/apps/sim/hooks/selectors/providers/wealthbox/selectors.ts deleted file mode 100644 index 8e9d93b0fca..00000000000 --- a/apps/sim/hooks/selectors/providers/wealthbox/selectors.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { requestJson } from '@/lib/api/client/request' -import * as selectorContracts from '@/lib/api/contracts/selectors' -import { ensureCredential, SELECTOR_STALE } from '@/hooks/selectors/providers/shared' -import type { SelectorDefinition, SelectorKey, SelectorQueryArgs } from '@/hooks/selectors/types' - -export const wealthboxSelectors = { - 'wealthbox.contacts': { - key: 'wealthbox.contacts', - contracts: [selectorContracts.wealthboxItemsSelectorContract], - staleTime: SELECTOR_STALE, - getQueryKey: ({ context, search }: SelectorQueryArgs) => [ - 'selectors', - 'wealthbox.contacts', - context.oauthCredential ?? 'none', - search ?? '', - ], - enabled: ({ context }) => Boolean(context.oauthCredential), - fetchList: async ({ context, search, signal }: SelectorQueryArgs) => { - const credentialId = ensureCredential(context, 'wealthbox.contacts') - const data = await requestJson(selectorContracts.wealthboxItemsSelectorContract, { - query: { credentialId, type: 'contact', query: search ?? '' }, - signal, - }) - return (data.items || []).map((item) => ({ - id: item.id, - label: item.name, - })) - }, - }, -} satisfies Record, SelectorDefinition> diff --git a/apps/sim/hooks/selectors/providers/webflow/selectors.ts b/apps/sim/hooks/selectors/providers/webflow/selectors.ts deleted file mode 100644 index 65eeec52304..00000000000 --- a/apps/sim/hooks/selectors/providers/webflow/selectors.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { requestJson } from '@/lib/api/client/request' -import * as selectorContracts from '@/lib/api/contracts/selectors' -import { - ensureCredential, - SELECTOR_SEARCH_STALE, - SELECTOR_STALE, -} from '@/hooks/selectors/providers/shared' -import type { SelectorDefinition, SelectorKey, SelectorQueryArgs } from '@/hooks/selectors/types' - -export const webflowSelectors = { - 'webflow.sites': { - key: 'webflow.sites', - contracts: [selectorContracts.webflowSitesSelectorContract], - staleTime: SELECTOR_STALE, - getQueryKey: ({ context }: SelectorQueryArgs) => [ - 'selectors', - 'webflow.sites', - context.oauthCredential ?? 'none', - ], - enabled: ({ context }) => Boolean(context.oauthCredential), - fetchList: async ({ context, signal }: SelectorQueryArgs) => { - const credentialId = ensureCredential(context, 'webflow.sites') - const data = await requestJson(selectorContracts.webflowSitesSelectorContract, { - body: { credential: credentialId, workflowId: context.workflowId }, - signal, - }) - return (data.sites || []).map((site) => ({ - id: site.id, - label: site.name, - })) - }, - }, - 'webflow.collections': { - key: 'webflow.collections', - contracts: [selectorContracts.webflowCollectionsSelectorContract], - staleTime: SELECTOR_STALE, - getQueryKey: ({ context }: SelectorQueryArgs) => [ - 'selectors', - 'webflow.collections', - context.oauthCredential ?? 'none', - context.siteId ?? 'none', - ], - enabled: ({ context }) => Boolean(context.oauthCredential && context.siteId), - fetchList: async ({ context, signal }: SelectorQueryArgs) => { - const credentialId = ensureCredential(context, 'webflow.collections') - if (!context.siteId) { - throw new Error('Missing site ID for webflow.collections selector') - } - const data = await requestJson(selectorContracts.webflowCollectionsSelectorContract, { - body: { - credential: credentialId, - workflowId: context.workflowId, - siteId: context.siteId, - }, - signal, - }) - return (data.collections || []).map((collection) => ({ - id: collection.id, - label: collection.name, - })) - }, - }, - 'webflow.items': { - key: 'webflow.items', - contracts: [selectorContracts.webflowItemsSelectorContract], - staleTime: SELECTOR_SEARCH_STALE, - getQueryKey: ({ context, search }: SelectorQueryArgs) => [ - 'selectors', - 'webflow.items', - context.oauthCredential ?? 'none', - context.collectionId ?? 'none', - search ?? '', - ], - enabled: ({ context }) => Boolean(context.oauthCredential && context.collectionId), - fetchList: async ({ context, search, signal }: SelectorQueryArgs) => { - const credentialId = ensureCredential(context, 'webflow.items') - if (!context.collectionId) { - throw new Error('Missing collection ID for webflow.items selector') - } - const data = await requestJson(selectorContracts.webflowItemsSelectorContract, { - body: { - credential: credentialId, - workflowId: context.workflowId, - collectionId: context.collectionId, - search, - }, - signal, - }) - return (data.items || []).map((item) => ({ - id: item.id, - label: item.name, - })) - }, - }, -} satisfies Record< - Extract, - SelectorDefinition -> diff --git a/apps/sim/hooks/selectors/providers/workspace/selectors.ts b/apps/sim/hooks/selectors/providers/workspace/selectors.ts deleted file mode 100644 index 62f7314cc4a..00000000000 --- a/apps/sim/hooks/selectors/providers/workspace/selectors.ts +++ /dev/null @@ -1,258 +0,0 @@ -import { getCredentialGroupProviderService } from '@/lib/credential-groups/providers' -import { selectRawMountableSecretNames } from '@/lib/credentials/secret-mount-options' -import { fetchWorkspaceEnvironment } from '@/lib/environment/api' -import { getServiceConfigByProviderId } from '@/lib/oauth/utils' -import { getQueryClient } from '@/app/_shell/providers/get-query-client' -import { environmentKeys, WORKSPACE_ENVIRONMENT_STALE_TIME } from '@/hooks/queries/environment' -import { getSandboxListQueryOptions } from '@/hooks/queries/sandboxes' -import { - CREDENTIAL_GROUP_LIST_STALE_TIME, - credentialGroupKeys, - fetchCredentialGroupList, -} from '@/hooks/queries/utils/credential-group-queries' -import { workspaceCredentialKeys } from '@/hooks/queries/utils/credential-keys' -import { - fetchWorkspaceCredentialList, - WORKSPACE_CREDENTIAL_LIST_STALE_TIME, -} from '@/hooks/queries/utils/fetch-workspace-credentials' -import { SELECTOR_STALE } from '@/hooks/selectors/providers/shared' -import type { - SelectorContext, - SelectorDefinition, - SelectorKey, - SelectorOption, - SelectorQueryArgs, -} from '@/hooks/selectors/types' - -/** - * Workspace-scoped option lists: things a block picks from its OWN workspace rather than from - * a third-party account. They are selectors for the same reason the credential-scoped ones - * are — a per-block fetcher reading the active-workspace store only works on the canvas — but - * their context key is `workspaceId` instead of `oauthCredential`. - */ - -function workspaceCredentials(workspaceId: string) { - return getQueryClient().fetchQuery({ - queryKey: workspaceCredentialKeys.list(workspaceId), - queryFn: ({ signal }: { signal?: AbortSignal }) => - fetchWorkspaceCredentialList(workspaceId, signal), - staleTime: WORKSPACE_CREDENTIAL_LIST_STALE_TIME, - }) -} - -function credentialGroups(workspaceId: string) { - return getQueryClient().fetchQuery({ - queryKey: credentialGroupKeys.list(workspaceId), - queryFn: ({ signal }: { signal?: AbortSignal }) => - fetchCredentialGroupList(workspaceId, signal), - staleTime: CREDENTIAL_GROUP_LIST_STALE_TIME, - }) -} - -function workspaceScoped( - key: SelectorKey, - fetchList: (workspaceId: string, context: SelectorContext) => Promise, - extraKey?: (context: SelectorContext) => string -): SelectorDefinition { - return { - key, - staleTime: SELECTOR_STALE, - getQueryKey: ({ context }: SelectorQueryArgs) => [ - 'selectors', - key, - context.workspaceId ?? 'none', - ...(extraKey ? [extraKey(context)] : []), - ], - enabled: ({ context }) => Boolean(context.workspaceId), - fetchList: ({ context }: SelectorQueryArgs) => - context.workspaceId ? fetchList(context.workspaceId, context) : Promise.resolve([]), - } -} - -export const workspaceSelectors = { - /** Distinct OAuth providers the workspace holds a credential for. */ - 'workspace.credentialProviders': { - ...workspaceScoped('workspace.credentialProviders', async (workspaceId) => { - const credentials = await workspaceCredentials(workspaceId) - const seen = new Set() - const options: SelectorOption[] = [] - for (const credential of credentials) { - if (credential.type !== 'oauth' || !credential.providerId) continue - if (seen.has(credential.providerId)) continue - seen.add(credential.providerId) - const service = getServiceConfigByProviderId(credential.providerId) - options.push({ id: credential.providerId, label: service?.name ?? credential.providerId }) - } - return options.sort((a, b) => a.label.localeCompare(b.label)) - }), - // Resolves a stored provider id with no list fetch at all — the service registry is local. - fetchById: async ({ detailId }: SelectorQueryArgs) => { - if (!detailId) return null - const service = getServiceConfigByProviderId(detailId) - return { id: detailId, label: service?.name ?? detailId } - }, - }, - 'workspace.credentialGroups': { - ...workspaceScoped('workspace.credentialGroups', async (workspaceId) => { - const groups = await credentialGroups(workspaceId) - return groups - .filter((group) => group.status === 'active') - .map((group) => ({ id: group.id, label: group.name })) - .sort((a, b) => a.label.localeCompare(b.label)) - }), - fetchById: async ({ context, detailId }: SelectorQueryArgs) => { - if (!context.workspaceId || !detailId) return null - const group = (await credentialGroups(context.workspaceId)).find( - (candidate) => candidate.id === detailId - ) - return group ? { id: group.id, label: group.name } : null - }, - }, - /** Providers represented inside ONE credential group, for its per-provider filter. */ - 'workspace.credentialGroupProviders': { - ...workspaceScoped( - 'workspace.credentialGroupProviders', - async (workspaceId, context) => { - if (!context.credentialGroupId) return [] - const group = (await credentialGroups(workspaceId)).find( - (candidate) => candidate.id === context.credentialGroupId - ) - if (!group) return [] - return group.options - .filter((option) => option.status === 'active') - .map((option) => { - const service = getCredentialGroupProviderService(option.provider) - return { id: service.providerId, label: service.name } - }) - .sort((a, b) => a.label.localeCompare(b.label)) - }, - (context) => context.credentialGroupId ?? 'none' - ), - /** - * Resolves one stored provider id to its service name. The field is multi-select, so the - * canvas card summarises several stored ids at once and needs each label before (or - * without) the full list — which is what `useDynamicSubBlockOptionDisplayName` asks for. - */ - fetchById: async ({ context, detailId }: SelectorQueryArgs) => { - if (!context.workspaceId || !context.credentialGroupId || !detailId) return null - const group = (await credentialGroups(context.workspaceId)).find( - (candidate) => candidate.id === context.credentialGroupId - ) - const option = group?.options.find( - (candidate) => - candidate.status === 'active' && - getCredentialGroupProviderService(candidate.provider).providerId === detailId - ) - if (!option) return null - const service = getCredentialGroupProviderService(option.provider) - return { id: service.providerId, label: service.name } - }, - }, - /** - * Secret NAMES the workspace can resolve. Names only — values stay server-side and are - * injected at execution. Both halves come from the one workspace-environment response, the - * client mirror of `getEffectiveDecryptedEnv`, so this picker and the `{{VAR}}` autocomplete - * can never disagree about what exists. - */ - 'workspace.secretNames': workspaceScoped('workspace.secretNames', async (workspaceId) => { - const environment = await getQueryClient().fetchQuery({ - queryKey: environmentKeys.workspace(workspaceId), - queryFn: ({ signal }: { signal?: AbortSignal }) => - fetchWorkspaceEnvironment(workspaceId, signal), - staleTime: WORKSPACE_ENVIRONMENT_STALE_TIME, - }) - const names = new Set([ - ...Object.keys(environment?.workspace ?? {}), - ...Object.keys(environment?.personal ?? {}), - ]) - return [...names].sort().map((name) => ({ id: name, label: name })) - }), - /** Only the secret names the current actor may mount as PLAINTEXT into Copilot code. */ - 'workspace.rawSecretNames': workspaceScoped('workspace.rawSecretNames', async (workspaceId) => { - const credentials = await workspaceCredentials(workspaceId) - return selectRawMountableSecretNames(credentials).map((name) => ({ id: name, label: name })) - }), - /** - * Sandboxes a Function block can run in, narrowed to the language its sibling selects — a - * Python block must never be offered an npm sandbox. `shell` runs anywhere. - */ - 'workspace.sandboxes': { - ...workspaceScoped( - 'workspace.sandboxes', - async (workspaceId, context) => { - const { sandboxes } = await getQueryClient().fetchQuery( - getSandboxListQueryOptions(workspaceId) - ) - const language = context.language - return sandboxes - .filter((sandbox) => !language || language === 'shell' || sandbox.language === language) - .map((sandbox) => ({ id: sandbox.id, label: sandbox.name })) - }, - (context) => context.language ?? 'any' - ), - /** - * A selection left over from before a language switch is still shown, flagged rather than - * hidden: returning `null` would drop the field to its placeholder while the value stayed - * stored and stayed fatal at execution — cleared-looking, still broken, nothing to point at. - */ - fetchById: async ({ context, detailId }: SelectorQueryArgs) => { - if (!context.workspaceId || !detailId) return null - const { sandboxes } = await getQueryClient().fetchQuery( - getSandboxListQueryOptions(context.workspaceId) - ) - const sandbox = sandboxes.find((candidate) => candidate.id === detailId) - if (!sandbox) return null - const option = { id: sandbox.id, label: sandbox.name } - const language = context.language - if ((language === 'python' || language === 'javascript') && sandbox.language !== language) { - return { ...option, label: `${option.label} · wrong language for this block` } - } - return option - }, - }, - /** - * The trigger vocabulary the Logs page filter offers, so the Logs block and that page name a - * run's origin identically. Workspace-independent, but registered here because it is the - * Logs block's list. - * - * The registry is reached lazily: `getTriggerOptions` reads the block and trigger registries, - * and importing it eagerly from a module block definitions import would close a cycle. - * Entries sharing a label merge into one comma-joined id, because the filter is a - * comma-separated list end to end and two identical rows would be unselectable apart. - */ - 'workspace.triggerTypes': { - key: 'workspace.triggerTypes', - staleTime: SELECTOR_STALE, - getQueryKey: () => ['selectors', 'workspace.triggerTypes'], - fetchList: async () => { - const { getTriggerOptions } = await import('@/lib/logs/get-trigger-options') - const valuesByLabel = new Map() - for (const option of getTriggerOptions()) { - const values = valuesByLabel.get(option.label) - if (values) values.push(option.value) - else valuesByLabel.set(option.label, [option.value]) - } - return Array.from(valuesByLabel, ([label, values]) => ({ id: values.join(','), label })) - }, - }, -} satisfies Partial> - -/** - * The OpenRouter embedding catalog. Workspace-independent — the list is the same for everyone - * — but a selector rather than a static array because it is fetched, and a fetched list has to - * be reachable from every surface, not just the canvas. - */ -export const providerSelectors = { - 'providers.openrouterEmbeddingModels': { - key: 'providers.openrouterEmbeddingModels', - staleTime: SELECTOR_STALE, - getQueryKey: () => ['selectors', 'providers.openrouterEmbeddingModels'], - fetchList: async () => { - const { providerModelsQueryOptions } = await import('@/hooks/queries/providers') - const { models } = await getQueryClient().fetchQuery( - providerModelsQueryOptions('openrouter-embeddings') - ) - return models.map((model: string) => ({ id: model, label: model })) - }, - }, -} satisfies Partial> diff --git a/apps/sim/hooks/selectors/providers/zoho-desk/selectors.test.ts b/apps/sim/hooks/selectors/providers/zoho-desk/selectors.test.ts deleted file mode 100644 index 846a45a6058..00000000000 --- a/apps/sim/hooks/selectors/providers/zoho-desk/selectors.test.ts +++ /dev/null @@ -1,188 +0,0 @@ -/** - * @vitest-environment node - */ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { mockRequestJson } = vi.hoisted(() => ({ mockRequestJson: vi.fn() })) - -vi.mock('@/lib/api/client/request', () => ({ requestJson: mockRequestJson })) - -import { getSelectorDefinition } from '@/hooks/selectors/registry' -import type { SelectorQueryArgs } from '@/hooks/selectors/types' - -const organizations = getSelectorDefinition('zoho_desk.organizations') -const departments = getSelectorDefinition('zoho_desk.departments') -const agents = getSelectorDefinition('zoho_desk.agents') - -const orgArgs = (overrides: Partial = {}): SelectorQueryArgs => ({ - key: 'zoho_desk.organizations', - context: { oauthCredential: 'cred-1', workflowId: 'wf-1', ...overrides }, -}) - -const deptArgs = (overrides: Partial = {}): SelectorQueryArgs => ({ - key: 'zoho_desk.departments', - context: { oauthCredential: 'cred-1', workflowId: 'wf-1', orgId: 'org-9', ...overrides }, -}) - -const agentArgs = (overrides: Partial = {}): SelectorQueryArgs => ({ - key: 'zoho_desk.agents', - context: { oauthCredential: 'cred-1', workflowId: 'wf-1', orgId: 'org-9', ...overrides }, -}) - -describe('zoho_desk.organizations selector', () => { - beforeEach(() => vi.clearAllMocks()) - - it('is enabled only once a credential is selected', () => { - expect(organizations.enabled?.(orgArgs())).toBe(true) - expect(organizations.enabled?.(orgArgs({ oauthCredential: undefined }))).toBe(false) - }) - - it('keys the query by credential', () => { - expect(organizations.getQueryKey(orgArgs())).toEqual([ - 'selectors', - 'zoho_desk.organizations', - 'cred-1', - ]) - expect(organizations.getQueryKey(orgArgs({ oauthCredential: undefined }))).toEqual([ - 'selectors', - 'zoho_desk.organizations', - 'none', - ]) - }) - - it('posts the credential id (never a token) and maps organizations to options', async () => { - mockRequestJson.mockResolvedValue({ - organizations: [ - { id: '700123', name: 'Zylker' }, - { id: '700124', name: 'zPad' }, - ], - }) - - const options = await organizations.fetchList?.(orgArgs()) - - expect(mockRequestJson).toHaveBeenCalledWith( - expect.objectContaining({ path: '/api/tools/zoho_desk/organizations' }), - expect.objectContaining({ body: { credential: 'cred-1', workflowId: 'wf-1' } }) - ) - expect(options).toEqual([ - { id: '700123', label: 'Zylker' }, - { id: '700124', label: 'zPad' }, - ]) - }) - - it('throws when the credential is missing rather than calling the route', async () => { - await expect( - organizations.fetchList?.(orgArgs({ oauthCredential: undefined })) - ).rejects.toThrow(/Missing credential/) - expect(mockRequestJson).not.toHaveBeenCalled() - }) -}) - -describe('zoho_desk.departments selector', () => { - beforeEach(() => vi.clearAllMocks()) - - it('stays disabled until both the credential and the organization are set', () => { - expect(departments.enabled?.(deptArgs())).toBe(true) - expect(departments.enabled?.(deptArgs({ orgId: undefined }))).toBe(false) - expect(departments.enabled?.(deptArgs({ oauthCredential: undefined }))).toBe(false) - }) - - it('keys the query by credential and organization so switching portals refetches', () => { - expect(departments.getQueryKey(deptArgs())).toEqual([ - 'selectors', - 'zoho_desk.departments', - 'cred-1', - 'org-9', - ]) - expect(departments.getQueryKey(deptArgs({ orgId: undefined }))).toEqual([ - 'selectors', - 'zoho_desk.departments', - 'cred-1', - 'none', - ]) - }) - - it('forwards the organization id and maps departments to options', async () => { - mockRequestJson.mockResolvedValue({ - departments: [{ id: '1892000000082069', name: 'Zylker' }], - }) - - const options = await departments.fetchList?.(deptArgs()) - - expect(mockRequestJson).toHaveBeenCalledWith( - expect.objectContaining({ path: '/api/tools/zoho_desk/departments' }), - expect.objectContaining({ - body: { credential: 'cred-1', orgId: 'org-9', workflowId: 'wf-1' }, - }) - ) - expect(options).toEqual([{ id: '1892000000082069', label: 'Zylker' }]) - }) - - it('throws when the organization is missing rather than calling the route unscoped', async () => { - await expect(departments.fetchList?.(deptArgs({ orgId: undefined }))).rejects.toThrow( - /Missing organization ID/ - ) - expect(mockRequestJson).not.toHaveBeenCalled() - }) -}) - -describe('zoho_desk.agents selector', () => { - beforeEach(() => vi.clearAllMocks()) - - it('stays disabled until both the credential and the organization are set', () => { - expect(agents.enabled?.(agentArgs())).toBe(true) - expect(agents.enabled?.(agentArgs({ orgId: undefined }))).toBe(false) - expect(agents.enabled?.(agentArgs({ oauthCredential: undefined }))).toBe(false) - }) - - it('keys the query by credential and organization so switching portals refetches', () => { - expect(agents.getQueryKey(agentArgs())).toEqual([ - 'selectors', - 'zoho_desk.agents', - 'cred-1', - 'org-9', - ]) - expect(agents.getQueryKey(agentArgs({ orgId: undefined }))).toEqual([ - 'selectors', - 'zoho_desk.agents', - 'cred-1', - 'none', - ]) - }) - - it('forwards the organization id and maps agents to options', async () => { - mockRequestJson.mockResolvedValue({ - agents: [ - { id: '1892000000056007', name: 'zyl case' }, - { id: '1892000000042001', name: 'jade' }, - ], - }) - - const options = await agents.fetchList?.(agentArgs()) - - expect(mockRequestJson).toHaveBeenCalledWith( - expect.objectContaining({ path: '/api/tools/zoho_desk/agents' }), - expect.objectContaining({ - body: { credential: 'cred-1', orgId: 'org-9', workflowId: 'wf-1' }, - }) - ) - expect(options).toEqual([ - { id: '1892000000056007', label: 'zyl case' }, - { id: '1892000000042001', label: 'jade' }, - ]) - }) - - it('throws when the organization is missing rather than calling the route unscoped', async () => { - await expect(agents.fetchList?.(agentArgs({ orgId: undefined }))).rejects.toThrow( - /Missing organization ID/ - ) - expect(mockRequestJson).not.toHaveBeenCalled() - }) - - it('throws when the credential is missing rather than calling the route', async () => { - await expect(agents.fetchList?.(agentArgs({ oauthCredential: undefined }))).rejects.toThrow( - /Missing credential/ - ) - expect(mockRequestJson).not.toHaveBeenCalled() - }) -}) diff --git a/apps/sim/hooks/selectors/providers/zoho-desk/selectors.ts b/apps/sim/hooks/selectors/providers/zoho-desk/selectors.ts deleted file mode 100644 index 31fad78e433..00000000000 --- a/apps/sim/hooks/selectors/providers/zoho-desk/selectors.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { requestJson } from '@/lib/api/client/request' -import * as selectorContracts from '@/lib/api/contracts/selectors' -import { ensureCredential, SELECTOR_STALE } from '@/hooks/selectors/providers/shared' -import type { SelectorDefinition, SelectorKey, SelectorQueryArgs } from '@/hooks/selectors/types' - -export const zohoDeskSelectors = { - 'zoho_desk.organizations': { - key: 'zoho_desk.organizations', - contracts: [selectorContracts.zohoDeskOrganizationsSelectorContract], - staleTime: SELECTOR_STALE, - getQueryKey: ({ context }: SelectorQueryArgs) => [ - 'selectors', - 'zoho_desk.organizations', - context.oauthCredential ?? 'none', - ], - enabled: ({ context }) => Boolean(context.oauthCredential), - fetchList: async ({ context, signal }: SelectorQueryArgs) => { - const credentialId = ensureCredential(context, 'zoho_desk.organizations') - const data = await requestJson(selectorContracts.zohoDeskOrganizationsSelectorContract, { - body: { credential: credentialId, workflowId: context.workflowId }, - signal, - }) - return (data.organizations || []).map((organization) => ({ - id: organization.id, - label: organization.name, - })) - }, - }, - 'zoho_desk.departments': { - key: 'zoho_desk.departments', - contracts: [selectorContracts.zohoDeskDepartmentsSelectorContract], - staleTime: SELECTOR_STALE, - getQueryKey: ({ context }: SelectorQueryArgs) => [ - 'selectors', - 'zoho_desk.departments', - context.oauthCredential ?? 'none', - context.orgId ?? 'none', - ], - // Every Desk call but `/organizations` is scoped by the `orgId` header, so - // the organization must be chosen before departments can be listed. - enabled: ({ context }) => Boolean(context.oauthCredential && context.orgId), - fetchList: async ({ context, signal }: SelectorQueryArgs) => { - const credentialId = ensureCredential(context, 'zoho_desk.departments') - if (!context.orgId) { - throw new Error('Missing organization ID for zoho_desk.departments selector') - } - const data = await requestJson(selectorContracts.zohoDeskDepartmentsSelectorContract, { - body: { - credential: credentialId, - orgId: context.orgId, - workflowId: context.workflowId, - }, - signal, - }) - return (data.departments || []).map((department) => ({ - id: department.id, - label: department.name, - })) - }, - }, - 'zoho_desk.agents': { - key: 'zoho_desk.agents', - contracts: [selectorContracts.zohoDeskAgentsSelectorContract], - staleTime: SELECTOR_STALE, - getQueryKey: ({ context }: SelectorQueryArgs) => [ - 'selectors', - 'zoho_desk.agents', - context.oauthCredential ?? 'none', - context.orgId ?? 'none', - ], - // Same `orgId` header scoping as departments: the organization must be - // chosen before agents can be listed. - enabled: ({ context }) => Boolean(context.oauthCredential && context.orgId), - fetchList: async ({ context, signal }: SelectorQueryArgs) => { - const credentialId = ensureCredential(context, 'zoho_desk.agents') - if (!context.orgId) { - throw new Error('Missing organization ID for zoho_desk.agents selector') - } - const data = await requestJson(selectorContracts.zohoDeskAgentsSelectorContract, { - body: { - credential: credentialId, - orgId: context.orgId, - workflowId: context.workflowId, - }, - signal, - }) - return (data.agents || []).map((agent) => ({ - id: agent.id, - label: agent.name, - })) - }, - }, -} satisfies Record< - Extract, - SelectorDefinition -> diff --git a/apps/sim/hooks/selectors/providers/zoom/selectors.ts b/apps/sim/hooks/selectors/providers/zoom/selectors.ts deleted file mode 100644 index 1f9dbd9fc34..00000000000 --- a/apps/sim/hooks/selectors/providers/zoom/selectors.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { requestJson } from '@/lib/api/client/request' -import * as selectorContracts from '@/lib/api/contracts/selectors' -import { ensureCredential, SELECTOR_STALE } from '@/hooks/selectors/providers/shared' -import type { SelectorDefinition, SelectorKey, SelectorQueryArgs } from '@/hooks/selectors/types' - -export const zoomSelectors = { - 'zoom.meetings': { - key: 'zoom.meetings', - contracts: [selectorContracts.zoomMeetingsSelectorContract], - staleTime: SELECTOR_STALE, - getQueryKey: ({ context }: SelectorQueryArgs) => [ - 'selectors', - 'zoom.meetings', - context.oauthCredential ?? 'none', - ], - enabled: ({ context }) => Boolean(context.oauthCredential), - fetchList: async ({ context, signal }: SelectorQueryArgs) => { - const credentialId = ensureCredential(context, 'zoom.meetings') - const data = await requestJson(selectorContracts.zoomMeetingsSelectorContract, { - body: { credential: credentialId, workflowId: context.workflowId }, - signal, - }) - return (data.meetings || []).map((m) => ({ - id: m.id, - label: m.name || `Meeting ${m.id}`, - })) - }, - fetchById: async ({ context, detailId, signal }: SelectorQueryArgs) => { - if (!detailId) return null - const credentialId = ensureCredential(context, 'zoom.meetings') - const data = await requestJson(selectorContracts.zoomMeetingsSelectorContract, { - body: { credential: credentialId, workflowId: context.workflowId }, - signal, - }) - const meeting = (data.meetings || []).find((m) => m.id === detailId) ?? null - if (!meeting) return null - return { id: meeting.id, label: meeting.name || `Meeting ${meeting.id}` } - }, - }, -} satisfies Record, SelectorDefinition> diff --git a/apps/sim/hooks/selectors/query-keys.ts b/apps/sim/hooks/selectors/query-keys.ts deleted file mode 100644 index c5fa1afe97a..00000000000 --- a/apps/sim/hooks/selectors/query-keys.ts +++ /dev/null @@ -1,7 +0,0 @@ -export const selectorKeys = { - all: ['selectors'] as const, - simWorkflowsPrefix: (workspaceId: string) => - [...selectorKeys.all, 'sim.workflows', workspaceId] as const, - simWorkflows: (workspaceId: string, excludeWorkflowId?: string) => - [...selectorKeys.simWorkflowsPrefix(workspaceId), excludeWorkflowId ?? 'none'] as const, -} diff --git a/apps/sim/hooks/selectors/registry.test.ts b/apps/sim/hooks/selectors/registry.test.ts deleted file mode 100644 index a7d53c4f906..00000000000 --- a/apps/sim/hooks/selectors/registry.test.ts +++ /dev/null @@ -1,239 +0,0 @@ -/** - * @vitest-environment node - */ -import type { QueryClient } from '@tanstack/react-query' -import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' -import * as getQueryClientModule from '@/app/_shell/providers/get-query-client' -import * as folderCacheModule from '@/hooks/queries/utils/folder-cache' -import * as workflowCacheModule from '@/hooks/queries/utils/workflow-cache' -import * as workflowListQueryModule from '@/hooks/queries/utils/workflow-list-query' -import { getSelectorDefinition } from '@/hooks/selectors/registry' - -const mockEnsureQueryData = vi.fn().mockResolvedValue(undefined) -const mockFetchQuery = vi.fn().mockResolvedValue(undefined) - -/** - * Spy on the real module namespaces instead of vi.mock: under `isolate: false` - * `@/hooks/selectors/registry` may already be cached bound to the real - * cache/query modules, so patching the shared namespaces is the only wiring - * that always applies. - */ -const getQueryClientSpy = vi.spyOn(getQueryClientModule, 'getQueryClient').mockImplementation( - () => - ({ - ensureQueryData: mockEnsureQueryData, - fetchQuery: mockFetchQuery, - }) as unknown as QueryClient -) -const mockGetWorkflows = vi.spyOn(workflowCacheModule, 'getWorkflows') -const getWorkflowByIdSpy = vi - .spyOn(workflowCacheModule, 'getWorkflowById') - .mockImplementation((workspaceId: string, workflowId: string) => - mockGetWorkflows(workspaceId).find((workflow: { id: string }) => workflow.id === workflowId) - ) -const mockGetFolderMap = vi.spyOn(folderCacheModule, 'getFolderMap').mockReturnValue({}) -const getWorkflowListQueryOptionsSpy = vi - .spyOn(workflowListQueryModule, 'getWorkflowListQueryOptions') - .mockImplementation( - (workspaceId: string) => - ({ - queryKey: ['workflows', 'list', workspaceId, 'active'], - }) as unknown as ReturnType - ) - -afterAll(() => { - getQueryClientSpy.mockRestore() - mockGetWorkflows.mockRestore() - getWorkflowByIdSpy.mockRestore() - mockGetFolderMap.mockRestore() - getWorkflowListQueryOptionsSpy.mockRestore() -}) - -describe('sim.workflows selector', () => { - beforeEach(() => { - vi.clearAllMocks() - mockEnsureQueryData.mockResolvedValue(undefined) - getQueryClientSpy.mockImplementation( - () => - ({ - ensureQueryData: mockEnsureQueryData, - fetchQuery: mockFetchQuery, - }) as unknown as QueryClient - ) - getWorkflowByIdSpy.mockImplementation((workspaceId: string, workflowId: string) => - mockGetWorkflows(workspaceId).find((workflow: { id: string }) => workflow.id === workflowId) - ) - getWorkflowListQueryOptionsSpy.mockImplementation( - (workspaceId: string) => - ({ - queryKey: ['workflows', 'list', workspaceId, 'active'], - }) as unknown as ReturnType - ) - mockGetWorkflows.mockReturnValue([ - { id: 'wf-1', name: 'Alpha Workflow', folderId: null }, - { id: 'wf-2', name: 'Bravo Workflow', folderId: null }, - ]) - mockGetFolderMap.mockReturnValue({}) - }) - - it('requires an explicit workspaceId in selector context', () => { - const definition = getSelectorDefinition('sim.workflows') - - expect(definition.enabled?.({ key: 'sim.workflows', context: {} })).toBe(false) - expect(definition.staleTime).toBe(60_000) - expect( - definition.getQueryKey({ - key: 'sim.workflows', - context: { workspaceId: 'ws-1', excludeWorkflowId: 'wf-2' }, - }) - ).toEqual(['selectors', 'sim.workflows', 'ws-1', 'wf-2']) - }) - - it('reads workflow options from the scoped workflow cache', async () => { - const definition = getSelectorDefinition('sim.workflows') - - const options = await definition.fetchList!({ - key: 'sim.workflows', - context: { workspaceId: 'ws-1', excludeWorkflowId: 'wf-2' }, - }) - - expect(mockEnsureQueryData).toHaveBeenCalledWith({ - queryKey: ['workflows', 'list', 'ws-1', 'active'], - }) - expect(mockGetWorkflows).toHaveBeenCalledWith('ws-1') - expect(options).toEqual([{ id: 'wf-1', label: 'Alpha Workflow' }]) - }) - - it('resolves workflow labels by id using the same workspace scope', async () => { - const definition = getSelectorDefinition('sim.workflows') - - const option = await definition.fetchById?.({ - key: 'sim.workflows', - context: { workspaceId: 'ws-1' }, - detailId: 'wf-2', - }) - - expect(mockEnsureQueryData).toHaveBeenCalledWith({ - queryKey: ['workflows', 'list', 'ws-1', 'active'], - }) - expect(mockGetWorkflows).toHaveBeenCalledWith('ws-1') - expect(option).toEqual({ id: 'wf-2', label: 'Bravo Workflow' }) - }) - - it('disambiguates duplicate workflow names with their folder path', async () => { - mockGetWorkflows.mockReturnValue([ - { id: 'wf-root', name: 'Pipeline', folderId: null }, - { id: 'wf-eng', name: 'Pipeline', folderId: 'folder-eng' }, - { id: 'wf-eng-backend', name: 'Pipeline', folderId: 'folder-backend' }, - { id: 'wf-unique', name: 'Solo Workflow', folderId: 'folder-eng' }, - ]) - mockGetFolderMap.mockReturnValue({ - 'folder-eng': { - id: 'folder-eng', - name: 'Engineering', - parentId: null, - workspaceId: 'ws-1', - }, - 'folder-backend': { - id: 'folder-backend', - name: 'Backend', - parentId: 'folder-eng', - workspaceId: 'ws-1', - }, - }) - - const definition = getSelectorDefinition('sim.workflows') - const options = await definition.fetchList!({ - key: 'sim.workflows', - context: { workspaceId: 'ws-1' }, - }) - - const labelById = Object.fromEntries(options.map((o) => [o.id, o.label])) - expect(labelById['wf-root']).toBe('Pipeline (Root)') - expect(labelById['wf-eng']).toBe('Pipeline (Engineering)') - expect(labelById['wf-eng-backend']).toBe('Pipeline (Engineering / Backend)') - expect(labelById['wf-unique']).toBe('Solo Workflow') - }) - - it('disambiguates a single workflow lookup when its name has duplicates', async () => { - mockGetWorkflows.mockReturnValue([ - { id: 'wf-1', name: 'Pipeline', folderId: 'folder-a' }, - { id: 'wf-2', name: 'Pipeline', folderId: null }, - ]) - mockGetFolderMap.mockReturnValue({ - 'folder-a': { - id: 'folder-a', - name: 'Alpha', - parentId: null, - workspaceId: 'ws-1', - }, - }) - - const definition = getSelectorDefinition('sim.workflows') - const option = await definition.fetchById?.({ - key: 'sim.workflows', - context: { workspaceId: 'ws-1' }, - detailId: 'wf-1', - }) - - expect(option).toEqual({ id: 'wf-1', label: 'Pipeline (Alpha)' }) - }) -}) - -describe('table column selectors', () => { - beforeEach(() => { - vi.clearAllMocks() - mockFetchQuery.mockResolvedValue({ - schema: { - columns: [ - { id: 'col_email', name: 'Email', type: 'string', unique: true }, - { id: 'col_name', name: 'Name', type: 'string' }, - ], - }, - }) - }) - - it('stores stable column ids while displaying column names', async () => { - const definition = getSelectorDefinition('table.outputColumns') - - const options = await definition.fetchList!({ - key: 'table.outputColumns', - context: { workspaceId: 'ws-1', tableId: 'tbl-1' }, - }) - - expect(options).toEqual([ - { id: 'col_email', label: 'Email' }, - { id: 'col_name', label: 'Name' }, - ]) - }) - - it('resolves a saved column id to its current name', async () => { - const definition = getSelectorDefinition('table.outputColumns') - const context = { workspaceId: 'ws-1', tableId: 'tbl-1' } - - const existing = await definition.fetchById!({ - key: 'table.outputColumns', - context, - detailId: 'col_email', - }) - const missing = await definition.fetchById!({ - key: 'table.outputColumns', - context, - detailId: 'Missing', - }) - - expect(existing).toEqual({ id: 'col_email', label: 'Email' }) - expect(missing).toBeNull() - }) - - it('keeps the existing conflict-column selector limited to unique columns', async () => { - const definition = getSelectorDefinition('table.columns') - - const options = await definition.fetchList!({ - key: 'table.columns', - context: { workspaceId: 'ws-1', tableId: 'tbl-1' }, - }) - - expect(options).toEqual([{ id: 'col_email', label: 'Email' }]) - }) -}) diff --git a/apps/sim/hooks/selectors/registry.ts b/apps/sim/hooks/selectors/registry.ts deleted file mode 100644 index e11d656e7da..00000000000 --- a/apps/sim/hooks/selectors/registry.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { airtableSelectors } from '@/hooks/selectors/providers/airtable/selectors' -import { asanaSelectors } from '@/hooks/selectors/providers/asana/selectors' -import { attioSelectors } from '@/hooks/selectors/providers/attio/selectors' -import { bigquerySelectors } from '@/hooks/selectors/providers/bigquery/selectors' -import { bitbucketSelectors } from '@/hooks/selectors/providers/bitbucket/selectors' -import { calcomSelectors } from '@/hooks/selectors/providers/calcom/selectors' -import { clickupSelectors } from '@/hooks/selectors/providers/clickup/selectors' -import { cloudwatchSelectors } from '@/hooks/selectors/providers/cloudwatch/selectors' -import { confluenceSelectors } from '@/hooks/selectors/providers/confluence/selectors' -import { googleSelectors } from '@/hooks/selectors/providers/google/selectors' -import { harmonicSelectors } from '@/hooks/selectors/providers/harmonic/selectors' -import { hubspotSelectors } from '@/hooks/selectors/providers/hubspot/selectors' -import { imapSelectors } from '@/hooks/selectors/providers/imap/selectors' -import { jiraSelectors } from '@/hooks/selectors/providers/jira/selectors' -import { jsmSelectors } from '@/hooks/selectors/providers/jsm/selectors' -import { knowledgeSelectors } from '@/hooks/selectors/providers/knowledge/selectors' -import { linearSelectors } from '@/hooks/selectors/providers/linear/selectors' -import { managedAgentSelectors } from '@/hooks/selectors/providers/managed-agent/selectors' -import { microsoftSelectors } from '@/hooks/selectors/providers/microsoft/selectors' -import { mondaySelectors } from '@/hooks/selectors/providers/monday/selectors' -import { netsuiteSelectors } from '@/hooks/selectors/providers/netsuite/selectors' -import { notionSelectors } from '@/hooks/selectors/providers/notion/selectors' -import { pipedriveSelectors } from '@/hooks/selectors/providers/pipedrive/selectors' -import { sharepointSelectors } from '@/hooks/selectors/providers/sharepoint/selectors' -import { simSelectors } from '@/hooks/selectors/providers/sim/selectors' -import { slackSelectors } from '@/hooks/selectors/providers/slack/selectors' -import { snowflakeSelectors } from '@/hooks/selectors/providers/snowflake/selectors' -import { trelloSelectors } from '@/hooks/selectors/providers/trello/selectors' -import { wealthboxSelectors } from '@/hooks/selectors/providers/wealthbox/selectors' -import { webflowSelectors } from '@/hooks/selectors/providers/webflow/selectors' -import { - providerSelectors, - workspaceSelectors, -} from '@/hooks/selectors/providers/workspace/selectors' -import { zohoDeskSelectors } from '@/hooks/selectors/providers/zoho-desk/selectors' -import { zoomSelectors } from '@/hooks/selectors/providers/zoom/selectors' -import type { - SelectorDefinition, - SelectorKey, - SelectorOption, - SelectorQueryArgs, -} from '@/hooks/selectors/types' - -export const selectorRegistry = { - ...airtableSelectors, - ...asanaSelectors, - ...attioSelectors, - ...bigquerySelectors, - ...bitbucketSelectors, - ...calcomSelectors, - ...confluenceSelectors, - ...jsmSelectors, - ...googleSelectors, - ...harmonicSelectors, - ...hubspotSelectors, - ...managedAgentSelectors, - ...imapSelectors, - ...workspaceSelectors, - ...providerSelectors, - ...microsoftSelectors, - ...notionSelectors, - ...pipedriveSelectors, - ...sharepointSelectors, - ...trelloSelectors, - ...zohoDeskSelectors, - ...zoomSelectors, - ...slackSelectors, - ...wealthboxSelectors, - ...jiraSelectors, - ...mondaySelectors, - ...netsuiteSelectors, - ...linearSelectors, - ...knowledgeSelectors, - ...webflowSelectors, - ...clickupSelectors, - ...cloudwatchSelectors, - ...simSelectors, - ...snowflakeSelectors, -} satisfies Record - -export function getSelectorDefinition(key: SelectorKey): SelectorDefinition { - const definition = selectorRegistry[key] - if (!definition) { - throw new Error(`Missing selector definition for ${key}`) - } - return definition -} - -const MAX_LOAD_ALL_PAGES = 50 - -/** - * Loads the complete option list for a selector outside the React Query hook — - * for callers (search/replace, value resolution) that need every option in one - * call. Uses `fetchList` when defined, otherwise drains `fetchPage` (bounded by - * {@link MAX_LOAD_ALL_PAGES}). Returns an empty array for a selector that - * provides neither. - */ -export async function loadAllSelectorOptions( - definition: SelectorDefinition, - args: SelectorQueryArgs -): Promise { - if (definition.fetchList) { - return definition.fetchList(args) - } - - if (definition.fetchPage) { - const items: SelectorOption[] = [] - let cursor: string | undefined - for (let page = 0; page < MAX_LOAD_ALL_PAGES; page++) { - const { items: pageItems, nextCursor } = await definition.fetchPage({ ...args, cursor }) - items.push(...pageItems) - cursor = nextCursor - if (!cursor) break - } - return items - } - - return [] -} - -export function mergeOption(options: SelectorOption[], option?: SelectorOption | null) { - if (!option) return options - if (options.some((item) => item.id === option.id)) { - return options - } - return [option, ...options] -} diff --git a/apps/sim/hooks/selectors/resolution.ts b/apps/sim/hooks/selectors/resolution.ts deleted file mode 100644 index 38886d70de4..00000000000 --- a/apps/sim/hooks/selectors/resolution.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { SubBlockConfig } from '@/blocks/types' -import type { SelectorContext, SelectorKey } from '@/hooks/selectors/types' - -export interface SelectorResolution { - key: SelectorKey | null - context: SelectorContext - allowSearch: boolean -} - -export function resolveSelectorForSubBlock( - subBlock: SubBlockConfig, - context: SelectorContext -): SelectorResolution | null { - if (!subBlock.selectorKey) return null - return { - key: subBlock.selectorKey, - context: { - ...context, - mimeType: subBlock.mimeType ?? context.mimeType, - }, - allowSearch: subBlock.selectorAllowSearch ?? true, - } -} diff --git a/apps/sim/hooks/selectors/trigger-types-live.test.ts b/apps/sim/hooks/selectors/trigger-types-live.test.ts deleted file mode 100644 index 5e6d482ad0d..00000000000 --- a/apps/sim/hooks/selectors/trigger-types-live.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -/** - * @vitest-environment node - */ -import { describe, expect, it } from 'vitest' -import { getSelectorDefinition } from '@/hooks/selectors/registry' - -/** - * Exercises the real block and trigger registries rather than a mock: the - * selector reaches them through a lazy import specifically to avoid an - * initialization cycle, and a mocked test cannot show that the import resolves - * or that the registry is populated by the time the dropdown asks for options. - */ -const fetchTriggerTypeOptions = () => - getSelectorDefinition('workspace.triggerTypes').fetchList!({ - key: 'workspace.triggerTypes', - context: {}, - }) - -describe('workspace.triggerTypes against the real registry', () => { - it('resolves the lazy import into a populated list of unique labels', async () => { - const options = await fetchTriggerTypeOptions() - - expect(options.length).toBeGreaterThan(10) - expect(options.every((option) => option.id.length > 0 && option.label.length > 0)).toBe(true) - - const labels = options.map((option) => option.label) - expect(new Set(labels).size).toBe(labels.length) - }) - - it('merges the two Sim agent trigger values behind one option', async () => { - const options = await fetchTriggerTypeOptions() - - expect(options.find((option) => option.label === 'Sim agent')?.id).toBe('copilot,mothership') - expect(options.find((option) => option.label === 'API')?.id).toBe('api') - }) -}) diff --git a/apps/sim/hooks/selectors/types.ts b/apps/sim/hooks/selectors/types.ts deleted file mode 100644 index b8d1b5e7ab0..00000000000 --- a/apps/sim/hooks/selectors/types.ts +++ /dev/null @@ -1,221 +0,0 @@ -import type React from 'react' -import type { QueryKey } from '@tanstack/react-query' -import type { AnyApiRouteContract } from '@/lib/api/contracts/types' - -export type SelectorKey = - | 'airtable.bases' - | 'airtable.tables' - | 'asana.workspaces' - | 'attio.lists' - | 'attio.objects' - | 'bigquery.datasets' - | 'bigquery.tables' - | 'bitbucket.workspaces' - | 'bitbucket.repositories' - | 'calcom.eventTypes' - | 'calcom.schedules' - | 'clickup.workspaces' - | 'clickup.spaces' - | 'clickup.folders' - | 'clickup.lists' - | 'confluence.spaces' - | 'google.tasks.lists' - | 'harmonic.savedSearches' - | 'managedAgent.agents' - | 'managedAgent.environments' - | 'managedAgent.vaults' - | 'managedAgent.memoryStores' - | 'hubspot.lists' - | 'hubspot.owners' - | 'hubspot.pipelines' - | 'hubspot.pipelineStages' - | 'hubspot.properties' - | 'jsm.requestTypes' - | 'jsm.serviceDesks' - | 'microsoft.planner.plans' - | 'notion.databases' - | 'notion.pages' - | 'netsuite.recordTypes' - | 'netsuite.asyncTasks' - | 'pipedrive.pipelines' - | 'sharepoint.lists' - | 'trello.boards' - | 'zoho_desk.organizations' - | 'zoho_desk.departments' - | 'zoho_desk.agents' - | 'zoom.meetings' - | 'slack.channels' - | 'snowflake.databases' - | 'snowflake.schemas' - | 'snowflake.tables' - | 'snowflake.warehouses' - | 'snowflake.roles' - | 'snowflake.fileFormats' - | 'snowflake.procedures' - | 'slack.users' - | 'gmail.labels' - | 'outlook.folders' - | 'outlook.calendars' - | 'google.calendar' - | 'jira.issues' - | 'jira.projects' - | 'linear.projects' - | 'linear.teams' - | 'confluence.pages' - | 'microsoft.teams' - | 'microsoft.chats' - | 'microsoft.channels' - | 'wealthbox.contacts' - | 'onedrive.files' - | 'onedrive.folders' - | 'sharepoint.sites' - | 'microsoft.excel' - | 'microsoft.excel.drives' - | 'microsoft.excel.sheets' - | 'microsoft.word' - | 'microsoft.planner' - | 'google.drive' - | 'google.sheets' - | 'knowledge.documents' - | 'webflow.sites' - | 'webflow.collections' - | 'webflow.items' - | 'cloudwatch.logGroups' - | 'cloudwatch.logStreams' - | 'monday.boards' - | 'monday.groups' - | 'sim.workflows' - | 'table.columns' - | 'table.outputColumns' - | 'workspace.credentialProviders' - | 'workspace.credentialGroups' - | 'workspace.credentialGroupProviders' - | 'workspace.secretNames' - | 'workspace.rawSecretNames' - | 'workspace.sandboxes' - | 'workspace.triggerTypes' - | 'imap.mailboxes' - | 'providers.openrouterEmbeddingModels' - -export interface SelectorOption { - id: string - label: string - icon?: React.ComponentType<{ className?: string }> - meta?: Record -} - -export interface SelectorContext { - workspaceId?: string - workflowId?: string - oauthCredential?: string - serviceId?: string - domain?: string - teamId?: string - projectId?: string - knowledgeBaseId?: string - planId?: string - mimeType?: string - fileId?: string - siteId?: string - collectionId?: string - spreadsheetId?: string - driveId?: string - excludeWorkflowId?: string - baseId?: string - datasetId?: string - serviceDeskId?: string - impersonateUserEmail?: string - boardId?: string - spaceId?: string - listSpaceId?: string - folderId?: string - awsAccessKeyId?: string - awsSecretAccessKey?: string - awsRegion?: string - logGroupName?: string - mcpServerId?: string - tableId?: string - /** NetSuite asynchronous job whose bounded task list a picker enumerates. */ - jobId?: string - /** Snowflake database holding the objects a picker enumerates. */ - database?: string - /** Snowflake schema holding the objects a picker enumerates. */ - schema?: string - /** Zoho Desk organization (portal) id — the `orgId` header every Desk call but `/organizations` requires. */ - orgId?: string - /** Bitbucket Cloud workspace slug that scopes repository discovery. */ - workspaceSlug?: string - /** - * HubSpot CRM object the pickers are scoped to (`contact` | `deal` | … | `custom`). Left - * unset until the user picks one; the selectors apply HubSpot's own `contact` default so an - * untouched dropdown still lists properties for what it visibly shows. - */ - objectType?: string - /** HubSpot custom object type id (e.g. `2-12345`), used when `objectType` is `custom`. */ - customObjectTypeId?: string - /** HubSpot pipeline whose stages a stage picker enumerates. */ - pipelineId?: string - /** - * Managed Agent deployment mode (`cloud` | `self_hosted`). The two expose different fields, - * so an environment list is filtered to the selected mode rather than mixing them. - */ - environmentType?: string - /** Credential group whose per-provider filter a picker enumerates. */ - credentialGroupId?: string - /** Function block runtime (`python` | `javascript` | `shell`), scoping the sandbox list. */ - language?: string - /** - * IMAP connection parameters. `imapPassword` is a raw secret, so unlike `oauthCredential` — - * which is only an id — it must NEVER appear in a query key; see `imap.mailboxes`. - */ - host?: string - port?: string - secure?: string - username?: string - password?: string -} - -export interface SelectorQueryArgs { - key: SelectorKey - context: SelectorContext - search?: string - detailId?: string - signal?: AbortSignal -} - -export interface SelectorPage { - items: SelectorOption[] - nextCursor?: string -} - -interface SelectorPageArgs extends SelectorQueryArgs { - cursor?: string -} - -export interface SelectorDefinition { - key: SelectorKey - contracts?: readonly AnyApiRouteContract[] - getQueryKey: (args: SelectorQueryArgs) => QueryKey - /** - * Loads the full option list in a single call. Required unless `fetchPage` is - * defined, in which case the hook drives pagination through `fetchPage` and - * `fetchList` is never invoked — provide one or the other, not both. - */ - fetchList?: (args: SelectorQueryArgs) => Promise - /** - * Optional. When defined, the selector hook fetches one page at a time and - * auto-drains remaining pages so the dropdown populates progressively. - * Returns `{ items, nextCursor }`; `nextCursor: undefined` ends the stream. - */ - fetchPage?: (args: SelectorPageArgs) => Promise - fetchById?: (args: SelectorQueryArgs) => Promise - /** - * Set when `fetchById` tolerates an id that may not exist, returning `null` rather - * than erroring. Only then is it safe to speculatively resolve whatever a user has - * typed — most implementations resolve a record by id and would turn every partial - * keystroke into a failed upstream request. - */ - resolvesUnknownIds?: boolean - enabled?: (args: SelectorQueryArgs) => boolean - staleTime?: number -} diff --git a/apps/sim/hooks/selectors/use-selector-query.ts b/apps/sim/hooks/selectors/use-selector-query.ts deleted file mode 100644 index 213afcdab91..00000000000 --- a/apps/sim/hooks/selectors/use-selector-query.ts +++ /dev/null @@ -1,265 +0,0 @@ -import { useEffect, useMemo } from 'react' -import { createLogger } from '@sim/logger' -import { useInfiniteQuery, useQueries, useQuery } from '@tanstack/react-query' -import { extractEnvVarName, isEnvVarReference, isReference } from '@/executor/constants' -import { usePersonalEnvironment } from '@/hooks/queries/environment' -import { getSelectorDefinition, mergeOption } from '@/hooks/selectors/registry' -import type { - SelectorKey, - SelectorOption, - SelectorPage, - SelectorQueryArgs, -} from '@/hooks/selectors/types' - -interface SelectorHookArgs extends Omit { - search?: string - detailId?: string - enabled?: boolean -} - -export interface SelectorOptionsResult { - data: SelectorOption[] | undefined - isLoading: boolean - isFetching: boolean - /** - * True while paginated selectors are draining remaining pages in the - * background. Always false for non-paginated selectors. - */ - isFetchingMore: boolean - /** - * True when the paginated selector still has more pages queued. Always false - * for non-paginated selectors. - */ - hasMore: boolean - /** - * True when the paginated drain stopped at {@link MAX_AUTO_DRAIN_PAGES} with - * pages still remaining, so the option list is a partial view. Always false - * for non-paginated selectors. - */ - truncated: boolean - error: Error | null -} - -const logger = createLogger('SelectorQuery') - -const EMPTY_PAGE: SelectorPage = { items: [], nextCursor: undefined } - -/** - * Safety bound on the background auto-drain. Real dropdowns settle in a handful - * of pages; this only trips for pathological result sets and prevents an - * unbounded request loop when a provider keeps handing back cursors. - */ -const MAX_AUTO_DRAIN_PAGES = 50 - -/** Fallback freshness for selectors that do not declare their own `staleTime`. */ -export const DEFAULT_SELECTOR_STALE_TIME = 30_000 - -/** - * Fallback for a single-option resolution when the definition declares no - * `staleTime`: keyed by an exact id, so it changes far less often than a list. - */ -export const DEFAULT_SELECTOR_DETAIL_STALE_TIME = 300_000 - -export function useSelectorOptions( - key: SelectorKey, - args: SelectorHookArgs -): SelectorOptionsResult { - const definition = getSelectorDefinition(key) - const queryArgs: SelectorQueryArgs = { - key, - context: args.context, - search: args.search, - } - /** - * `definition.enabled` mirrors the preconditions the definition's own fetchers assert, so - * it is a hard precondition for the list, not a default a caller may replace. A caller's - * `enabled` only narrows — widening would run a fetch guaranteed to reject and cache it. - */ - const isEnabled = args.enabled !== false && (definition.enabled?.(queryArgs) ?? true) - const supportsPagination = Boolean(definition.fetchPage) - - const flatQuery = useQuery({ - queryKey: definition.getQueryKey(queryArgs), - queryFn: ({ signal }) => - definition.fetchList?.({ ...queryArgs, signal }) ?? Promise.resolve([]), - enabled: !supportsPagination && isEnabled, - staleTime: definition.staleTime ?? DEFAULT_SELECTOR_STALE_TIME, - }) - - const pagedQuery = useInfiniteQuery({ - queryKey: [...definition.getQueryKey(queryArgs), 'paged'], - queryFn: ({ pageParam, signal }) => { - if (!definition.fetchPage) return Promise.resolve(EMPTY_PAGE) - return definition.fetchPage({ - ...queryArgs, - cursor: pageParam as string | undefined, - signal, - }) - }, - getNextPageParam: (last) => last.nextCursor, - initialPageParam: undefined as string | undefined, - enabled: supportsPagination && isEnabled, - staleTime: definition.staleTime ?? DEFAULT_SELECTOR_STALE_TIME, - }) - - const { hasNextPage, isFetchingNextPage, fetchNextPage, isError } = pagedQuery - const pageCount = pagedQuery.data?.pages.length ?? 0 - const reachedDrainCap = pageCount >= MAX_AUTO_DRAIN_PAGES - useEffect(() => { - if (!supportsPagination) return - if (isError) return - if (reachedDrainCap) { - if (hasNextPage) { - logger.warn('Selector hit auto-drain cap; option list is truncated', { - key, - pages: pageCount, - }) - } - return - } - if (hasNextPage && !isFetchingNextPage) { - void fetchNextPage() - } - }, [ - supportsPagination, - hasNextPage, - isFetchingNextPage, - isError, - fetchNextPage, - reachedDrainCap, - pageCount, - key, - ]) - - const pagedOptions = useMemo(() => { - if (!supportsPagination) return undefined - if (!pagedQuery.data) return undefined - return pagedQuery.data.pages.flatMap((page) => page.items) - }, [supportsPagination, pagedQuery.data]) - - if (supportsPagination) { - return { - data: pagedOptions, - isLoading: pagedQuery.isLoading, - isFetching: pagedQuery.isFetching, - isFetchingMore: pagedQuery.isFetchingNextPage, - hasMore: (pagedQuery.hasNextPage ?? false) && !reachedDrainCap, - truncated: reachedDrainCap && (pagedQuery.hasNextPage ?? false), - error: (pagedQuery.error as Error | null) ?? null, - } - } - - return { - data: flatQuery.data, - isLoading: flatQuery.isLoading, - isFetching: flatQuery.isFetching, - isFetchingMore: false, - hasMore: false, - truncated: false, - error: (flatQuery.error as Error | null) ?? null, - } -} - -export function useSelectorOptionDetail( - key: SelectorKey, - args: SelectorHookArgs & { detailId?: string } -) { - const { data: envVariables = {} } = usePersonalEnvironment() - const definition = getSelectorDefinition(key) - - const resolvedDetailId = useMemo(() => { - if (!args.detailId) return undefined - if (isReference(args.detailId)) return undefined - if (isEnvVarReference(args.detailId)) { - const varName = extractEnvVarName(args.detailId) - return envVariables[varName]?.value || undefined - } - return args.detailId - }, [args.detailId, envVariables]) - - const queryArgs: SelectorQueryArgs = { - key, - context: args.context, - detailId: resolvedDetailId, - } - const hasRealDetailId = Boolean(resolvedDetailId) - /** - * Hard precondition: `queryFn` asserts `fetchById` is defined, so this must hold - * however the caller configures the query — otherwise the assertion throws for the - * many selectors that declare no `fetchById`. - */ - const canResolveDetail = hasRealDetailId && definition.fetchById !== undefined - /** - * `definition.enabled` describes when the *list* can be fetched, so it gates on - * context a list needs (credential, domain, region). Resolving one already-known id - * can need far less — `cloudwatch.*` echoes the id back without calling AWS at all — - * so a caller that opts in explicitly is only narrowed by the hard precondition. - * Callers that pass nothing keep the list predicate as their default. - */ - const enabled = - (args.enabled ?? (definition.enabled ? definition.enabled(queryArgs) : true)) && - canResolveDetail - - const query = useQuery({ - queryKey: [...definition.getQueryKey(queryArgs), 'detail', resolvedDetailId ?? 'none'], - queryFn: ({ signal }) => definition.fetchById!({ ...queryArgs, signal }), - enabled, - staleTime: definition.staleTime ?? DEFAULT_SELECTOR_DETAIL_STALE_TIME, - }) - - return query -} - -/** - * Resolves several ids at once, so a multi-select field can label every selected - * value — including values restored from saved config, which no in-session search - * would have resolved. Query keys match {@link useSelectorOptionDetail} exactly, so - * the two share a cache and an id already resolved by search costs no extra request. - */ -export function useSelectorOptionDetails( - key: SelectorKey, - args: Omit & { detailIds: string[] } -): SelectorOption[] { - const { data: envVariables = {} } = usePersonalEnvironment() - const definition = getSelectorDefinition(key) - - const resolvedIds = useMemo(() => { - const out: string[] = [] - for (const id of args.detailIds) { - if (!id || isReference(id)) continue - if (isEnvVarReference(id)) { - const value = envVariables[extractEnvVarName(id)]?.value - if (value) out.push(value) - continue - } - out.push(id) - } - return Array.from(new Set(out)) - }, [args.detailIds, envVariables]) - - const results = useQueries({ - queries: resolvedIds.map((detailId) => { - const queryArgs: SelectorQueryArgs = { key, context: args.context, detailId } - const canResolveDetail = definition.fetchById !== undefined - return { - queryKey: [...definition.getQueryKey(queryArgs), 'detail', detailId], - queryFn: ({ signal }: { signal: AbortSignal }) => - definition.fetchById!({ ...queryArgs, signal }), - enabled: - args.enabled !== undefined - ? args.enabled && canResolveDetail - : canResolveDetail && (definition.enabled ? definition.enabled(queryArgs) : true), - staleTime: definition.staleTime ?? DEFAULT_SELECTOR_DETAIL_STALE_TIME, - } - }), - }) - - return useMemo(() => results.flatMap((result) => (result.data ? [result.data] : [])), [results]) -} - -export function useSelectorOptionMap(options: SelectorOption[], extra?: SelectorOption | null) { - return useMemo(() => { - const merged = mergeOption(options, extra) - return new Map(merged.map((option) => [option.id, option])) - }, [options, extra]) -} diff --git a/apps/sim/hooks/use-selector-display-name.ts b/apps/sim/hooks/use-selector-display-name.ts index d7f332d2067..d5e3f8ca07a 100644 --- a/apps/sim/hooks/use-selector-display-name.ts +++ b/apps/sim/hooks/use-selector-display-name.ts @@ -1,13 +1,13 @@ import { useMemo } from 'react' +import type { SelectorKey } from '@/lib/selectors/manifest' import { summarizeNames } from '@/lib/workflows/subblocks/display' import type { SubBlockConfig } from '@/blocks/types' -import { resolveSelectorForSubBlock } from '@/hooks/selectors/resolution' -import type { SelectorKey } from '@/hooks/selectors/types' import { + type SelectorClientContext, useSelectorOptionDetail, useSelectorOptionMap, useSelectorOptions, -} from '@/hooks/selectors/use-selector-query' +} from '@/hooks/queries/selectors' interface SelectorDisplayNameArgs { subBlock?: SubBlockConfig @@ -66,8 +66,8 @@ export function useSelectorDisplayName({ const hasSelection = selectedIds.length > 0 const resolution = useMemo(() => { - if (!subBlock || !hasSelection) return null - return resolveSelectorForSubBlock(subBlock, { + if (!subBlock?.selectorKey || !hasSelection) return null + const context: SelectorClientContext = { workflowId, oauthCredential, domain, @@ -82,7 +82,9 @@ export function useSelectorDisplayName({ collectionId, spreadsheetId, fileId, - }) + mimeType: subBlock.mimeType, + } + return { key: subBlock.selectorKey, context } }, [ subBlock, hasSelection, diff --git a/apps/sim/lib/api/contracts/selectors/airtable.ts b/apps/sim/lib/api/contracts/selectors/airtable.ts deleted file mode 100644 index f38ee14298c..00000000000 --- a/apps/sim/lib/api/contracts/selectors/airtable.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { z } from 'zod' -import { - credentialWorkflowBodySchema, - definePostSelector, - idNameSchema, -} from '@/lib/api/contracts/selectors/shared' -import type { ContractJsonResponse } from '@/lib/api/contracts/types' - -const airtableBaseSchema = idNameSchema -const airtableTableSchema = idNameSchema - -export const airtableTablesBodySchema = credentialWorkflowBodySchema.extend({ - baseId: z.string().min(1, 'Base ID is required'), -}) - -export const airtableBasesSelectorContract = definePostSelector( - '/api/tools/airtable/bases', - credentialWorkflowBodySchema.passthrough(), - z.object({ bases: z.array(airtableBaseSchema) }) -) - -export const airtableTablesSelectorContract = definePostSelector( - '/api/tools/airtable/tables', - airtableTablesBodySchema, - z.object({ tables: z.array(airtableTableSchema) }) -) - -export type AirtableBasesSelectorResponse = ContractJsonResponse< - typeof airtableBasesSelectorContract -> -export type AirtableTablesSelectorResponse = ContractJsonResponse< - typeof airtableTablesSelectorContract -> diff --git a/apps/sim/lib/api/contracts/selectors/asana.ts b/apps/sim/lib/api/contracts/selectors/asana.ts deleted file mode 100644 index 66f833e3df3..00000000000 --- a/apps/sim/lib/api/contracts/selectors/asana.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { z } from 'zod' -import { - credentialWorkflowBodySchema, - definePostSelector, - idNameSchema, -} from '@/lib/api/contracts/selectors/shared' -import type { ContractJsonResponse } from '@/lib/api/contracts/types' - -const asanaWorkspaceSchema = idNameSchema - -export const asanaWorkspacesSelectorContract = definePostSelector( - '/api/tools/asana/workspaces', - credentialWorkflowBodySchema, - z.object({ workspaces: z.array(asanaWorkspaceSchema) }) -) - -export type AsanaWorkspacesSelectorResponse = ContractJsonResponse< - typeof asanaWorkspacesSelectorContract -> diff --git a/apps/sim/lib/api/contracts/selectors/attio.ts b/apps/sim/lib/api/contracts/selectors/attio.ts deleted file mode 100644 index c6c57b1afcb..00000000000 --- a/apps/sim/lib/api/contracts/selectors/attio.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { z } from 'zod' -import { - credentialWorkflowBodySchema, - definePostSelector, - idNameSchema, -} from '@/lib/api/contracts/selectors/shared' -import type { ContractJsonResponse } from '@/lib/api/contracts/types' - -const attioObjectSchema = idNameSchema -const attioListSchema = idNameSchema - -export const attioObjectsSelectorContract = definePostSelector( - '/api/tools/attio/objects', - credentialWorkflowBodySchema, - z.object({ objects: z.array(attioObjectSchema) }) -) - -export const attioListsSelectorContract = definePostSelector( - '/api/tools/attio/lists', - credentialWorkflowBodySchema, - z.object({ lists: z.array(attioListSchema) }) -) - -export type AttioObjectsSelectorResponse = ContractJsonResponse -export type AttioListsSelectorResponse = ContractJsonResponse diff --git a/apps/sim/lib/api/contracts/selectors/bigquery.ts b/apps/sim/lib/api/contracts/selectors/bigquery.ts deleted file mode 100644 index b0bf3c0ca21..00000000000 --- a/apps/sim/lib/api/contracts/selectors/bigquery.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { z } from 'zod' -import { - credentialWorkflowImpersonateBodySchema, - definePostSelector, -} from '@/lib/api/contracts/selectors/shared' -import type { ContractBodyInput, ContractJsonResponse } from '@/lib/api/contracts/types' - -const bigQueryDatasetSchema = z - .object({ - datasetReference: z - .object({ - datasetId: z.string(), - projectId: z.string(), - }) - .passthrough(), - friendlyName: z.string().optional(), - }) - .passthrough() - -const bigQueryTableSchema = z - .object({ - tableReference: z.object({ tableId: z.string() }).passthrough(), - friendlyName: z.string().optional(), - }) - .passthrough() - -export const bigQueryDatasetsBodySchema = credentialWorkflowImpersonateBodySchema.extend({ - projectId: z.string().min(1), -}) - -export const bigQueryTablesBodySchema = bigQueryDatasetsBodySchema.extend({ - datasetId: z.string().min(1), -}) - -export const bigQueryDatasetsSelectorContract = definePostSelector( - '/api/tools/google_bigquery/datasets', - bigQueryDatasetsBodySchema, - z.object({ datasets: z.array(bigQueryDatasetSchema) }) -) - -export const bigQueryTablesSelectorContract = definePostSelector( - '/api/tools/google_bigquery/tables', - bigQueryTablesBodySchema, - z.object({ tables: z.array(bigQueryTableSchema) }) -) - -export type BigQueryDatasetsSelectorBody = ContractBodyInput< - typeof bigQueryDatasetsSelectorContract -> -export type BigQueryTablesSelectorBody = ContractBodyInput - -export type BigQueryDatasetsSelectorResponse = ContractJsonResponse< - typeof bigQueryDatasetsSelectorContract -> -export type BigQueryTablesSelectorResponse = ContractJsonResponse< - typeof bigQueryTablesSelectorContract -> diff --git a/apps/sim/lib/api/contracts/selectors/bitbucket.ts b/apps/sim/lib/api/contracts/selectors/bitbucket.ts deleted file mode 100644 index fee128e938f..00000000000 --- a/apps/sim/lib/api/contracts/selectors/bitbucket.ts +++ /dev/null @@ -1,199 +0,0 @@ -import { z } from 'zod' -import { - credentialWorkflowBodySchema, - definePostSelector, - optionalString, -} from '@/lib/api/contracts/selectors/shared' -import type { ContractBody, ContractJsonResponse } from '@/lib/api/contracts/types' - -const BITBUCKET_API_ORIGIN = 'https://api.bitbucket.org' -const BITBUCKET_WORKSPACES_PATH = '/2.0/user/workspaces' -const BITBUCKET_REPOSITORIES_PATH = '/2.0/repositories' -const BITBUCKET_CURSOR_MAX_LENGTH = 4_096 - -export const BITBUCKET_SELECTOR_PAGE_SIZE = 100 - -const bitbucketSlugSchema = z - .string() - .trim() - .min(1, 'Bitbucket slug is required') - .max(255, 'Bitbucket slug must be 255 characters or fewer') - -const bitbucketWorkspaceUuidPattern = - /^(?:\{[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\}|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i - -/** Bitbucket workspace ids are alphanumeric with hyphens and underscores only. */ -const bitbucketWorkspaceSlugPattern = /^[a-z0-9][a-z0-9_-]*$/i - -const bitbucketWorkspaceSlugSchema = bitbucketSlugSchema.refine( - (slug) => bitbucketWorkspaceSlugPattern.test(slug) && !bitbucketWorkspaceUuidPattern.test(slug), - 'Bitbucket workspace must be identified by its slug, not a UUID or path' -) - -const bitbucketRepositorySlugSchema = bitbucketSlugSchema.refine( - (slug) => slug.length <= 62, - 'Bitbucket repository slug must be 62 characters or fewer' -) - -/** - * Parses only absolute Bitbucket Cloud API URLs that are safe to receive an - * OAuth bearer token. Userinfo, fragments, non-default ports, lookalike hosts, - * and paths outside the v2 API are rejected. - */ -function parseBitbucketApiCursor(value: string): URL | null { - try { - const url = new URL(value) - if ( - url.origin !== BITBUCKET_API_ORIGIN || - url.username || - url.password || - url.hash || - !url.pathname.startsWith('/2.0/') - ) { - return null - } - return url - } catch { - return null - } -} - -/** Validates a provider cursor for the authenticated user's workspace stream. */ -export function isBitbucketWorkspacesCursor(value: string): boolean { - return parseBitbucketApiCursor(value)?.pathname === BITBUCKET_WORKSPACES_PATH -} - -/** - * Validates a repository cursor against the workspace dependency selected in - * the block, preventing a stale or crafted cursor from crossing workspaces. - */ -export function isBitbucketRepositoriesCursor(value: string, workspaceSlug: string): boolean { - const url = parseBitbucketApiCursor(value) - const expected = `${BITBUCKET_REPOSITORIES_PATH}/${encodeURIComponent(workspaceSlug)}` - return url?.pathname.toLowerCase() === expected.toLowerCase() -} - -const bitbucketCursorSchema = z - .string() - .min(1, 'Bitbucket cursor cannot be empty') - .max(BITBUCKET_CURSOR_MAX_LENGTH, 'Bitbucket cursor is too long') - -export const bitbucketWorkspacesBodySchema = credentialWorkflowBodySchema.extend({ - cursor: bitbucketCursorSchema - .refine(isBitbucketWorkspacesCursor, 'Invalid Bitbucket workspaces cursor') - .optional(), -}) - -export const bitbucketRepositoriesBodySchema = credentialWorkflowBodySchema - .extend({ - workspaceSlug: bitbucketWorkspaceSlugSchema, - cursor: bitbucketCursorSchema.optional(), - }) - .superRefine((body, ctx) => { - if (body.cursor && !isBitbucketRepositoriesCursor(body.cursor, body.workspaceSlug)) { - ctx.addIssue({ - code: 'custom', - path: ['cursor'], - message: 'Invalid Bitbucket repositories cursor', - }) - } - }) - -const bitbucketUuidSchema = z.string().trim().min(1).max(100) -const bitbucketNameSchema = z.string().trim().min(1).max(512) - -const bitbucketWorkspaceProviderSchema = z - .object({ - administrator: z.boolean(), - workspace: z - .object({ - slug: bitbucketSlugSchema, - uuid: bitbucketUuidSchema, - name: bitbucketNameSchema.optional(), - }) - .passthrough(), - }) - .passthrough() - -const bitbucketRepositoryProviderSchema = z - .object({ - slug: bitbucketRepositorySlugSchema.optional(), - uuid: bitbucketUuidSchema, - name: bitbucketNameSchema.optional(), - full_name: bitbucketNameSchema, - }) - .passthrough() - .refine((repository) => { - const slash = repository.full_name.indexOf('/') - if (slash <= 0 || slash !== repository.full_name.lastIndexOf('/')) return false - const fullNameSlug = repository.full_name.slice(slash + 1) - return ( - bitbucketRepositorySlugSchema.safeParse(fullNameSlug).success && - (!repository.slug || repository.slug === fullNameSlug) - ) - }, 'Bitbucket repository full_name does not match its slug') - .transform((repository) => ({ - ...repository, - slug: repository.slug ?? repository.full_name.slice(repository.full_name.indexOf('/') + 1), - })) - -/** Strictly narrows the untrusted Bitbucket Cloud workspace page. */ -export const bitbucketWorkspaceProviderPageSchema = z - .object({ - values: z.array(bitbucketWorkspaceProviderSchema).max(BITBUCKET_SELECTOR_PAGE_SIZE), - next: bitbucketCursorSchema.optional(), - }) - .passthrough() - -/** Strictly narrows the untrusted Bitbucket Cloud repository page. */ -export const bitbucketRepositoryProviderPageSchema = z - .object({ - values: z.array(bitbucketRepositoryProviderSchema).max(BITBUCKET_SELECTOR_PAGE_SIZE), - next: bitbucketCursorSchema.optional(), - }) - .passthrough() - -const bitbucketWorkspaceSchema = z.object({ - slug: bitbucketSlugSchema, - uuid: bitbucketUuidSchema, - name: bitbucketNameSchema, - administrator: z.boolean(), -}) - -const bitbucketRepositorySchema = z.object({ - slug: bitbucketRepositorySlugSchema, - uuid: bitbucketUuidSchema, - name: bitbucketNameSchema, - fullName: bitbucketNameSchema, -}) - -export const bitbucketWorkspacesSelectorContract = definePostSelector( - '/api/tools/bitbucket/workspaces', - bitbucketWorkspacesBodySchema, - z.object({ - workspaces: z.array(bitbucketWorkspaceSchema).max(BITBUCKET_SELECTOR_PAGE_SIZE), - nextCursor: optionalString, - }) -) - -export const bitbucketRepositoriesSelectorContract = definePostSelector( - '/api/tools/bitbucket/repositories', - bitbucketRepositoriesBodySchema, - z.object({ - repositories: z.array(bitbucketRepositorySchema).max(BITBUCKET_SELECTOR_PAGE_SIZE), - nextCursor: optionalString, - }) -) - -export type BitbucketWorkspacesSelectorBody = ContractBody< - typeof bitbucketWorkspacesSelectorContract -> -export type BitbucketRepositoriesSelectorBody = ContractBody< - typeof bitbucketRepositoriesSelectorContract -> -export type BitbucketWorkspacesSelectorResponse = ContractJsonResponse< - typeof bitbucketWorkspacesSelectorContract -> -export type BitbucketRepositoriesSelectorResponse = ContractJsonResponse< - typeof bitbucketRepositoriesSelectorContract -> diff --git a/apps/sim/lib/api/contracts/selectors/calcom.ts b/apps/sim/lib/api/contracts/selectors/calcom.ts deleted file mode 100644 index c310f8dffaf..00000000000 --- a/apps/sim/lib/api/contracts/selectors/calcom.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { z } from 'zod' -import { - credentialWorkflowBodySchema, - definePostSelector, - idNameSchema, -} from '@/lib/api/contracts/selectors/shared' -import type { ContractJsonResponse } from '@/lib/api/contracts/types' - -const calcomEventTypeSchema = z - .object({ id: z.string(), title: z.string(), slug: z.string() }) - .passthrough() - -export const calcomEventTypesSelectorContract = definePostSelector( - '/api/tools/calcom/event-types', - credentialWorkflowBodySchema, - z.object({ eventTypes: z.array(calcomEventTypeSchema) }) -) - -export const calcomSchedulesSelectorContract = definePostSelector( - '/api/tools/calcom/schedules', - credentialWorkflowBodySchema, - z.object({ schedules: z.array(idNameSchema) }) -) - -export type CalcomEventTypesSelectorResponse = ContractJsonResponse< - typeof calcomEventTypesSelectorContract -> -export type CalcomSchedulesSelectorResponse = ContractJsonResponse< - typeof calcomSchedulesSelectorContract -> diff --git a/apps/sim/lib/api/contracts/selectors/clickup.ts b/apps/sim/lib/api/contracts/selectors/clickup.ts deleted file mode 100644 index 9c2577f95f1..00000000000 --- a/apps/sim/lib/api/contracts/selectors/clickup.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { z } from 'zod' -import { - credentialWorkflowBodySchema, - definePostSelector, - idNameSchema, - optionalString, -} from '@/lib/api/contracts/selectors/shared' -import type { ContractJsonResponse } from '@/lib/api/contracts/types' - -export const clickupWorkspacesBodySchema = credentialWorkflowBodySchema - -export const clickupSpacesBodySchema = credentialWorkflowBodySchema.extend({ - teamId: z.string().min(1, 'Workspace (team) ID is required'), -}) - -export const clickupFoldersBodySchema = credentialWorkflowBodySchema.extend({ - spaceId: z.string().min(1, 'Space ID is required'), -}) - -/** - * ClickUp lists live either inside a folder or directly in a space - * (folderless). The route dispatches on whichever ID is provided, preferring - * the folder when both are present. - */ -export const clickupListsBodySchema = credentialWorkflowBodySchema - .extend({ - folderId: optionalString, - spaceId: optionalString, - }) - .superRefine((body, ctx) => { - if (!body.folderId?.trim() && !body.spaceId?.trim()) { - ctx.addIssue({ - code: 'custom', - path: ['folderId'], - message: 'Either folderId or spaceId is required', - }) - } - }) - -export const clickupWorkspacesSelectorContract = definePostSelector( - '/api/tools/clickup/workspaces', - clickupWorkspacesBodySchema, - z.object({ workspaces: z.array(idNameSchema) }) -) - -export const clickupSpacesSelectorContract = definePostSelector( - '/api/tools/clickup/spaces', - clickupSpacesBodySchema, - z.object({ spaces: z.array(idNameSchema) }) -) - -export const clickupFoldersSelectorContract = definePostSelector( - '/api/tools/clickup/folders', - clickupFoldersBodySchema, - z.object({ folders: z.array(idNameSchema) }) -) - -export const clickupListsSelectorContract = definePostSelector( - '/api/tools/clickup/lists', - clickupListsBodySchema, - z.object({ lists: z.array(idNameSchema) }) -) - -export type ClickupWorkspacesSelectorResponse = ContractJsonResponse< - typeof clickupWorkspacesSelectorContract -> -export type ClickupSpacesSelectorResponse = ContractJsonResponse< - typeof clickupSpacesSelectorContract -> -export type ClickupFoldersSelectorResponse = ContractJsonResponse< - typeof clickupFoldersSelectorContract -> -export type ClickupListsSelectorResponse = ContractJsonResponse diff --git a/apps/sim/lib/api/contracts/selectors/confluence.ts b/apps/sim/lib/api/contracts/selectors/confluence.ts index b680a1d8c93..8a49ac8d73c 100644 --- a/apps/sim/lib/api/contracts/selectors/confluence.ts +++ b/apps/sim/lib/api/contracts/selectors/confluence.ts @@ -1,31 +1,9 @@ import { z } from 'zod' -import { - credentialWorkflowDomainBodySchema, - definePostSelector, - fileOptionSchema, - optionalString, -} from '@/lib/api/contracts/selectors/shared' +import { definePostSelector, optionalString } from '@/lib/api/contracts/selectors/shared' import type { ContractBody, ContractJsonResponse, ContractQuery } from '@/lib/api/contracts/types' import { defineRouteContract } from '@/lib/api/contracts/types' import { validateAlphanumericId } from '@/lib/core/security/input-validation' -const confluenceSpaceSchema = z - .object({ - id: z.string(), - name: z.string(), - key: z.string(), - status: z.string().optional(), - }) - .passthrough() - -export const confluencePagesBodySchema = z.object({ - domain: z.string().min(1, 'Domain is required'), - accessToken: z.string().min(1, 'Access token is required'), - cloudId: optionalString, - title: optionalString, - limit: z.number().int().positive().optional().default(50), -}) - /** * Refines a `pageId` field to match Confluence's alphanumeric format * (max 255 chars). Used as a `superRefine` so multiple @@ -364,35 +342,6 @@ const defineConfluenceGetContract = (path: string, que }, }) -export const confluenceSpacesSelectorBodySchema = credentialWorkflowDomainBodySchema.extend({ - cursor: optionalString, - /** - * Exact space key to resolve server-side, bypassing pagination. Confluence v2 - * `/spaces` supports a `keys` filter, so a known key resolves in one request - * instead of depending on how far the background page drain has progressed. - */ - spaceKey: z - .string() - .min(1, 'spaceKey cannot be empty') - .max(255, 'spaceKey must be 255 characters or fewer') - .optional(), -}) - -export const confluenceSpacesSelectorContract = definePostSelector( - '/api/tools/confluence/selector-spaces', - confluenceSpacesSelectorBodySchema, - z.object({ - spaces: z.array(confluenceSpaceSchema), - nextCursor: optionalString, - }) -) - -export const confluencePagesSelectorContract = definePostSelector( - '/api/tools/confluence/pages', - confluencePagesBodySchema, - z.object({ files: z.array(fileOptionSchema) }) -) - export const confluencePageSelectorContract = definePostSelector( '/api/tools/confluence/page', confluencePageBodySchema, @@ -560,7 +509,6 @@ export const confluenceUserContract = defineConfluencePostContract( confluenceUserBodySchema ) -export type ConfluencePagesBody = ContractBody export type ConfluencePageBody = ContractBody export type ConfluenceUpdatePageBody = ContractBody export type ConfluenceDeletePageBody = ContractBody @@ -612,12 +560,6 @@ export type ConfluenceListSpacesQuery = ContractQuery export type ConfluenceUploadAttachmentBody = ContractBody export type ConfluenceUserBody = ContractBody -export type ConfluenceSpacesSelectorResponse = ContractJsonResponse< - typeof confluenceSpacesSelectorContract -> -export type ConfluencePagesSelectorResponse = ContractJsonResponse< - typeof confluencePagesSelectorContract -> export type ConfluencePageSelectorResponse = ContractJsonResponse< typeof confluencePageSelectorContract > diff --git a/apps/sim/lib/api/contracts/selectors/execute.ts b/apps/sim/lib/api/contracts/selectors/execute.ts new file mode 100644 index 00000000000..5808cd42711 --- /dev/null +++ b/apps/sim/lib/api/contracts/selectors/execute.ts @@ -0,0 +1,144 @@ +import { z } from 'zod' +import { defineRouteContract } from '@/lib/api/contracts' +import { workflowIdSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' +import { type SelectorKey, selectorManifest } from '@/lib/selectors/manifest' +import { selectorContextKeys } from '@/lib/selectors/types' + +const selectorKeySet = new Set(Object.keys(selectorManifest)) +const selectorContextKeySet = new Set(selectorContextKeys) + +export const selectorKeySchema = z.custom( + (value) => typeof value === 'string' && selectorKeySet.has(value), + { error: 'Unknown selector key' } +) + +export const selectorScopeSchema = z.discriminatedUnion('kind', [ + z + .object({ + kind: z.literal('workflow'), + workflowId: workflowIdSchema, + workspaceId: workspaceIdSchema.optional(), + }) + .strict(), + z + .object({ + kind: z.literal('workspace'), + workspaceId: workspaceIdSchema, + }) + .strict(), +]) + +export const selectorContextSchema = z + .record(z.string().min(1).max(64), z.string().max(16 * 1024)) + .superRefine((context, issueContext) => { + const keys = Object.keys(context) + if (keys.length > selectorContextKeys.length) { + issueContext.addIssue({ + code: 'custom', + message: 'Selector context contains too many fields', + }) + } + for (const key of keys) { + if (!selectorContextKeySet.has(key)) { + issueContext.addIssue({ + code: 'custom', + path: [key], + message: 'Unknown selector context field', + }) + } + } + const characters = Object.values(context).reduce((total, value) => total + value.length, 0) + if (characters > 128 * 1024) { + issueContext.addIssue({ + code: 'custom', + message: 'Selector context exceeds its aggregate size limit', + }) + } + }) + +export const selectorRequestSchema = z.discriminatedUnion('kind', [ + z + .object({ + kind: z.literal('list'), + search: z.string().max(1_024).optional(), + cursor: z + .string() + .min(1) + .max(16 * 1024) + .optional(), + }) + .strict(), + z + .object({ + kind: z.literal('detail'), + id: z + .string() + .min(1) + .max(16 * 1024), + }) + .strict(), +]) + +export const executeSelectorBodySchema = z + .object({ + selectorKey: selectorKeySchema, + scope: selectorScopeSchema, + context: selectorContextSchema, + request: selectorRequestSchema, + }) + .strict() + +const safeOptionMetaValueSchema = z.union([ + z.string().max(16 * 1024), + z.number().finite(), + z.boolean(), + z.null(), +]) + +export const selectorOptionSchema = z + .object({ + id: z + .string() + .min(1) + .max(16 * 1024), + label: z + .string() + .min(1) + .max(16 * 1024), + meta: z.record(z.string().min(1).max(128), safeOptionMetaValueSchema).optional(), + }) + .strict() + +export const executeSelectorResponseSchema = z.discriminatedUnion('kind', [ + z + .object({ + kind: z.literal('list'), + items: z.array(selectorOptionSchema).max(10_000), + nextCursor: z + .string() + .min(1) + .max(16 * 1024) + .optional(), + }) + .strict(), + z + .object({ + kind: z.literal('detail'), + item: selectorOptionSchema.nullable(), + }) + .strict(), +]) + +export const executeSelectorContract = defineRouteContract({ + method: 'POST', + path: '/api/selectors/execute', + body: executeSelectorBodySchema, + response: { + mode: 'json', + schema: executeSelectorResponseSchema, + }, +}) + +export type ExecuteSelectorBody = z.input +export type ExecuteSelectorRequest = z.output +export type ExecuteSelectorResponse = z.output diff --git a/apps/sim/lib/api/contracts/selectors/google.ts b/apps/sim/lib/api/contracts/selectors/google.ts deleted file mode 100644 index 7b464048c79..00000000000 --- a/apps/sim/lib/api/contracts/selectors/google.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { z } from 'zod' -import { - credentialIdQuerySchema, - credentialWorkflowImpersonateBodySchema, - defineGetSelector, - definePostSelector, - fileOptionSchema, - folderOptionSchema, - idNameSchema, - idTitleSchema, - optionalString, -} from '@/lib/api/contracts/selectors/shared' -import type { - ContractBodyInput, - ContractJsonResponse, - ContractQueryInput, -} from '@/lib/api/contracts/types' - -const googleCalendarSchema = z.object({ id: z.string(), summary: z.string() }).passthrough() -const gmailLabelSchema = z - .object({ - id: z.string(), - name: z.string(), - type: z.string().optional(), - messagesTotal: z.number().optional(), - messagesUnread: z.number().optional(), - }) - .passthrough() - -export const labelsQuerySchema = credentialIdQuerySchema.extend({ - query: optionalString, - impersonateEmail: optionalString, -}) - -export const gmailLabelQuerySchema = credentialIdQuerySchema.extend({ - labelId: z.string().min(1), - impersonateEmail: optionalString, -}) - -export const googleCalendarQuerySchema = credentialIdQuerySchema.extend({ - workflowId: optionalString, - impersonateEmail: optionalString, -}) - -export const googleDriveFilesQuerySchema = credentialIdQuerySchema.extend({ - mimeType: optionalString, - folderId: optionalString, - parentId: optionalString, - query: optionalString, - workflowId: optionalString, - impersonateEmail: optionalString, -}) - -export const googleDriveFileQuerySchema = credentialIdQuerySchema.extend({ - fileId: z.string().min(1, 'File ID is required'), - workflowId: optionalString, - impersonateEmail: optionalString, -}) - -export const googleSheetsQuerySchema = credentialIdQuerySchema.extend({ - spreadsheetId: z.string().min(1, 'Spreadsheet ID is required'), - workflowId: optionalString, - impersonateEmail: optionalString, -}) - -export const gmailLabelsSelectorContract = defineGetSelector( - '/api/tools/gmail/labels', - labelsQuerySchema, - z.object({ labels: z.array(folderOptionSchema) }) -) - -export const gmailLabelSelectorContract = defineGetSelector( - '/api/tools/gmail/label', - gmailLabelQuerySchema, - z.object({ label: gmailLabelSchema }) -) - -export const googleCalendarSelectorContract = defineGetSelector( - '/api/tools/google_calendar/calendars', - googleCalendarQuerySchema, - z.object({ calendars: z.array(googleCalendarSchema) }) -) - -export const googleTasksTaskListsSelectorContract = definePostSelector( - '/api/tools/google_tasks/task-lists', - credentialWorkflowImpersonateBodySchema, - z.object({ taskLists: z.array(idTitleSchema) }) -) - -export const googleDriveFilesSelectorContract = defineGetSelector( - '/api/tools/drive/files', - googleDriveFilesQuerySchema, - z.object({ files: z.array(fileOptionSchema) }) -) - -export const googleDriveFileSelectorContract = defineGetSelector( - '/api/tools/drive/file', - googleDriveFileQuerySchema, - z.object({ file: fileOptionSchema.optional() }).passthrough() -) - -export const googleSheetsSelectorContract = defineGetSelector( - '/api/tools/google_sheets/sheets', - googleSheetsQuerySchema, - z.object({ sheets: z.array(idNameSchema) }) -) - -export type GmailLabelsSelectorQuery = ContractQueryInput -export type GmailLabelSelectorQuery = ContractQueryInput -export type GoogleCalendarSelectorQuery = ContractQueryInput -export type GoogleTasksTaskListsSelectorBody = ContractBodyInput< - typeof googleTasksTaskListsSelectorContract -> -export type GoogleDriveFilesSelectorQuery = ContractQueryInput< - typeof googleDriveFilesSelectorContract -> -export type GoogleDriveFileSelectorQuery = ContractQueryInput< - typeof googleDriveFileSelectorContract -> -export type GoogleSheetsSelectorQuery = ContractQueryInput - -export type GmailLabelsSelectorResponse = ContractJsonResponse -export type GmailLabelSelectorResponse = ContractJsonResponse -export type GoogleCalendarSelectorResponse = ContractJsonResponse< - typeof googleCalendarSelectorContract -> -export type GoogleTasksTaskListsSelectorResponse = ContractJsonResponse< - typeof googleTasksTaskListsSelectorContract -> -export type GoogleDriveFilesSelectorResponse = ContractJsonResponse< - typeof googleDriveFilesSelectorContract -> -export type GoogleDriveFileSelectorResponse = ContractJsonResponse< - typeof googleDriveFileSelectorContract -> -export type GoogleSheetsSelectorResponse = ContractJsonResponse diff --git a/apps/sim/lib/api/contracts/selectors/harmonic.ts b/apps/sim/lib/api/contracts/selectors/harmonic.ts index d3e6c6761cc..4a5ead8398a 100644 --- a/apps/sim/lib/api/contracts/selectors/harmonic.ts +++ b/apps/sim/lib/api/contracts/selectors/harmonic.ts @@ -1,29 +1,7 @@ import { z } from 'zod' -import { workflowIdSchema } from '@/lib/api/contracts/primitives' -import { definePostSelector } from '@/lib/api/contracts/selectors/shared' -import type { ContractBodyInput, ContractJsonResponse } from '@/lib/api/contracts/types' export const HARMONIC_SAVED_SEARCH_SELECTOR_MAX_OPTIONS = 500 -const harmonicCredentialSchema = z - .string({ error: 'Credential is required' }) - .trim() - .min(1, 'Credential is required') - .max(128, 'Credential ID is too long') - -const harmonicWorkflowIdSchema = workflowIdSchema - .trim() - .min(1, 'Workflow ID is required') - .max(128, 'Workflow ID is too long') - -export const harmonicSavedSearchesBodySchema = z - .object({ - credential: harmonicCredentialSchema, - workflowId: harmonicWorkflowIdSchema, - }) - .strict() - -const harmonicSavedSearchIdSchema = z.string().regex(/^-?\d+$/, 'Invalid Harmonic saved-search ID') const harmonicSavedSearchUrnSchema = z .string() .trim() @@ -41,30 +19,3 @@ export const harmonicPeopleSavedSearchProviderSchema = z type: z.literal('PERSONS'), }) .passthrough() - -export const harmonicSavedSearchSelectorOptionSchema = z - .object({ - id: harmonicSavedSearchIdSchema, - urn: harmonicSavedSearchUrnSchema, - name: harmonicSavedSearchNameSchema, - }) - .strict() - -export const harmonicSavedSearchesSelectorContract = definePostSelector( - '/api/tools/harmonic/saved-searches', - harmonicSavedSearchesBodySchema, - z - .object({ - savedSearches: z - .array(harmonicSavedSearchSelectorOptionSchema) - .max(HARMONIC_SAVED_SEARCH_SELECTOR_MAX_OPTIONS), - }) - .strict() -) - -export type HarmonicSavedSearchesSelectorBody = ContractBodyInput< - typeof harmonicSavedSearchesSelectorContract -> -export type HarmonicSavedSearchesSelectorResponse = ContractJsonResponse< - typeof harmonicSavedSearchesSelectorContract -> diff --git a/apps/sim/lib/api/contracts/selectors/hubspot.ts b/apps/sim/lib/api/contracts/selectors/hubspot.ts deleted file mode 100644 index b8c44aadf59..00000000000 --- a/apps/sim/lib/api/contracts/selectors/hubspot.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { z } from 'zod' -import { - credentialIdQuerySchema, - defineGetSelector, - optionalString, -} from '@/lib/api/contracts/selectors/shared' -import type { ContractJsonResponse, ContractQueryInput } from '@/lib/api/contracts/types' - -const hubspotPropertySchema = z - .object({ - id: z.string(), - name: z.string(), - type: z.string().optional(), - fieldType: z.string().optional(), - groupName: z.string().optional(), - }) - .passthrough() - -const hubspotListSchema = z - .object({ - id: z.string(), - name: z.string(), - objectType: z.string().optional(), - processingType: z.string().optional(), - }) - .passthrough() - -const hubspotPipelineSchema = z - .object({ - id: z.string(), - name: z.string(), - stages: z.array(z.object({ id: z.string(), label: z.string() }).passthrough()).optional(), - }) - .passthrough() - -const hubspotOwnerSchema = z - .object({ - id: z.string(), - name: z.string(), - email: z.string().optional(), - }) - .passthrough() - -const hubspotPropertiesQuerySchema = credentialIdQuerySchema.extend({ - objectType: z - .string() - .min(1, 'objectType is required') - .describe('Built-in slug or custom object type id'), - query: optionalString, -}) - -const hubspotListsQuerySchema = credentialIdQuerySchema.extend({ - objectTypeId: optionalString.describe('Limit to lists targeting this object type'), - query: optionalString, -}) - -const hubspotPipelinesQuerySchema = credentialIdQuerySchema.extend({ - objectType: z - .string() - .min(1, 'objectType is required') - .describe("Object type for which to fetch pipelines (e.g., 'deal' or 'ticket')"), -}) - -const hubspotOwnersQuerySchema = credentialIdQuerySchema.extend({ - query: optionalString, -}) - -export const hubspotPropertiesSelectorContract = defineGetSelector( - '/api/tools/hubspot/properties', - hubspotPropertiesQuerySchema, - z.object({ properties: z.array(hubspotPropertySchema) }) -) - -export const hubspotListsSelectorContract = defineGetSelector( - '/api/tools/hubspot/lists', - hubspotListsQuerySchema, - z.object({ lists: z.array(hubspotListSchema) }) -) - -export const hubspotPipelinesSelectorContract = defineGetSelector( - '/api/tools/hubspot/pipelines', - hubspotPipelinesQuerySchema, - z.object({ pipelines: z.array(hubspotPipelineSchema) }) -) - -export const hubspotOwnersSelectorContract = defineGetSelector( - '/api/tools/hubspot/owners', - hubspotOwnersQuerySchema, - z.object({ owners: z.array(hubspotOwnerSchema) }) -) - -export type HubspotPropertiesSelectorQuery = ContractQueryInput< - typeof hubspotPropertiesSelectorContract -> -export type HubspotListsSelectorQuery = ContractQueryInput -export type HubspotPipelinesSelectorQuery = ContractQueryInput< - typeof hubspotPipelinesSelectorContract -> -export type HubspotOwnersSelectorQuery = ContractQueryInput - -export type HubspotPropertiesSelectorResponse = ContractJsonResponse< - typeof hubspotPropertiesSelectorContract -> -export type HubspotListsSelectorResponse = ContractJsonResponse -export type HubspotPipelinesSelectorResponse = ContractJsonResponse< - typeof hubspotPipelinesSelectorContract -> -export type HubspotOwnersSelectorResponse = ContractJsonResponse< - typeof hubspotOwnersSelectorContract -> diff --git a/apps/sim/lib/api/contracts/selectors/index.ts b/apps/sim/lib/api/contracts/selectors/index.ts index c8d7e3c411a..4b098935e77 100644 --- a/apps/sim/lib/api/contracts/selectors/index.ts +++ b/apps/sim/lib/api/contracts/selectors/index.ts @@ -1,238 +1 @@ -import { - airtableBasesSelectorContract, - airtableTablesSelectorContract, -} from '@/lib/api/contracts/selectors/airtable' -import { asanaWorkspacesSelectorContract } from '@/lib/api/contracts/selectors/asana' -import { - attioListsSelectorContract, - attioObjectsSelectorContract, -} from '@/lib/api/contracts/selectors/attio' -import { - bigQueryDatasetsSelectorContract, - bigQueryTablesSelectorContract, -} from '@/lib/api/contracts/selectors/bigquery' -import { - bitbucketRepositoriesSelectorContract, - bitbucketWorkspacesSelectorContract, -} from '@/lib/api/contracts/selectors/bitbucket' -import { - calcomEventTypesSelectorContract, - calcomSchedulesSelectorContract, -} from '@/lib/api/contracts/selectors/calcom' -import { - clickupFoldersSelectorContract, - clickupListsSelectorContract, - clickupSpacesSelectorContract, - clickupWorkspacesSelectorContract, -} from '@/lib/api/contracts/selectors/clickup' -import { - cloudwatchLogGroupsSelectorContract, - cloudwatchLogStreamsSelectorContract, -} from '@/lib/api/contracts/selectors/cloudwatch' -import { - confluencePageSelectorContract, - confluencePagesSelectorContract, - confluenceSpacesSelectorContract, -} from '@/lib/api/contracts/selectors/confluence' -import { - gmailLabelSelectorContract, - gmailLabelsSelectorContract, - googleCalendarSelectorContract, - googleDriveFileSelectorContract, - googleDriveFilesSelectorContract, - googleSheetsSelectorContract, - googleTasksTaskListsSelectorContract, -} from '@/lib/api/contracts/selectors/google' -import { harmonicSavedSearchesSelectorContract } from '@/lib/api/contracts/selectors/harmonic' -import { - hubspotListsSelectorContract, - hubspotOwnersSelectorContract, - hubspotPipelinesSelectorContract, - hubspotPropertiesSelectorContract, -} from '@/lib/api/contracts/selectors/hubspot' -import { - jiraIssueSelectorContract, - jiraIssuesSelectorContract, - jiraProjectSelectorContract, - jiraProjectsSelectorContract, -} from '@/lib/api/contracts/selectors/jira' -import { - jsmRequestTypesSelectorContract, - jsmServiceDesksSelectorContract, -} from '@/lib/api/contracts/selectors/jsm' -import { - linearProjectsSelectorContract, - linearTeamsSelectorContract, -} from '@/lib/api/contracts/selectors/linear' -import { - microsoftChannelsSelectorContract, - microsoftChatsSelectorContract, - microsoftExcelDriveSelectorContract, - microsoftExcelDrivesSelectorContract, - microsoftExcelSheetsSelectorContract, - microsoftFileSelectorContract, - microsoftFilesSelectorContract, - microsoftPlannerPlansSelectorContract, - microsoftPlannerTasksSelectorContract, - microsoftTeamsSelectorContract, - onedriveFilesSelectorContract, - onedriveFolderSelectorContract, - onedriveFoldersSelectorContract, - outlookCalendarsSelectorContract, - outlookFoldersSelectorContract, -} from '@/lib/api/contracts/selectors/microsoft' -import { - mondayBoardsSelectorContract, - mondayGroupsSelectorContract, -} from '@/lib/api/contracts/selectors/monday' -import { netsuiteObjectsSelectorContract } from '@/lib/api/contracts/selectors/netsuite' -import { - notionDatabasesSelectorContract, - notionPagesSelectorContract, -} from '@/lib/api/contracts/selectors/notion' -import { pipedrivePipelinesSelectorContract } from '@/lib/api/contracts/selectors/pipedrive' -import { - sharepointListsSelectorContract, - sharepointSiteSelectorContract, - sharepointSitesSelectorContract, -} from '@/lib/api/contracts/selectors/sharepoint' -import { - slackChannelsSelectorContract, - slackUserSelectorContract, - slackUsersSelectorContract, -} from '@/lib/api/contracts/selectors/slack' -import { snowflakeObjectsSelectorContract } from '@/lib/api/contracts/selectors/snowflake' -import { trelloBoardsSelectorContract } from '@/lib/api/contracts/selectors/trello' -import { - wealthboxItemContract, - wealthboxItemsSelectorContract, - wealthboxOAuthItemContract, - wealthboxOAuthItemsContract, -} from '@/lib/api/contracts/selectors/wealthbox' -import { - webflowCollectionsSelectorContract, - webflowItemsSelectorContract, - webflowSitesSelectorContract, -} from '@/lib/api/contracts/selectors/webflow' -import { - zohoDeskAgentsSelectorContract, - zohoDeskDepartmentsSelectorContract, - zohoDeskOrganizationsSelectorContract, -} from '@/lib/api/contracts/selectors/zoho-desk' -import { zoomMeetingsSelectorContract } from '@/lib/api/contracts/selectors/zoom' - -export * from '@/lib/api/contracts/selectors/airtable' -export * from '@/lib/api/contracts/selectors/asana' -export * from '@/lib/api/contracts/selectors/attio' -export * from '@/lib/api/contracts/selectors/bigquery' -export * from '@/lib/api/contracts/selectors/bitbucket' -export * from '@/lib/api/contracts/selectors/calcom' -export * from '@/lib/api/contracts/selectors/clickup' -export * from '@/lib/api/contracts/selectors/cloudwatch' -export * from '@/lib/api/contracts/selectors/confluence' -export * from '@/lib/api/contracts/selectors/google' -export * from '@/lib/api/contracts/selectors/harmonic' -export * from '@/lib/api/contracts/selectors/hubspot' -export * from '@/lib/api/contracts/selectors/jira' -export * from '@/lib/api/contracts/selectors/jsm' -export * from '@/lib/api/contracts/selectors/knowledge' -export * from '@/lib/api/contracts/selectors/linear' -export * from '@/lib/api/contracts/selectors/microsoft' -export * from '@/lib/api/contracts/selectors/monday' -export * from '@/lib/api/contracts/selectors/netsuite' -export * from '@/lib/api/contracts/selectors/notion' -export * from '@/lib/api/contracts/selectors/oauth' -export * from '@/lib/api/contracts/selectors/pipedrive' -export * from '@/lib/api/contracts/selectors/sharepoint' -export * from '@/lib/api/contracts/selectors/slack' -export * from '@/lib/api/contracts/selectors/snowflake' -export * from '@/lib/api/contracts/selectors/trello' -export * from '@/lib/api/contracts/selectors/wealthbox' -export * from '@/lib/api/contracts/selectors/webflow' -export * from '@/lib/api/contracts/selectors/zoho-desk' -export * from '@/lib/api/contracts/selectors/zoom' - -export const selectorContractsByPath = { - '/api/tools/airtable/bases': airtableBasesSelectorContract, - '/api/tools/airtable/tables': airtableTablesSelectorContract, - '/api/tools/asana/workspaces': asanaWorkspacesSelectorContract, - '/api/tools/attio/objects': attioObjectsSelectorContract, - '/api/tools/attio/lists': attioListsSelectorContract, - '/api/tools/google_bigquery/datasets': bigQueryDatasetsSelectorContract, - '/api/tools/google_bigquery/tables': bigQueryTablesSelectorContract, - '/api/tools/bitbucket/workspaces': bitbucketWorkspacesSelectorContract, - '/api/tools/bitbucket/repositories': bitbucketRepositoriesSelectorContract, - '/api/tools/calcom/event-types': calcomEventTypesSelectorContract, - '/api/tools/calcom/schedules': calcomSchedulesSelectorContract, - '/api/tools/clickup/workspaces': clickupWorkspacesSelectorContract, - '/api/tools/clickup/spaces': clickupSpacesSelectorContract, - '/api/tools/clickup/folders': clickupFoldersSelectorContract, - '/api/tools/clickup/lists': clickupListsSelectorContract, - '/api/tools/confluence/selector-spaces': confluenceSpacesSelectorContract, - '/api/tools/jsm/selector-servicedesks': jsmServiceDesksSelectorContract, - '/api/tools/jsm/selector-requesttypes': jsmRequestTypesSelectorContract, - '/api/tools/google_tasks/task-lists': googleTasksTaskListsSelectorContract, - '/api/tools/harmonic/saved-searches': harmonicSavedSearchesSelectorContract, - '/api/tools/microsoft_planner/plans': microsoftPlannerPlansSelectorContract, - '/api/tools/microsoft_planner/tasks': microsoftPlannerTasksSelectorContract, - '/api/tools/notion/databases': notionDatabasesSelectorContract, - '/api/tools/notion/pages': notionPagesSelectorContract, - '/api/tools/pipedrive/pipelines': pipedrivePipelinesSelectorContract, - '/api/tools/sharepoint/lists': sharepointListsSelectorContract, - '/api/tools/sharepoint/site': sharepointSiteSelectorContract, - '/api/tools/sharepoint/sites': sharepointSitesSelectorContract, - '/api/tools/trello/boards': trelloBoardsSelectorContract, - '/api/tools/zoho_desk/organizations': zohoDeskOrganizationsSelectorContract, - '/api/tools/zoho_desk/departments': zohoDeskDepartmentsSelectorContract, - '/api/tools/zoho_desk/agents': zohoDeskAgentsSelectorContract, - '/api/tools/zoom/meetings': zoomMeetingsSelectorContract, - '/api/tools/slack/channels': slackChannelsSelectorContract, - '/api/tools/snowflake/objects': snowflakeObjectsSelectorContract, - '/api/tools/slack/users': slackUsersSelectorContract, - '/api/tools/slack/users:detail': slackUserSelectorContract, - '/api/tools/gmail/labels': gmailLabelsSelectorContract, - '/api/tools/gmail/label': gmailLabelSelectorContract, - '/api/tools/hubspot/properties': hubspotPropertiesSelectorContract, - '/api/tools/hubspot/lists': hubspotListsSelectorContract, - '/api/tools/hubspot/pipelines': hubspotPipelinesSelectorContract, - '/api/tools/hubspot/owners': hubspotOwnersSelectorContract, - '/api/tools/outlook/folders': outlookFoldersSelectorContract, - '/api/tools/outlook/calendars': outlookCalendarsSelectorContract, - '/api/tools/google_calendar/calendars': googleCalendarSelectorContract, - '/api/tools/microsoft-teams/teams': microsoftTeamsSelectorContract, - '/api/tools/microsoft-teams/chats': microsoftChatsSelectorContract, - '/api/tools/microsoft-teams/channels': microsoftChannelsSelectorContract, - '/api/tools/wealthbox/items': wealthboxItemsSelectorContract, - '/api/tools/wealthbox/item': wealthboxItemContract, - '/api/auth/oauth/wealthbox/items': wealthboxOAuthItemsContract, - '/api/auth/oauth/wealthbox/item': wealthboxOAuthItemContract, - '/api/tools/jira/projects': jiraProjectsSelectorContract, - '/api/tools/jira/projects:POST': jiraProjectSelectorContract, - '/api/tools/jira/issues': jiraIssuesSelectorContract, - '/api/tools/jira/issues:POST': jiraIssueSelectorContract, - '/api/tools/monday/boards': mondayBoardsSelectorContract, - '/api/tools/monday/groups': mondayGroupsSelectorContract, - '/api/tools/netsuite/objects': netsuiteObjectsSelectorContract, - '/api/tools/linear/teams': linearTeamsSelectorContract, - '/api/tools/linear/projects': linearProjectsSelectorContract, - '/api/tools/confluence/pages': confluencePagesSelectorContract, - '/api/tools/confluence/page': confluencePageSelectorContract, - '/api/tools/onedrive/files': onedriveFilesSelectorContract, - '/api/tools/onedrive/folder': onedriveFolderSelectorContract, - '/api/tools/onedrive/folders': onedriveFoldersSelectorContract, - '/api/tools/drive/files': googleDriveFilesSelectorContract, - '/api/tools/drive/file': googleDriveFileSelectorContract, - '/api/tools/google_sheets/sheets': googleSheetsSelectorContract, - '/api/tools/microsoft_excel/sheets': microsoftExcelSheetsSelectorContract, - '/api/tools/microsoft_excel/drives': microsoftExcelDrivesSelectorContract, - '/api/tools/microsoft_excel/drives:detail': microsoftExcelDriveSelectorContract, - '/api/auth/oauth/microsoft/file': microsoftFileSelectorContract, - '/api/auth/oauth/microsoft/files': microsoftFilesSelectorContract, - '/api/tools/webflow/sites': webflowSitesSelectorContract, - '/api/tools/webflow/collections': webflowCollectionsSelectorContract, - '/api/tools/webflow/items': webflowItemsSelectorContract, - '/api/tools/cloudwatch/describe-log-groups': cloudwatchLogGroupsSelectorContract, - '/api/tools/cloudwatch/describe-log-streams': cloudwatchLogStreamsSelectorContract, -} as const - -export type SelectorContractPath = keyof typeof selectorContractsByPath +export * from '@/lib/api/contracts/selectors/execute' diff --git a/apps/sim/lib/api/contracts/selectors/jira.ts b/apps/sim/lib/api/contracts/selectors/jira.ts index c9cf30fd65b..e61e6131091 100644 --- a/apps/sim/lib/api/contracts/selectors/jira.ts +++ b/apps/sim/lib/api/contracts/selectors/jira.ts @@ -1,68 +1,8 @@ import { z } from 'zod' -import { idNameSchema, optionalString } from '@/lib/api/contracts/selectors/shared' -import type { ContractBody, ContractJsonResponse, ContractQuery } from '@/lib/api/contracts/types' +import type { ContractBody } from '@/lib/api/contracts/types' import { defineRouteContract } from '@/lib/api/contracts/types' import { RawFileInputArraySchema } from '@/lib/uploads/utils/file-schemas' -const jiraIssueSectionSchema = z - .object({ - issues: z.array( - z - .object({ - id: z.string().optional(), - key: z.string().optional(), - summary: z.string().optional(), - }) - .passthrough() - ), - }) - .passthrough() - -export const jiraProjectsQuerySchema = z.object({ - domain: z.string().trim().min(1, 'Domain is required'), - accessToken: z.string().min(1, 'Access token is required'), - cloudId: optionalString, - query: optionalString, -}) - -export const jiraProjectBodySchema = z.object({ - domain: z.string().min(1, 'Domain is required'), - accessToken: z.string().min(1, 'Access token is required'), - cloudId: optionalString, - projectId: z.string().min(1, 'Project ID is required'), -}) - -/** - * GET `/api/tools/jira/issues` query. - */ -export const jiraIssuesQuerySchema = z.object({ - domain: z.string().trim().min(1, 'Domain is required'), - accessToken: z.string().min(1, 'Access token is required'), - cloudId: optionalString, - projectId: optionalString, - manualProjectId: optionalString, - query: optionalString, - all: z - .preprocess( - (value) => (typeof value === 'string' ? value.toLowerCase() === 'true' : value), - z.boolean() - ) - .default(false), - limit: z - .preprocess((value) => { - const parsed = typeof value === 'string' ? Number.parseInt(value, 10) : value - return typeof parsed === 'number' && Number.isFinite(parsed) && parsed > 0 ? parsed : 0 - }, z.number()) - .default(0), -}) - -export const jiraIssuesBodySchema = z.object({ - domain: z.string().min(1, 'Domain is required'), - accessToken: z.string().min(1, 'Access token is required'), - cloudId: optionalString, - issueKeys: z.array(z.string().min(1)).default([]), -}) - export const jiraParentReferenceSchema = z.union([ z.string().min(1), z.object({ key: z.string().min(1) }).passthrough(), @@ -119,57 +59,6 @@ export const jiraAddAttachmentBodySchema = z.object({ cloudId: z.string().optional().nullable(), }) -export const jiraProjectsSelectorContract = defineRouteContract({ - method: 'GET', - path: '/api/tools/jira/projects', - query: jiraProjectsQuerySchema, - response: { - mode: 'json', - schema: z - .object({ projects: z.array(idNameSchema), cloudId: z.string().optional() }) - .passthrough(), - }, -}) - -export const jiraProjectSelectorContract = defineRouteContract({ - method: 'POST', - path: '/api/tools/jira/projects', - body: jiraProjectBodySchema, - response: { - mode: 'json', - schema: z - .object({ project: idNameSchema.optional(), cloudId: z.string().optional() }) - .passthrough(), - }, -}) - -export const jiraIssuesSelectorContract = defineRouteContract({ - method: 'GET', - path: '/api/tools/jira/issues', - query: jiraIssuesQuerySchema, - response: { - mode: 'json', - schema: z - .object({ - sections: z.array(jiraIssueSectionSchema).optional(), - cloudId: z.string().optional(), - }) - .passthrough(), - }, -}) - -export const jiraIssueSelectorContract = defineRouteContract({ - method: 'POST', - path: '/api/tools/jira/issues', - body: jiraIssuesBodySchema, - response: { - mode: 'json', - schema: z - .object({ issues: z.array(idNameSchema).optional(), cloudId: z.string().optional() }) - .passthrough(), - }, -}) - const jiraWriteResponseSchema = z.object({ success: z.literal(true), output: z.object({ @@ -245,14 +134,6 @@ export const jiraAddAttachmentContract = defineRouteContract({ response: { mode: 'json', schema: jiraAddAttachmentResponseSchema }, }) -export type JiraProjectsQuery = ContractQuery -export type JiraProjectBody = ContractBody -export type JiraIssuesQuery = ContractQuery -export type JiraIssuesBody = ContractBody export type JiraWriteBody = ContractBody export type JiraUpdateBody = ContractBody export type JiraAddAttachmentBody = ContractBody -export type JiraProjectsSelectorResponse = ContractJsonResponse -export type JiraProjectSelectorResponse = ContractJsonResponse -export type JiraIssuesSelectorResponse = ContractJsonResponse -export type JiraIssueSelectorResponse = ContractJsonResponse diff --git a/apps/sim/lib/api/contracts/selectors/jsm.ts b/apps/sim/lib/api/contracts/selectors/jsm.ts index 7dd2706f3db..b2083ea96fb 100644 --- a/apps/sim/lib/api/contracts/selectors/jsm.ts +++ b/apps/sim/lib/api/contracts/selectors/jsm.ts @@ -1,11 +1,7 @@ import { isRecordLike } from '@sim/utils/object' import { z } from 'zod' -import { - credentialWorkflowDomainBodySchema, - definePostSelector, - idNameSchema, -} from '@/lib/api/contracts/selectors/shared' -import type { ContractBody, ContractJsonResponse } from '@/lib/api/contracts/types' +import { definePostSelector } from '@/lib/api/contracts/selectors/shared' +import type { ContractBody } from '@/lib/api/contracts/types' const jsmBaseBodySchema = z.object({ domain: z.string({ error: 'Domain is required' }).min(1, 'Domain is required'), @@ -52,10 +48,6 @@ const jsmBooleanFlagField = z .transform((value) => String(value)) .optional() -export const jsmRequestTypesBodySchema = credentialWorkflowDomainBodySchema.extend({ - serviceDeskId: z.string().min(1), -}) - export const jsmServiceDesksBodySchema = jsmBaseBodySchema.extend({ expand: z.string().optional(), start: jsmPaginationField, @@ -289,18 +281,6 @@ export const jsmDeleteObjectBodySchema = jsmAssetsBaseBodySchema.extend({ export const defineJsmToolContract = (path: string, body: TBody) => definePostSelector(path, body, z.unknown()) -export const jsmServiceDesksSelectorContract = definePostSelector( - '/api/tools/jsm/selector-servicedesks', - credentialWorkflowDomainBodySchema, - z.object({ serviceDesks: z.array(idNameSchema) }) -) - -export const jsmRequestTypesSelectorContract = definePostSelector( - '/api/tools/jsm/selector-requesttypes', - jsmRequestTypesBodySchema, - z.object({ requestTypes: z.array(idNameSchema) }) -) - export const jsmServiceDesksContract = defineJsmToolContract( '/api/tools/jsm/servicedesks', jsmServiceDesksBodySchema @@ -481,9 +461,3 @@ export type JsmFormAnswersBody = ContractBody export type JsmProjectFormTemplatesBody = ContractBody export type JsmProjectFormStructureBody = ContractBody export type JsmCopyFormsBody = ContractBody -export type JsmServiceDesksSelectorResponse = ContractJsonResponse< - typeof jsmServiceDesksSelectorContract -> -export type JsmRequestTypesSelectorResponse = ContractJsonResponse< - typeof jsmRequestTypesSelectorContract -> diff --git a/apps/sim/lib/api/contracts/selectors/knowledge.ts b/apps/sim/lib/api/contracts/selectors/knowledge.ts deleted file mode 100644 index 1234c868f64..00000000000 --- a/apps/sim/lib/api/contracts/selectors/knowledge.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { z } from 'zod' -import { optionalString } from '@/lib/api/contracts/selectors/shared' -import type { ContractJsonResponse } from '@/lib/api/contracts/types' -import { defineRouteContract } from '@/lib/api/contracts/types' - -const knowledgeDocumentsParamsSchema = z.object({ id: z.string().min(1) }) - -const knowledgeDocumentParamsSchema = knowledgeDocumentsParamsSchema.extend({ - documentId: z.string().min(1), -}) - -const knowledgeDocumentsQuerySchema = z.object({ - limit: z.coerce.number().int().min(1).max(100).optional(), - offset: z.coerce.number().int().min(0).optional(), - search: optionalString, -}) - -const knowledgeDocumentQuerySchema = z.object({ - includeDisabled: optionalString, -}) - -const knowledgeDocumentSchema = z.object({ id: z.string(), filename: z.string() }).passthrough() - -export const listKnowledgeSelectorDocumentsContract = defineRouteContract({ - method: 'GET', - path: '/api/knowledge/[id]/documents', - params: knowledgeDocumentsParamsSchema, - query: knowledgeDocumentsQuerySchema, - response: { - mode: 'json', - schema: z.object({ - success: z.literal(true), - data: z - .object({ - documents: z.array(knowledgeDocumentSchema), - pagination: z - .object({ - total: z.number(), - limit: z.number(), - offset: z.number(), - hasMore: z.boolean(), - }) - .passthrough(), - }) - .passthrough(), - }), - }, -}) - -export const getKnowledgeSelectorDocumentContract = defineRouteContract({ - method: 'GET', - path: '/api/knowledge/[id]/documents/[documentId]', - params: knowledgeDocumentParamsSchema, - query: knowledgeDocumentQuerySchema, - response: { - mode: 'json', - schema: z.object({ - success: z.literal(true), - data: knowledgeDocumentSchema, - }), - }, -}) - -export type ListKnowledgeSelectorDocumentsResponse = ContractJsonResponse< - typeof listKnowledgeSelectorDocumentsContract -> -export type GetKnowledgeSelectorDocumentResponse = ContractJsonResponse< - typeof getKnowledgeSelectorDocumentContract -> diff --git a/apps/sim/lib/api/contracts/selectors/linear.ts b/apps/sim/lib/api/contracts/selectors/linear.ts deleted file mode 100644 index 2d47e6ac487..00000000000 --- a/apps/sim/lib/api/contracts/selectors/linear.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { z } from 'zod' -import { - credentialWorkflowBodySchema, - definePostSelector, - idNameSchema, -} from '@/lib/api/contracts/selectors/shared' -import type { ContractJsonResponse } from '@/lib/api/contracts/types' - -export const linearProjectsBodySchema = credentialWorkflowBodySchema.extend({ - teamId: z.string().min(1), -}) - -export const linearTeamsSelectorContract = definePostSelector( - '/api/tools/linear/teams', - credentialWorkflowBodySchema, - z.object({ teams: z.array(idNameSchema) }) -) - -export const linearProjectsSelectorContract = definePostSelector( - '/api/tools/linear/projects', - linearProjectsBodySchema, - z.object({ projects: z.array(idNameSchema) }) -) - -export type LinearTeamsSelectorResponse = ContractJsonResponse -export type LinearProjectsSelectorResponse = ContractJsonResponse< - typeof linearProjectsSelectorContract -> diff --git a/apps/sim/lib/api/contracts/selectors/microsoft.ts b/apps/sim/lib/api/contracts/selectors/microsoft.ts deleted file mode 100644 index 5876080d003..00000000000 --- a/apps/sim/lib/api/contracts/selectors/microsoft.ts +++ /dev/null @@ -1,253 +0,0 @@ -import { z } from 'zod' -import { - credentialIdQuerySchema, - credentialIdQueryWithSearchSchema, - credentialWorkflowBodySchema, - defineGetSelector, - definePostSelector, - fileOptionSchema, - idDisplayNameSchema, - idNameSchema, - idTitleSchema, - optionalString, -} from '@/lib/api/contracts/selectors/shared' -import type { ContractBody, ContractJsonResponse, ContractQuery } from '@/lib/api/contracts/types' - -export const teamsChannelsBodySchema = credentialWorkflowBodySchema.extend({ - teamId: z.string().min(1), -}) - -export const plannerTasksBodySchema = credentialWorkflowBodySchema.extend({ - planId: z.string().min(1), -}) - -export const microsoftExcelSheetsQuerySchema = credentialIdQuerySchema.extend({ - spreadsheetId: z.string().min(1, 'Spreadsheet ID is required'), - driveId: optionalString, - workflowId: optionalString, -}) - -/** - * Body for `POST /api/tools/microsoft_excel/drives`. The route serves both - * list-drives (no `driveId`) and single-drive lookup (`driveId` provided), - * dispatching at runtime. The contract permits the optional `driveId` so a - * single body schema covers both flows. - */ -export const microsoftExcelDrivesBodySchema = credentialWorkflowBodySchema.extend({ - siteId: z.string().min(1, 'Site ID is required'), - driveId: optionalString, -}) - -/** - * The `/api/auth/oauth/microsoft/files` route is shared by the - * `microsoft.excel` and `microsoft.word` selectors. `fileType` lets the route - * search for and filter to the correct Office document type; it is optional and - * defaults to `excel` on the server for backward compatibility. - */ -export const microsoftFilesQuerySchema = credentialIdQuerySchema.extend({ - query: optionalString, - driveId: optionalString, - workflowId: optionalString, - fileType: z.enum(['excel', 'word']).optional(), -}) - -export const microsoftFileQuerySchema = credentialIdQuerySchema.extend({ - fileId: z.string({ error: 'File ID is required' }).min(1, 'File ID is required'), - workflowId: optionalString, -}) - -export const onedriveFolderQuerySchema = z.object({ - credentialId: z.preprocess( - (value) => value ?? '', - z.string().min(1, 'Credential ID and File ID are required') - ), - fileId: z.preprocess( - (value) => value ?? '', - z.string().min(1, 'Credential ID and File ID are required') - ), -}) - -export const onedriveFilesQuerySchema = credentialIdQueryWithSearchSchema -/** - * Folder listing is drive-scoped like the file listing above: without `driveId` - * the picker would always show the signed-in user's OneDrive, even for a block - * pointed at a SharePoint document library. - */ -export const onedriveFoldersQuerySchema = credentialIdQueryWithSearchSchema.extend({ - driveId: z.string().optional(), -}) -export const outlookFoldersQuerySchema = credentialIdQuerySchema - -export const outlookFoldersSelectorContract = defineGetSelector( - '/api/tools/outlook/folders', - outlookFoldersQuerySchema, - z.object({ folders: z.array(z.object({ id: z.string(), name: z.string() }).passthrough()) }) -) - -export const outlookCalendarsQuerySchema = credentialIdQuerySchema - -export const outlookCalendarsSelectorContract = defineGetSelector( - '/api/tools/outlook/calendars', - outlookCalendarsQuerySchema, - z.object({ calendars: z.array(z.object({ id: z.string(), name: z.string() }).passthrough()) }) -) - -export const microsoftTeamsSelectorContract = definePostSelector( - '/api/tools/microsoft-teams/teams', - credentialWorkflowBodySchema, - z.object({ teams: z.array(idDisplayNameSchema) }) -) - -export const microsoftChatsSelectorContract = definePostSelector( - '/api/tools/microsoft-teams/chats', - credentialWorkflowBodySchema, - z.object({ chats: z.array(idDisplayNameSchema) }) -) - -export const microsoftChannelsSelectorContract = definePostSelector( - '/api/tools/microsoft-teams/channels', - teamsChannelsBodySchema, - z.object({ channels: z.array(idDisplayNameSchema) }) -) - -export const microsoftPlannerPlansSelectorContract = definePostSelector( - '/api/tools/microsoft_planner/plans', - credentialWorkflowBodySchema, - z.object({ plans: z.array(idTitleSchema) }) -) - -export const microsoftPlannerTasksSelectorContract = definePostSelector( - '/api/tools/microsoft_planner/tasks', - plannerTasksBodySchema, - z - .object({ - tasks: z.array(idTitleSchema), - metadata: z - .object({ - planId: z.string(), - planUrl: z.string(), - }) - .passthrough() - .optional(), - }) - .passthrough() -) - -export const onedriveFilesSelectorContract = defineGetSelector( - '/api/tools/onedrive/files', - onedriveFilesQuerySchema, - z.object({ files: z.array(fileOptionSchema) }) -) - -export const onedriveFoldersSelectorContract = defineGetSelector( - '/api/tools/onedrive/folders', - onedriveFoldersQuerySchema, - z.object({ files: z.array(fileOptionSchema) }) -) - -export const onedriveFolderSelectorContract = defineGetSelector( - '/api/tools/onedrive/folder', - onedriveFolderQuerySchema, - z.object({ file: fileOptionSchema.optional() }).passthrough() -) - -export const microsoftExcelSheetsSelectorContract = defineGetSelector( - '/api/tools/microsoft_excel/sheets', - microsoftExcelSheetsQuerySchema, - z.object({ sheets: z.array(idNameSchema) }) -) - -export const microsoftExcelDrivesSelectorContract = definePostSelector( - '/api/tools/microsoft_excel/drives', - microsoftExcelDrivesBodySchema, - z.object({ drives: z.array(idNameSchema) }) -) - -/** - * Single-drive variant. Same body schema as the list contract; the `driveId` - * is what discriminates the response shape at the route layer. - */ -export const microsoftExcelDriveSelectorContract = definePostSelector( - '/api/tools/microsoft_excel/drives', - microsoftExcelDrivesBodySchema, - z.object({ drive: idNameSchema.optional() }) -) - -export const microsoftFilesSelectorContract = defineGetSelector( - '/api/auth/oauth/microsoft/files', - microsoftFilesQuerySchema, - z.object({ files: z.array(fileOptionSchema) }) -) - -export const microsoftFileSelectorContract = defineGetSelector( - '/api/auth/oauth/microsoft/file', - microsoftFileQuerySchema, - z.object({ file: fileOptionSchema.optional() }).passthrough() -) - -export type OutlookFoldersSelectorResponse = ContractJsonResponse< - typeof outlookFoldersSelectorContract -> -export type OutlookFoldersSelectorQuery = ContractQuery -export type MicrosoftTeamsSelectorResponse = ContractJsonResponse< - typeof microsoftTeamsSelectorContract -> -export type MicrosoftTeamsSelectorBody = ContractBody -export type MicrosoftChatsSelectorResponse = ContractJsonResponse< - typeof microsoftChatsSelectorContract -> -export type MicrosoftChatsSelectorBody = ContractBody -export type MicrosoftChannelsSelectorResponse = ContractJsonResponse< - typeof microsoftChannelsSelectorContract -> -export type MicrosoftChannelsSelectorBody = ContractBody -export type MicrosoftPlannerPlansSelectorResponse = ContractJsonResponse< - typeof microsoftPlannerPlansSelectorContract -> -export type MicrosoftPlannerPlansSelectorBody = ContractBody< - typeof microsoftPlannerPlansSelectorContract -> -export type MicrosoftPlannerTasksSelectorResponse = ContractJsonResponse< - typeof microsoftPlannerTasksSelectorContract -> -export type MicrosoftPlannerTasksSelectorBody = ContractBody< - typeof microsoftPlannerTasksSelectorContract -> -export type OnedriveFilesSelectorResponse = ContractJsonResponse< - typeof onedriveFilesSelectorContract -> -export type OnedriveFilesSelectorQuery = ContractQuery -export type OnedriveFoldersSelectorResponse = ContractJsonResponse< - typeof onedriveFoldersSelectorContract -> -export type OnedriveFoldersSelectorQuery = ContractQuery -export type OnedriveFolderSelectorResponse = ContractJsonResponse< - typeof onedriveFolderSelectorContract -> -export type OnedriveFolderSelectorQuery = ContractQuery -export type MicrosoftExcelSheetsSelectorResponse = ContractJsonResponse< - typeof microsoftExcelSheetsSelectorContract -> -export type MicrosoftExcelSheetsSelectorQuery = ContractQuery< - typeof microsoftExcelSheetsSelectorContract -> -export type MicrosoftExcelDrivesSelectorResponse = ContractJsonResponse< - typeof microsoftExcelDrivesSelectorContract -> -export type MicrosoftExcelDrivesSelectorBody = ContractBody< - typeof microsoftExcelDrivesSelectorContract -> -export type MicrosoftExcelDriveSelectorResponse = ContractJsonResponse< - typeof microsoftExcelDriveSelectorContract -> -export type MicrosoftExcelDriveSelectorBody = ContractBody< - typeof microsoftExcelDriveSelectorContract -> -export type MicrosoftFilesSelectorResponse = ContractJsonResponse< - typeof microsoftFilesSelectorContract -> -export type MicrosoftFilesSelectorQuery = ContractQuery -export type MicrosoftFileSelectorResponse = ContractJsonResponse< - typeof microsoftFileSelectorContract -> -export type MicrosoftFileSelectorQuery = ContractQuery diff --git a/apps/sim/lib/api/contracts/selectors/monday.ts b/apps/sim/lib/api/contracts/selectors/monday.ts deleted file mode 100644 index 63669cfa078..00000000000 --- a/apps/sim/lib/api/contracts/selectors/monday.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { z } from 'zod' -import { - credentialWorkflowBodySchema, - definePostSelector, - idNameSchema, -} from '@/lib/api/contracts/selectors/shared' -import type { ContractJsonResponse } from '@/lib/api/contracts/types' - -/** - * Monday board IDs are numeric in the API (e.g. `123456789`). Clients - * sometimes pass them as numbers and sometimes as strings, so we accept - * `string | number` and let the route's `validateMondayNumericId` enforce - * the actual numeric format. - */ -export const mondayGroupsBodySchema = credentialWorkflowBodySchema.extend({ - boardId: z.union([z.string().min(1), z.number()]), -}) - -export const mondayBoardsSelectorContract = definePostSelector( - '/api/tools/monday/boards', - credentialWorkflowBodySchema, - z.object({ boards: z.array(idNameSchema) }) -) - -export const mondayGroupsSelectorContract = definePostSelector( - '/api/tools/monday/groups', - mondayGroupsBodySchema, - z.object({ groups: z.array(idNameSchema) }) -) - -export type MondayBoardsSelectorResponse = ContractJsonResponse -export type MondayGroupsSelectorResponse = ContractJsonResponse diff --git a/apps/sim/lib/api/contracts/selectors/netsuite.ts b/apps/sim/lib/api/contracts/selectors/netsuite.ts deleted file mode 100644 index 9c4b036a9af..00000000000 --- a/apps/sim/lib/api/contracts/selectors/netsuite.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { z } from 'zod' -import { workflowIdSchema } from '@/lib/api/contracts/primitives' -import { definePostSelector } from '@/lib/api/contracts/selectors/shared' -import type { ContractBodyInput, ContractJsonResponse } from '@/lib/api/contracts/types' - -export const NETSUITE_SELECTOR_KINDS = ['record_types', 'async_tasks'] as const - -const credentialSchema = z - .string({ error: 'Credential is required' }) - .trim() - .min(1, 'Credential is required') - .max(128, 'Credential ID is too long') - -const boundedWorkflowIdSchema = workflowIdSchema.trim().max(128, 'Workflow ID is too long') - -const commonBodyShape = { - credential: credentialSchema, - workflowId: boundedWorkflowIdSchema, -} as const - -export const netsuiteObjectsBodySchema = z.discriminatedUnion('kind', [ - z.object({ ...commonBodyShape, kind: z.literal('record_types') }).strict(), - z - .object({ - ...commonBodyShape, - kind: z.literal('async_tasks'), - jobId: z - .string({ error: 'Job ID is required to list asynchronous tasks' }) - .trim() - .min(1, 'Job ID is required to list asynchronous tasks') - .max(512, 'Job ID is too long'), - }) - .strict(), -]) - -export const netsuiteSelectorObjectSchema = z - .object({ - id: z.string().min(1).max(512), - label: z.string().min(1).max(1_000), - detail: z.string().max(2_000).nullable(), - }) - .strict() - -export const netsuiteObjectsSelectorContract = definePostSelector( - '/api/tools/netsuite/objects', - netsuiteObjectsBodySchema, - z.object({ objects: z.array(netsuiteSelectorObjectSchema).max(1_000) }).strict() -) - -export type NetSuiteSelectorKind = (typeof NETSUITE_SELECTOR_KINDS)[number] -export type NetSuiteObjectsSelectorBody = ContractBodyInput -export type NetSuiteObjectsSelectorResponse = ContractJsonResponse< - typeof netsuiteObjectsSelectorContract -> diff --git a/apps/sim/lib/api/contracts/selectors/notion.ts b/apps/sim/lib/api/contracts/selectors/notion.ts deleted file mode 100644 index c123b1e5cb0..00000000000 --- a/apps/sim/lib/api/contracts/selectors/notion.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { z } from 'zod' -import { - credentialWorkflowBodySchema, - definePostSelector, - idNameSchema, -} from '@/lib/api/contracts/selectors/shared' -import type { ContractJsonResponse } from '@/lib/api/contracts/types' - -export const notionDatabasesSelectorContract = definePostSelector( - '/api/tools/notion/databases', - credentialWorkflowBodySchema, - z.object({ databases: z.array(idNameSchema) }) -) - -export const notionPagesSelectorContract = definePostSelector( - '/api/tools/notion/pages', - credentialWorkflowBodySchema, - z.object({ pages: z.array(idNameSchema) }) -) - -export type NotionDatabasesSelectorResponse = ContractJsonResponse< - typeof notionDatabasesSelectorContract -> -export type NotionPagesSelectorResponse = ContractJsonResponse diff --git a/apps/sim/lib/api/contracts/selectors/oauth.ts b/apps/sim/lib/api/contracts/selectors/oauth.ts deleted file mode 100644 index 24c04703835..00000000000 --- a/apps/sim/lib/api/contracts/selectors/oauth.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { z } from 'zod' -import { oauthTokenRequestBodySchema } from '@/lib/api/contracts/oauth-connections' -import { definePostSelector } from '@/lib/api/contracts/selectors/shared' -import type { ContractJsonResponse } from '@/lib/api/contracts/types' - -const oauthTokenResponseSchema = z - .object({ - accessToken: z.string().optional(), - idToken: z.string().optional(), - instanceUrl: z.string().optional(), - cloudId: z.string().optional(), - domain: z.string().optional(), - apiDomain: z.string().optional(), - }) - .passthrough() - -export const oauthTokenContract = definePostSelector( - '/api/auth/oauth/token', - oauthTokenRequestBodySchema, - oauthTokenResponseSchema -) - -export type OauthTokenResponse = ContractJsonResponse diff --git a/apps/sim/lib/api/contracts/selectors/pipedrive.ts b/apps/sim/lib/api/contracts/selectors/pipedrive.ts deleted file mode 100644 index 7be05d1e712..00000000000 --- a/apps/sim/lib/api/contracts/selectors/pipedrive.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { z } from 'zod' -import { - credentialWorkflowBodySchema, - definePostSelector, - idNameSchema, -} from '@/lib/api/contracts/selectors/shared' -import type { ContractJsonResponse } from '@/lib/api/contracts/types' - -export const pipedrivePipelinesSelectorContract = definePostSelector( - '/api/tools/pipedrive/pipelines', - credentialWorkflowBodySchema, - z.object({ pipelines: z.array(idNameSchema) }) -) - -export type PipedrivePipelinesSelectorResponse = ContractJsonResponse< - typeof pipedrivePipelinesSelectorContract -> diff --git a/apps/sim/lib/api/contracts/selectors/sharepoint.ts b/apps/sim/lib/api/contracts/selectors/sharepoint.ts deleted file mode 100644 index fed88488728..00000000000 --- a/apps/sim/lib/api/contracts/selectors/sharepoint.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { z } from 'zod' -import { - credentialWorkflowBodySchema, - defineGetSelector, - definePostSelector, - fileOptionSchema, - idDisplayNameSchema, - optionalString, -} from '@/lib/api/contracts/selectors/shared' -import type { ContractBody, ContractJsonResponse, ContractQuery } from '@/lib/api/contracts/types' - -export const sharepointListsBodySchema = credentialWorkflowBodySchema.extend({ - siteId: z.string().min(1), -}) - -export const sharepointSitesBodySchema = credentialWorkflowBodySchema.extend({ - query: optionalString, -}) - -export const sharepointSiteQuerySchema = z.object({ - credentialId: z.preprocess( - (value) => value ?? '', - z.string().min(1, 'Credential ID and Site ID are required') - ), - siteId: z.preprocess( - (value) => value ?? '', - z.string().min(1, 'Credential ID and Site ID are required') - ), -}) - -export const sharepointListsSelectorContract = definePostSelector( - '/api/tools/sharepoint/lists', - sharepointListsBodySchema, - z.object({ lists: z.array(idDisplayNameSchema) }) -) - -export const sharepointSitesSelectorContract = definePostSelector( - '/api/tools/sharepoint/sites', - sharepointSitesBodySchema, - z.object({ files: z.array(fileOptionSchema) }) -) - -export const sharepointSiteSelectorContract = defineGetSelector( - '/api/tools/sharepoint/site', - sharepointSiteQuerySchema, - z.object({ site: fileOptionSchema.optional() }).passthrough() -) - -export type SharepointListsSelectorResponse = ContractJsonResponse< - typeof sharepointListsSelectorContract -> -export type SharepointListsSelectorBody = ContractBody -export type SharepointSitesSelectorResponse = ContractJsonResponse< - typeof sharepointSitesSelectorContract -> -export type SharepointSitesSelectorBody = ContractBody -export type SharepointSiteSelectorResponse = ContractJsonResponse< - typeof sharepointSiteSelectorContract -> -export type SharepointSiteSelectorQuery = ContractQuery diff --git a/apps/sim/lib/api/contracts/selectors/slack.ts b/apps/sim/lib/api/contracts/selectors/slack.ts deleted file mode 100644 index 70f272bda3f..00000000000 --- a/apps/sim/lib/api/contracts/selectors/slack.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { z } from 'zod' -import { - credentialWorkflowBodySchema, - definePostSelector, -} from '@/lib/api/contracts/selectors/shared' -import type { ContractJsonResponse } from '@/lib/api/contracts/types' - -const slackChannelSchema = z.object({ - id: z.string(), - name: z.string(), - isPrivate: z.boolean(), -}) -const slackUserSchema = z - .object({ id: z.string(), name: z.string(), real_name: z.string() }) - .passthrough() - -export const slackUsersBodySchema = credentialWorkflowBodySchema.extend({ - userId: z.string().optional(), -}) - -export const slackChannelsSelectorContract = definePostSelector( - '/api/tools/slack/channels', - credentialWorkflowBodySchema, - z.object({ channels: z.array(slackChannelSchema) }) -) - -export const slackUsersSelectorContract = definePostSelector( - '/api/tools/slack/users', - credentialWorkflowBodySchema, - z.object({ users: z.array(slackUserSchema) }) -) - -export const slackUserSelectorContract = definePostSelector( - '/api/tools/slack/users', - credentialWorkflowBodySchema.extend({ userId: z.string().min(1) }), - z.object({ user: slackUserSchema }) -) - -export const slackUsersListOrDetailContract = definePostSelector( - '/api/tools/slack/users', - slackUsersBodySchema, - z.union([z.object({ user: slackUserSchema }), z.object({ users: z.array(slackUserSchema) })]) -) - -export type SlackChannelsSelectorResponse = ContractJsonResponse< - typeof slackChannelsSelectorContract -> -export type SlackUsersSelectorResponse = ContractJsonResponse -export type SlackUserSelectorResponse = ContractJsonResponse diff --git a/apps/sim/lib/api/contracts/selectors/snowflake.ts b/apps/sim/lib/api/contracts/selectors/snowflake.ts deleted file mode 100644 index bfbf193620b..00000000000 --- a/apps/sim/lib/api/contracts/selectors/snowflake.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { z } from 'zod' -import { - credentialWorkflowBodySchema, - definePostSelector, -} from '@/lib/api/contracts/selectors/shared' -import type { ContractBodyInput, ContractJsonResponse } from '@/lib/api/contracts/types' -import { SNOWFLAKE_SELECTOR_KINDS } from '@/tools/snowflake/selector-kinds' - -/** - * One route backs every Snowflake picker: each `kind` maps to a metadata-only - * `SHOW` statement over the same SQL API endpoint and the same credential, so - * splitting them into nine routes would duplicate the credential resolution - * and statement transport nine times. - */ -export const snowflakeObjectsBodySchema = credentialWorkflowBodySchema - .extend({ - kind: z.enum(SNOWFLAKE_SELECTOR_KINDS), - database: z.string().min(1, 'database cannot be empty').max(255).optional(), - schema: z.string().min(1, 'schema cannot be empty').max(255).optional(), - }) - .superRefine((value, ctx) => { - const needsDatabase = - value.kind !== 'databases' && value.kind !== 'warehouses' && value.kind !== 'roles' - const needsSchema = needsDatabase && value.kind !== 'schemas' - if (needsDatabase && !value.database?.trim()) { - ctx.addIssue({ - code: 'custom', - path: ['database'], - message: `database is required to list ${value.kind}`, - }) - } - if (needsSchema && !value.schema?.trim()) { - ctx.addIssue({ - code: 'custom', - path: ['schema'], - message: `schema is required to list ${value.kind}`, - }) - } - }) - -const snowflakeObjectSchema = z.object({ - /** Snowflake object name, exactly as Snowflake stores it. */ - name: z.string().min(1), - /** Kind-specific secondary column (state, type, comment, or signature). */ - detail: z.string().nullable(), -}) - -export const snowflakeObjectsSelectorContract = definePostSelector( - '/api/tools/snowflake/objects', - snowflakeObjectsBodySchema, - z.object({ objects: z.array(snowflakeObjectSchema) }) -) - -export type SnowflakeObjectsSelectorBody = ContractBodyInput< - typeof snowflakeObjectsSelectorContract -> -export type SnowflakeObjectsSelectorResponse = ContractJsonResponse< - typeof snowflakeObjectsSelectorContract -> diff --git a/apps/sim/lib/api/contracts/selectors/trello.ts b/apps/sim/lib/api/contracts/selectors/trello.ts deleted file mode 100644 index e4079d655a7..00000000000 --- a/apps/sim/lib/api/contracts/selectors/trello.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { z } from 'zod' -import { - credentialWorkflowBodySchema, - definePostSelector, -} from '@/lib/api/contracts/selectors/shared' -import type { ContractJsonResponse } from '@/lib/api/contracts/types' - -const trelloBoardSchema = z - .object({ id: z.string(), name: z.string(), closed: z.boolean().optional() }) - .passthrough() - -export const trelloBoardsSelectorContract = definePostSelector( - '/api/tools/trello/boards', - credentialWorkflowBodySchema, - z.object({ boards: z.array(trelloBoardSchema) }) -) - -export type TrelloBoardsSelectorResponse = ContractJsonResponse diff --git a/apps/sim/lib/api/contracts/selectors/wealthbox.ts b/apps/sim/lib/api/contracts/selectors/wealthbox.ts deleted file mode 100644 index c66e8b59147..00000000000 --- a/apps/sim/lib/api/contracts/selectors/wealthbox.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { z } from 'zod' -import { defineGetSelector, optionalString } from '@/lib/api/contracts/selectors/shared' -import type { ContractJsonResponse } from '@/lib/api/contracts/types' - -export const WEALTHBOX_ITEM_TYPES = ['note', 'contact', 'task'] as const - -const wealthboxItemSchema = z.object({ - id: z.string(), - name: z.string(), - type: z.string(), - content: z.string(), - createdAt: z.string(), - updatedAt: z.string(), -}) - -const wealthboxItemsResponseSchema = z.object({ - items: z.array(wealthboxItemSchema), -}) - -const wealthboxItemResponseSchema = z.object({ - item: wealthboxItemSchema.optional(), -}) - -export const wealthboxItemsQuerySchema = z.object({ - credentialId: z.string().min(1), - type: z.preprocess( - (value) => (value === '' || value === undefined ? 'contact' : value), - z.literal('contact').default('contact') - ), - query: z.preprocess( - (value) => (value === undefined || value === null ? '' : value), - optionalString.default('') - ), -}) - -export const wealthboxItemQuerySchema = z.object({ - credentialId: z.preprocess( - (value) => value ?? '', - z.string().min(1, 'Credential ID is required') - ), - itemId: z.preprocess((value) => value ?? '', z.string().min(1, 'Item ID is required')), - type: z.preprocess( - (value) => value || 'note', - z.enum(WEALTHBOX_ITEM_TYPES, { error: 'type must be one of: note, contact, task' }) - ), -}) - -export const wealthboxItemsSelectorContract = defineGetSelector( - '/api/tools/wealthbox/items', - wealthboxItemsQuerySchema, - wealthboxItemsResponseSchema -) - -export const wealthboxItemContract = defineGetSelector( - '/api/tools/wealthbox/item', - wealthboxItemQuerySchema, - wealthboxItemResponseSchema -) - -export const wealthboxOAuthItemsContract = defineGetSelector( - '/api/auth/oauth/wealthbox/items', - wealthboxItemsQuerySchema, - wealthboxItemsResponseSchema -) - -export const wealthboxOAuthItemContract = defineGetSelector( - '/api/auth/oauth/wealthbox/item', - wealthboxItemQuerySchema, - wealthboxItemResponseSchema -) - -export type WealthboxItemsSelectorResponse = ContractJsonResponse< - typeof wealthboxItemsSelectorContract -> -export type WealthboxItemResponse = ContractJsonResponse -export type WealthboxOAuthItemsResponse = ContractJsonResponse -export type WealthboxOAuthItemResponse = ContractJsonResponse diff --git a/apps/sim/lib/api/contracts/selectors/webflow.ts b/apps/sim/lib/api/contracts/selectors/webflow.ts deleted file mode 100644 index f9aba45d3b8..00000000000 --- a/apps/sim/lib/api/contracts/selectors/webflow.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { z } from 'zod' -import { - credentialWorkflowBodySchema, - definePostSelector, - idNameSchema, - optionalString, -} from '@/lib/api/contracts/selectors/shared' -import type { ContractJsonResponse } from '@/lib/api/contracts/types' - -/** - * Webflow `/sites` accepts an optional `siteId`. When provided, the route - * dispatches to the single-site detail endpoint instead of the list endpoint. - */ -export const webflowSitesBodySchema = credentialWorkflowBodySchema.extend({ - siteId: optionalString, -}) - -export const webflowCollectionsBodySchema = credentialWorkflowBodySchema.extend({ - siteId: z.string().min(1, 'Site ID is required'), -}) - -export const webflowItemsBodySchema = credentialWorkflowBodySchema.extend({ - collectionId: z.string().min(1, 'Collection ID is required'), - search: optionalString, -}) - -export const webflowSitesSelectorContract = definePostSelector( - '/api/tools/webflow/sites', - webflowSitesBodySchema, - z.object({ sites: z.array(idNameSchema) }) -) - -export const webflowCollectionsSelectorContract = definePostSelector( - '/api/tools/webflow/collections', - webflowCollectionsBodySchema, - z.object({ collections: z.array(idNameSchema) }) -) - -export const webflowItemsSelectorContract = definePostSelector( - '/api/tools/webflow/items', - webflowItemsBodySchema, - z.object({ items: z.array(idNameSchema) }) -) - -export type WebflowSitesSelectorResponse = ContractJsonResponse -export type WebflowCollectionsSelectorResponse = ContractJsonResponse< - typeof webflowCollectionsSelectorContract -> -export type WebflowItemsSelectorResponse = ContractJsonResponse diff --git a/apps/sim/lib/api/contracts/selectors/zoho-desk.ts b/apps/sim/lib/api/contracts/selectors/zoho-desk.ts deleted file mode 100644 index 8da5d624184..00000000000 --- a/apps/sim/lib/api/contracts/selectors/zoho-desk.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { z } from 'zod' -import { - credentialWorkflowBodySchema, - definePostSelector, - idNameSchema, -} from '@/lib/api/contracts/selectors/shared' -import type { ContractJsonResponse } from '@/lib/api/contracts/types' - -/** - * Zoho Desk scopes every call except `GET /organizations` to a single portal via - * the `orgId` header, so every downstream selector must carry the organization - * the user picked. - */ -const zohoDeskOrgIdSchema = z.string().min(1, 'orgId is required') - -export const zohoDeskOrganizationsSelectorContract = definePostSelector( - '/api/tools/zoho_desk/organizations', - credentialWorkflowBodySchema, - z.object({ organizations: z.array(idNameSchema) }) -) - -export const zohoDeskDepartmentsSelectorContract = definePostSelector( - '/api/tools/zoho_desk/departments', - credentialWorkflowBodySchema.extend({ orgId: zohoDeskOrgIdSchema }), - z.object({ departments: z.array(idNameSchema) }) -) - -export const zohoDeskAgentsSelectorContract = definePostSelector( - '/api/tools/zoho_desk/agents', - credentialWorkflowBodySchema.extend({ orgId: zohoDeskOrgIdSchema }), - z.object({ agents: z.array(idNameSchema) }) -) - -export type ZohoDeskOrganizationsSelectorResponse = ContractJsonResponse< - typeof zohoDeskOrganizationsSelectorContract -> -export type ZohoDeskDepartmentsSelectorResponse = ContractJsonResponse< - typeof zohoDeskDepartmentsSelectorContract -> -export type ZohoDeskAgentsSelectorResponse = ContractJsonResponse< - typeof zohoDeskAgentsSelectorContract -> diff --git a/apps/sim/lib/api/contracts/selectors/zoom.ts b/apps/sim/lib/api/contracts/selectors/zoom.ts deleted file mode 100644 index 1eb0217de51..00000000000 --- a/apps/sim/lib/api/contracts/selectors/zoom.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { z } from 'zod' -import { - credentialWorkflowBodySchema, - definePostSelector, - idNameSchema, -} from '@/lib/api/contracts/selectors/shared' -import type { ContractJsonResponse } from '@/lib/api/contracts/types' - -export const zoomMeetingsSelectorContract = definePostSelector( - '/api/tools/zoom/meetings', - credentialWorkflowBodySchema, - z.object({ meetings: z.array(idNameSchema) }) -) - -export type ZoomMeetingsSelectorResponse = ContractJsonResponse diff --git a/apps/sim/lib/api/contracts/tools/imap.ts b/apps/sim/lib/api/contracts/tools/imap.ts deleted file mode 100644 index ace87bd4ca1..00000000000 --- a/apps/sim/lib/api/contracts/tools/imap.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { z } from 'zod' -import type { - ContractBody, - ContractBodyInput, - ContractJsonResponse, -} from '@/lib/api/contracts/types' -import { defineRouteContract } from '@/lib/api/contracts/types' - -export const imapMailboxSchema = z.object({ - path: z.string(), - name: z.string(), - delimiter: z - .union([z.string(), z.literal(false)]) - .nullable() - .optional(), -}) - -export const imapMailboxesResponseSchema = z.object({ - success: z.literal(true), - mailboxes: z.array(imapMailboxSchema), -}) - -export const imapMailboxesBodySchema = z.object({ - host: z.string().min(1), - port: z.preprocess((value) => value || 993, z.coerce.number().int().positive()), - secure: z.preprocess((value) => value ?? true, z.boolean()), - username: z.string().min(1), - password: z.string().min(1), -}) - -export const imapMailboxesContract = defineRouteContract({ - method: 'POST', - path: '/api/tools/imap/mailboxes', - body: imapMailboxesBodySchema, - response: { mode: 'json', schema: imapMailboxesResponseSchema }, -}) - -export type ImapMailboxesBody = ContractBody -export type ImapMailboxesBodyInput = ContractBodyInput -export type ImapMailbox = z.output -export type ImapMailboxesResponse = ContractJsonResponse diff --git a/apps/sim/lib/api/contracts/tools/index.ts b/apps/sim/lib/api/contracts/tools/index.ts index 94ed2bb939b..baf71f9fd11 100644 --- a/apps/sim/lib/api/contracts/tools/index.ts +++ b/apps/sim/lib/api/contracts/tools/index.ts @@ -17,7 +17,6 @@ export * from './fireflies' export * from './github' export * from './google' export * from './grafana' -export * from './imap' export * from './latex' export * from './mail' export * from './media' diff --git a/apps/sim/lib/core/security/input-validation.server.ts b/apps/sim/lib/core/security/input-validation.server.ts index 08cd4ad311e..24f964c0c74 100644 --- a/apps/sim/lib/core/security/input-validation.server.ts +++ b/apps/sim/lib/core/security/input-validation.server.ts @@ -194,7 +194,8 @@ export async function validateAndPinProxyUrl( */ export async function validateDatabaseHost( host: string | null | undefined, - paramName = 'host' + paramName = 'host', + options: { logDetails?: boolean } = {} ): Promise { if (!host) { return { isValid: false, error: `${paramName} is required` } @@ -217,11 +218,12 @@ export async function validateDatabaseHost( : addresses.find((candidate) => isPrivateIp(candidate)) if (blockedAddress !== undefined) { - logger.warn('Database host resolves to blocked IP address', { - paramName, - hostname: host, - resolvedIP: blockedAddress, - }) + logger.warn( + 'Database host resolves to blocked IP address', + options.logDetails === false + ? { paramName } + : { paramName, hostname: host, resolvedIP: blockedAddress } + ) return { isValid: false, error: `${paramName} resolves to a blocked IP address`, @@ -234,11 +236,12 @@ export async function validateDatabaseHost( originalHostname: host, } } catch (error) { - logger.warn('DNS lookup failed for database host', { - paramName, - hostname: host, - error: toError(error).message, - }) + logger.warn( + 'DNS lookup failed for database host', + options.logDetails === false + ? { paramName } + : { paramName, hostname: host, error: toError(error).message } + ) return { isValid: false, error: `${paramName} hostname could not be resolved`, diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/zoho-desk.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/zoho-desk.ts index 32f776ff33d..4113ce36120 100644 --- a/apps/sim/lib/credentials/client-credential-accounts/minters/zoho-desk.ts +++ b/apps/sim/lib/credentials/client-credential-accounts/minters/zoho-desk.ts @@ -242,10 +242,8 @@ export async function mintZohoDeskServiceAccountToken( const apiDomain = reportedDeskBase ?? dataCenter.deskBase if (reportedDeskBase && reportedDeskBase !== dataCenter.deskBase) { logger.warn('Zoho api_domain disagrees with the selected data center', { - soid, selectedDataCenter: dataCenter.id, - selectedDeskBase: dataCenter.deskBase, - reportedDeskBase, + usedProviderReportedDomain: true, }) } const expiresInSeconds = diff --git a/apps/sim/lib/environment/utils.ts b/apps/sim/lib/environment/utils.ts index ea10b6d1546..ed931ef9276 100644 --- a/apps/sim/lib/environment/utils.ts +++ b/apps/sim/lib/environment/utils.ts @@ -2,7 +2,6 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { environment, workspaceEnvironment } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { eq, inArray } from 'drizzle-orm' import { LRUCache } from 'lru-cache' @@ -247,12 +246,11 @@ export async function getPersonalAndWorkspaceEnv( try { const { decrypted } = await decryptSecret(v) return [k, decrypted] as const - } catch (error) { - logger.error(`Failed to decrypt ${source} environment variable "${k}"`, { + } catch { + logger.error('Failed to decrypt environment variable', { userId, workspaceId, source, - error: getErrorMessage(error, 'Unknown error'), }) decryptionFailures.push(k) return [k, ''] as const @@ -273,7 +271,6 @@ export async function getPersonalAndWorkspaceEnv( logger.warn('Some environment variables failed to decrypt', { userId, workspaceId, - failedKeys: decryptionFailures, failedCount: decryptionFailures.length, }) } diff --git a/apps/sim/lib/imap/connection.server.test.ts b/apps/sim/lib/imap/connection.server.test.ts new file mode 100644 index 00000000000..f97ff748e25 --- /dev/null +++ b/apps/sim/lib/imap/connection.server.test.ts @@ -0,0 +1,176 @@ +/** + * @vitest-environment node + */ +import { environmentUtilsMockFns, resetEnvironmentUtilsMock } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockImapFlow, mockValidateDatabaseHost } = vi.hoisted(() => ({ + mockImapFlow: vi.fn(), + mockValidateDatabaseHost: vi.fn(), +})) + +vi.mock('imapflow', () => ({ + ImapFlow: function MockImapFlow(options: unknown) { + mockImapFlow(options) + }, +})) + +vi.mock('@/lib/core/security/input-validation.server', () => ({ + validateDatabaseHost: mockValidateDatabaseHost, +})) + +import { + createSecureImapClient, + type ImapConnectionPolicyError, + normalizeLiteralImapConnection, + resolveImapConnectionForActor, +} from '@/lib/imap/connection.server' + +function environmentSnapshot(overrides: Record = {}) { + return { + personalEncrypted: {}, + workspaceEncrypted: {}, + personalDecrypted: {}, + workspaceDecrypted: {}, + personalOwners: {}, + conflicts: [], + decryptionFailures: [], + workspaceUnredactedKeys: [], + ...overrides, + } +} + +describe('IMAP connection policy', () => { + beforeEach(() => { + vi.clearAllMocks() + resetEnvironmentUtilsMock() + mockValidateDatabaseHost.mockResolvedValue({ + isValid: true, + sanitized: 'imap.example.com', + resolvedIP: '203.0.113.10', + }) + }) + + afterAll(resetEnvironmentUtilsMock) + + it('accepts literal configuration while requiring TLS or STARTTLS on the pinned host', async () => { + const secureConnection = normalizeLiteralImapConnection({ + host: ' imap.example.com ', + username: 'mailbox-user', + password: 'literal-password', + }) + const startTlsConnection = normalizeLiteralImapConnection({ + host: 'imap.example.com', + port: '143', + secure: 'false', + username: 'mailbox-user', + password: 'literal-password', + }) + + await createSecureImapClient(secureConnection) + await createSecureImapClient(startTlsConnection) + + expect(secureConnection).toEqual({ + host: 'imap.example.com', + port: 993, + secure: true, + username: 'mailbox-user', + password: 'literal-password', + }) + expect(mockValidateDatabaseHost).toHaveBeenCalledTimes(2) + expect(mockValidateDatabaseHost).toHaveBeenNthCalledWith(1, 'imap.example.com', 'host', { + logDetails: false, + }) + expect(mockValidateDatabaseHost).toHaveBeenNthCalledWith(2, 'imap.example.com', 'host', { + logDetails: false, + }) + expect(mockImapFlow).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + host: '203.0.113.10', + servername: 'imap.example.com', + port: 993, + secure: true, + auth: { user: 'mailbox-user', pass: 'literal-password' }, + tls: { rejectUnauthorized: true }, + logger: false, + }) + ) + expect(mockImapFlow.mock.calls[0]?.[0]).not.toHaveProperty('doSTARTTLS') + expect(mockImapFlow).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ secure: false, port: 143, doSTARTTLS: true }) + ) + }) + + it('resolves exact personal and visible shared references for the deployment actor', async () => { + environmentUtilsMockFns.mockGetEffectiveEnvironmentSnapshot.mockResolvedValue( + environmentSnapshot({ + personalDecrypted: { PERSONAL_PASSWORD: 'personal-password' }, + workspaceDecrypted: { + SHARED_HOST: 'imap.shared.example', + SHARED_PORT: '143', + SHARED_SECURE: 'false', + SHARED_USERNAME: 'shared-user', + }, + personalOwners: { PERSONAL_PASSWORD: 'actor-1' }, + workspaceUnredactedKeys: ['SHARED_USERNAME'], + }) + ) + + await expect( + resolveImapConnectionForActor({ + connection: { + host: '{{SHARED_HOST}}', + port: '{{SHARED_PORT}}', + secure: '{{SHARED_SECURE}}', + username: '{{SHARED_USERNAME}}', + password: '{{PERSONAL_PASSWORD}}', + }, + actorUserId: 'actor-1', + workspaceId: 'workspace-1', + }) + ).resolves.toEqual({ + host: 'imap.shared.example', + port: 143, + secure: false, + username: 'shared-user', + password: 'personal-password', + }) + expect(environmentUtilsMockFns.mockGetEffectiveEnvironmentSnapshot).toHaveBeenCalledWith( + 'actor-1', + 'workspace-1' + ) + }) + + it('rejects hidden shared username and password references before DNS or ImapFlow', async () => { + environmentUtilsMockFns.mockGetEffectiveEnvironmentSnapshot.mockResolvedValue( + environmentSnapshot({ + workspaceDecrypted: { HIDDEN_AUTH: 'use-only-secret' }, + }) + ) + + for (const field of ['username', 'password'] as const) { + const connection = { + host: 'imap.example.com', + username: 'literal-user', + password: 'literal-password', + [field]: '{{HIDDEN_AUTH}}', + } + await expect( + resolveImapConnectionForActor({ + connection, + actorUserId: 'actor-1', + workspaceId: 'workspace-1', + }) + ).rejects.toMatchObject>({ + name: 'ImapConnectionPolicyError', + code: 'hidden_auth', + message: 'IMAP connection is unavailable', + }) + } + + expect(mockValidateDatabaseHost).not.toHaveBeenCalled() + expect(mockImapFlow).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/imap/connection.server.ts b/apps/sim/lib/imap/connection.server.ts new file mode 100644 index 00000000000..98873ea5e72 --- /dev/null +++ b/apps/sim/lib/imap/connection.server.ts @@ -0,0 +1,169 @@ +import { ImapFlow } from 'imapflow' +import { validateDatabaseHost } from '@/lib/core/security/input-validation.server' +import { getEffectiveEnvironmentSnapshot } from '@/lib/environment/utils' + +const EXACT_ENVIRONMENT_REFERENCE = /^\{\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*\}\}$/ + +export class ImapConnectionPolicyError extends Error { + constructor(readonly code: 'context' | 'hidden_auth' | 'destination' | 'transport') { + super('IMAP connection is unavailable') + this.name = 'ImapConnectionPolicyError' + } +} + +export interface ImapConnectionInput { + host: unknown + port?: unknown + secure?: unknown + username: unknown + password: unknown +} + +export interface ResolvedImapConnection { + host: string + port: number + secure: boolean + username: string + password: string +} + +function containsTemplateDelimiter(value: string): boolean { + return value.includes('{{') || value.includes('}}') +} + +export function hasImapEnvironmentReferences(input: ImapConnectionInput): boolean { + return [input.host, input.port, input.secure, input.username, input.password].some( + (value) => typeof value === 'string' && EXACT_ENVIRONMENT_REFERENCE.test(value) + ) +} + +function normalizeConnection(input: ImapConnectionInput): ResolvedImapConnection { + const host = typeof input.host === 'string' ? input.host.trim() : '' + const username = typeof input.username === 'string' ? input.username : '' + const password = typeof input.password === 'string' ? input.password : '' + const port = input.port === undefined || input.port === '' ? 993 : Number(input.port) + const secure = + input.secure === undefined || input.secure === '' + ? true + : typeof input.secure === 'string' + ? input.secure.toLowerCase() === 'true' + : input.secure === true + + if (!host || !username || !password || !Number.isInteger(port) || port < 1 || port > 65_535) { + throw new ImapConnectionPolicyError('context') + } + return { host, port, secure, username, password } +} + +/** Resolves only exact references and rejects hidden shared authentication material. */ +export async function resolveImapConnectionForActor(input: { + connection: ImapConnectionInput + actorUserId: string + workspaceId?: string | null +}): Promise { + if (!hasImapEnvironmentReferences(input.connection)) { + return normalizeLiteralImapConnection(input.connection) + } + + const snapshot = await getEffectiveEnvironmentSnapshot( + input.actorUserId, + input.workspaceId ?? undefined + ) + const visibleWorkspaceNames = new Set(snapshot.workspaceUnredactedKeys) + + const resolve = (field: 'host' | 'port' | 'secure' | 'username' | 'password', value: unknown) => { + if (typeof value !== 'string') return value + const match = EXACT_ENVIRONMENT_REFERENCE.exec(value) + if (!match) { + if (containsTemplateDelimiter(value)) throw new ImapConnectionPolicyError('context') + return value + } + const name = match[1] + const workspaceReference = Object.hasOwn(snapshot.workspaceDecrypted, name) + const resolved = workspaceReference + ? snapshot.workspaceDecrypted[name] + : snapshot.personalDecrypted[name] + if (resolved === undefined) throw new ImapConnectionPolicyError('context') + if ( + (field === 'username' || field === 'password') && + (workspaceReference + ? !visibleWorkspaceNames.has(name) + : snapshot.personalOwners[name] !== input.actorUserId) + ) { + throw new ImapConnectionPolicyError('hidden_auth') + } + return resolved + } + + return normalizeConnection({ + host: resolve('host', input.connection.host), + port: resolve('port', input.connection.port), + secure: resolve('secure', input.connection.secure), + username: resolve('username', input.connection.username), + password: resolve('password', input.connection.password), + }) +} + +export function normalizeLiteralImapConnection(input: ImapConnectionInput): ResolvedImapConnection { + for (const value of [input.host, input.port, input.secure, input.username, input.password]) { + if (typeof value === 'string' && containsTemplateDelimiter(value)) { + throw new ImapConnectionPolicyError('context') + } + } + return normalizeConnection(input) +} + +/** Validates and pins the user-controlled destination before any IMAP authentication occurs. */ +export async function createSecureImapClient( + connection: ResolvedImapConnection, + signal?: AbortSignal +): Promise { + signal?.throwIfAborted() + const validation = await validateDatabaseHost(connection.host, 'host', { logDetails: false }) + signal?.throwIfAborted() + if (!validation.isValid || !validation.resolvedIP) { + throw new ImapConnectionPolicyError('destination') + } + return new ImapFlow({ + host: validation.resolvedIP, + servername: connection.host, + port: connection.port, + secure: connection.secure, + ...(connection.secure ? {} : { doSTARTTLS: true }), + auth: { user: connection.username, pass: connection.password }, + tls: { rejectUnauthorized: true }, + logger: false, + }) +} + +export async function listImapMailboxes( + connection: ResolvedImapConnection, + signal?: AbortSignal +): Promise> { + const client = await createSecureImapClient(connection, signal) + const abort = () => client.close() + signal?.addEventListener('abort', abort, { once: true }) + try { + signal?.throwIfAborted() + await client.connect() + signal?.throwIfAborted() + const mailboxes = (await client.list()).map((mailbox) => ({ + path: mailbox.path, + name: mailbox.name, + delimiter: mailbox.delimiter, + })) + mailboxes.sort((left, right) => { + if (left.path === 'INBOX') return -1 + if (right.path === 'INBOX') return 1 + return left.path.localeCompare(right.path) + }) + return mailboxes + } finally { + signal?.removeEventListener('abort', abort) + try { + await client.logout() + } catch { + client.close() + } + } +} diff --git a/apps/sim/lib/imap/selector-policy.test.ts b/apps/sim/lib/imap/selector-policy.test.ts new file mode 100644 index 00000000000..81bdd21b687 --- /dev/null +++ b/apps/sim/lib/imap/selector-policy.test.ts @@ -0,0 +1,58 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it, vi } from 'vitest' + +const { mockListImapMailboxes, mockNormalizeLiteralImapConnection } = vi.hoisted(() => ({ + mockListImapMailboxes: vi.fn(), + mockNormalizeLiteralImapConnection: vi.fn(), +})) + +vi.mock('@/lib/imap/connection.server', () => ({ + listImapMailboxes: mockListImapMailboxes, + normalizeLiteralImapConnection: mockNormalizeLiteralImapConnection, +})) + +import { SelectorConnectionUnavailableError } from '@/lib/selectors/server/errors' +import { createSelectorProtectedValues } from '@/lib/selectors/server/protected-values' +import { imapSelectorAttachments } from '@/lib/selectors/server/providers/imap' +import type { ExecuteServerSelectorArgs } from '@/lib/selectors/server/types' + +function selectorArgs(): ExecuteServerSelectorArgs { + return { + selectorKey: 'imap.mailboxes', + context: { + host: 'imap.example.com', + username: 'resolved-use-only-user', + password: 'resolved-use-only-password', + }, + request: { kind: 'list' }, + scope: { kind: 'workflow', workflowId: 'workflow-1' }, + workspaceId: 'workspace-1', + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + requesterUserId: 'user-1', + references: new Map([ + [ + 'password', + { + field: 'password', + name: 'SHARED_IMAP_PASSWORD', + scope: 'workspace', + visible: false, + }, + ], + ]), + protectedValues: createSelectorProtectedValues(), + } +} + +describe('IMAP selector policy', () => { + it('rejects hidden shared authentication before normalizing or connecting', async () => { + await expect( + imapSelectorAttachments['imap.mailboxes'].execute(selectorArgs()) + ).rejects.toBeInstanceOf(SelectorConnectionUnavailableError) + + expect(mockNormalizeLiteralImapConnection).not.toHaveBeenCalled() + expect(mockListImapMailboxes).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/managed-agents/session-client.ts b/apps/sim/lib/managed-agents/session-client.ts index 388aab8e4a6..d11fd4d8d50 100644 --- a/apps/sim/lib/managed-agents/session-client.ts +++ b/apps/sim/lib/managed-agents/session-client.ts @@ -531,6 +531,7 @@ async function listPaginated( const resp = await fetch(url.toString(), { method: 'GET', headers: managedAgentsHeaders(input.apiKey, { beta: input.beta }), + redirect: 'error', signal: input.signal, }) if (!resp.ok) { diff --git a/apps/sim/lib/oauth/credential-service.test.ts b/apps/sim/lib/oauth/credential-service.test.ts new file mode 100644 index 00000000000..4f05c6aba37 --- /dev/null +++ b/apps/sim/lib/oauth/credential-service.test.ts @@ -0,0 +1,198 @@ +/** + * @vitest-environment node + */ +import { account, credential } from '@sim/db/schema' +import { queueTableRows, resetDbChainMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + coalesceLocally: vi.fn(), + getFreshestSlackChain: vi.fn(), + getRecentTerminalError: vi.fn(), + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + trace: vi.fn(), + fatal: vi.fn(), + }, + refreshOAuthToken: vi.fn(), + withLeaderLock: vi.fn(), +})) + +vi.mock('@sim/logger', () => ({ + createLogger: vi.fn(() => mocks.logger), +})) + +vi.mock('@/lib/concurrency/singleflight', () => ({ + coalesceLocally: mocks.coalesceLocally, +})) + +vi.mock('@/lib/concurrency/leader-lock', () => ({ + withLeaderLock: mocks.withLeaderLock, +})) + +vi.mock('@/lib/oauth/instagram', () => ({ + isInstagramProvider: vi.fn(() => false), + shouldProactivelyRefreshInstagramToken: vi.fn(() => false), +})) + +vi.mock('@/lib/oauth/microsoft', () => ({ + getMicrosoftRefreshTokenExpiry: vi.fn(), + isMicrosoftProvider: vi.fn(() => false), + PROACTIVE_REFRESH_THRESHOLD_DAYS: 7, +})) + +vi.mock('@/lib/oauth/oauth', () => ({ + OAUTH_PROVIDERS: {}, + refreshOAuthToken: mocks.refreshOAuthToken, +})) + +vi.mock('@/lib/oauth/slack', () => ({ + extractSlackTeamId: (value: string | null | undefined) => + value?.match(/^([TE][A-Z0-9]+)-/)?.[1] ?? null, + fanOutSlackTokenChain: vi.fn(), + getFreshestSlackChain: mocks.getFreshestSlackChain, + hasSlackChainMoved: vi.fn(() => false), + isSlackProvider: (providerId: string) => providerId === 'slack', +})) + +vi.mock('@/lib/oauth/terminal-errors', () => ({ + getRecentTerminalError: mocks.getRecentTerminalError, + isTerminalRefreshError: vi.fn(() => false), + markCredentialDead: vi.fn(), +})) + +import { resolveCredentialAccessToken } from '@/lib/oauth/credential-service' + +const RAW_CREDENTIAL_ID = 'credential-raw-secret-id' +const RAW_ACCOUNT_ID = 'account-raw-secret-id' +const RAW_USER_ID = 'user-raw-secret-id' +const RAW_SLACK_TEAM_ID = 'TSECRET123' +const RAW_PROVIDER_ERROR = 'provider returned raw private failure text' + +interface RefreshObservation { + cacheKey: string + coalescingKey: string + lockKey: string + logs: string +} + +async function observeRefresh( + providerId: 'google' | 'slack', + privacyMode?: 'selector' +): Promise { + resetDbChainMock() + vi.clearAllMocks() + mocks.getRecentTerminalError.mockResolvedValue(null) + mocks.coalesceLocally.mockImplementation(async (_key: string, producer: () => Promise) => + producer() + ) + mocks.withLeaderLock.mockImplementation(async (options: { onLeader: () => Promise }) => + options.onLeader() + ) + mocks.getFreshestSlackChain.mockResolvedValue({ + accessToken: null, + refreshToken: 'refresh-token', + accessTokenExpiresAt: new Date(0), + chainVersion: new Date(0), + }) + mocks.refreshOAuthToken.mockRejectedValue(new Error(RAW_PROVIDER_ERROR)) + + queueTableRows(credential, [ + { + id: RAW_CREDENTIAL_ID, + type: 'oauth', + accountId: RAW_ACCOUNT_ID, + workspaceId: 'workspace-1', + providerId: null, + }, + ]) + queueTableRows(account, [ + { + id: RAW_ACCOUNT_ID, + accountId: + providerId === 'slack' + ? `${RAW_SLACK_TEAM_ID}-usr_USECRET-connection` + : 'provider-account-id', + providerId, + userId: RAW_USER_ID, + accessToken: null, + refreshToken: 'refresh-token', + accessTokenExpiresAt: new Date(0), + refreshTokenExpiresAt: null, + updatedAt: new Date(0), + }, + ]) + + await expect( + resolveCredentialAccessToken( + RAW_CREDENTIAL_ID, + RAW_USER_ID, + 'selector-execution', + undefined, + undefined, + privacyMode ? { privacyMode } : undefined + ) + ).resolves.toBeNull() + + return { + cacheKey: mocks.getRecentTerminalError.mock.calls[0][0], + coalescingKey: mocks.coalesceLocally.mock.calls[0][0], + lockKey: mocks.withLeaderLock.mock.calls[0][0].key, + logs: JSON.stringify([ + ...mocks.logger.info.mock.calls, + ...mocks.logger.warn.mock.calls, + ...mocks.logger.error.mock.calls, + ]), + } +} + +describe('resolveCredentialAccessToken selector privacy', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it('HMACs OAuth and Slack refresh identities and suppresses raw identifiers and provider errors', async () => { + for (const providerId of ['google', 'slack'] as const) { + const observed = await observeRefresh(providerId, 'selector') + const serializedKeys = JSON.stringify([ + observed.cacheKey, + observed.coalescingKey, + observed.lockKey, + ]) + + expect(observed.coalescingKey).toBe(observed.lockKey) + expect(observed.coalescingKey).toMatch(/^oauth:refresh:[A-Za-z0-9_-]{40,}$/) + for (const privateValue of [ + RAW_CREDENTIAL_ID, + RAW_ACCOUNT_ID, + RAW_USER_ID, + RAW_SLACK_TEAM_ID, + RAW_PROVIDER_ERROR, + ]) { + expect(serializedKeys).not.toContain(privateValue) + expect(observed.logs).not.toContain(privateValue) + } + } + }) + + it('preserves the existing raw refresh identity and diagnostics outside selector mode', async () => { + const google = await observeRefresh('google') + expect(google.cacheKey).toBe(RAW_ACCOUNT_ID) + expect(google.coalescingKey).toBe(`oauth:refresh:${RAW_ACCOUNT_ID}`) + expect(google.lockKey).toBe(google.coalescingKey) + expect(google.logs).toContain(RAW_ACCOUNT_ID) + expect(google.logs).toContain(RAW_USER_ID) + expect(google.logs).toContain(RAW_PROVIDER_ERROR) + + const slack = await observeRefresh('slack') + expect(slack.cacheKey).toBe(`slack:${RAW_SLACK_TEAM_ID}`) + expect(slack.coalescingKey).toBe(`oauth:refresh:slack:${RAW_SLACK_TEAM_ID}`) + expect(slack.lockKey).toBe(slack.coalescingKey) + expect(slack.logs).toContain(RAW_SLACK_TEAM_ID) + expect(slack.logs).toContain(RAW_PROVIDER_ERROR) + }) +}) diff --git a/apps/sim/lib/oauth/credential-service.ts b/apps/sim/lib/oauth/credential-service.ts index a92468715e6..d2f7ff586a9 100644 --- a/apps/sim/lib/oauth/credential-service.ts +++ b/apps/sim/lib/oauth/credential-service.ts @@ -1,4 +1,4 @@ -import { createSign } from 'crypto' +import { createHmac, createSign } from 'crypto' import { db } from '@sim/db' import { account, credential } from '@sim/db/schema' import { createLogger } from '@sim/logger' @@ -6,6 +6,7 @@ import { getPostgresErrorCode, toError } from '@sim/utils/errors' import { and, desc, eq } from 'drizzle-orm' import { withLeaderLock } from '@/lib/concurrency/leader-lock' import { coalesceLocally } from '@/lib/concurrency/singleflight' +import { env } from '@/lib/core/config/env' import { decryptSecret } from '@/lib/core/security/encryption' import { isClientCredentialAccountProviderId } from '@/lib/credentials/client-credential-accounts/descriptors' import { @@ -48,6 +49,23 @@ import { const logger = createLogger('OAuthCredentialService') +export interface CredentialTokenResolutionOptions { + /** + * Selector execution may receive a credential/account id through a hidden + * workspace environment reference. In that mode identifiers are omitted + * from diagnostics and HMACed before they are used as cache or lock keys. + */ + privacyMode?: 'selector' +} + +function privateCredentialIdentity(namespace: string, value: string): string { + return createHmac('sha256', env.ENCRYPTION_KEY) + .update(namespace) + .update('\0') + .update(value) + .digest('base64url') +} + export class ServiceAccountTokenError extends Error { constructor( public readonly statusCode: number, @@ -160,7 +178,8 @@ const SA_EXCLUDED_SCOPES = new Set([ export async function getServiceAccountToken( credentialId: string, scopes: string[], - impersonateEmail?: string + impersonateEmail?: string, + options?: CredentialTokenResolutionOptions ): Promise { const [credentialRow] = await db .select({ @@ -203,12 +222,22 @@ export async function getServiceAccountToken( payload.sub = impersonateEmail } - logger.info('Service account JWT payload', { - iss: keyData.client_email, - sub: impersonateEmail || '(none)', - scopes: filteredScopes.join(' '), - aud: tokenUri, - }) + logger.info( + 'Service account JWT payload', + options?.privacyMode === 'selector' + ? { + hasIssuer: Boolean(keyData.client_email), + hasSubject: Boolean(impersonateEmail), + scopeCount: filteredScopes.length, + hasAudience: Boolean(tokenUri), + } + : { + iss: keyData.client_email, + sub: impersonateEmail || '(none)', + scopes: filteredScopes.join(' '), + aud: tokenUri, + } + ) const toBase64Url = (obj: unknown) => Buffer.from(JSON.stringify(obj)).toString('base64url') @@ -222,6 +251,7 @@ export async function getServiceAccountToken( const response = await fetch(tokenUri, { method: 'POST', + redirect: 'error', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', @@ -233,21 +263,23 @@ export async function getServiceAccountToken( const errorBody = await response.text() logger.error('Service account token exchange failed', { status: response.status, - body: errorBody, + ...(options?.privacyMode === 'selector' ? {} : { body: errorBody }), }) let description = `Token exchange failed: ${response.status}` - try { - const parsed = JSON.parse(errorBody) as { error_description?: string } - if (parsed.error_description) { - const raw = parsed.error_description - if (raw.includes('SignatureException') || raw.includes('Invalid signature')) { - description = 'Invalid account credentials.' - } else { - description = raw + if (options?.privacyMode !== 'selector') { + try { + const parsed = JSON.parse(errorBody) as { error_description?: string } + if (parsed.error_description) { + const raw = parsed.error_description + if (raw.includes('SignatureException') || raw.includes('Invalid signature')) { + description = 'Invalid account credentials.' + } else { + description = raw + } } + } catch { + // use default description } - } catch { - // use default description } throw new ServiceAccountTokenError(response.status, description) } @@ -491,9 +523,14 @@ function secretFingerprintOf(encryptedServiceAccountKey: string): string { */ async function resolveClientCredentialAccountToken( credentialId: string, - providerId: string + providerId: string, + options?: CredentialTokenResolutionOptions ): Promise { - return coalesceLocally(`ccsa:${credentialId}`, async () => { + const cacheIdentity = + options?.privacyMode === 'selector' + ? privateCredentialIdentity('selector-client-credential', credentialId) + : credentialId + return coalesceLocally(`ccsa:${cacheIdentity}`, async () => { pruneExpiredClientCredentialCaches(Date.now()) const [credentialRow] = await db .select({ encryptedServiceAccountKey: credential.encryptedServiceAccountKey }) @@ -501,13 +538,13 @@ async function resolveClientCredentialAccountToken( .where(eq(credential.id, credentialId)) .limit(1) if (!credentialRow?.encryptedServiceAccountKey) { - clientCredentialTokenCache.delete(credentialId) - clientCredentialMintFailureCache.delete(credentialId) + clientCredentialTokenCache.delete(cacheIdentity) + clientCredentialMintFailureCache.delete(cacheIdentity) throw new Error('Client-credential service account secret not found') } const secretFingerprint = secretFingerprintOf(credentialRow.encryptedServiceAccountKey) - const cached = clientCredentialTokenCache.get(credentialId) + const cached = clientCredentialTokenCache.get(cacheIdentity) if ( cached && cached.secretFingerprint === secretFingerprint && @@ -520,7 +557,7 @@ async function resolveClientCredentialAccountToken( } } - const failed = clientCredentialMintFailureCache.get(credentialId) + const failed = clientCredentialMintFailureCache.get(cacheIdentity) if ( failed && failed.secretFingerprint === secretFingerprint && @@ -528,7 +565,7 @@ async function resolveClientCredentialAccountToken( ) { throw failed.error } - clientCredentialMintFailureCache.delete(credentialId) + clientCredentialMintFailureCache.delete(cacheIdentity) try { const { decrypted } = await decryptSecret(credentialRow.encryptedServiceAccountKey) @@ -551,7 +588,7 @@ async function resolveClientCredentialAccountToken( }, { skipIdentity: true } ) - clientCredentialTokenCache.set(credentialId, { + clientCredentialTokenCache.set(cacheIdentity, { accessToken: mint.accessToken, expiresAtMs: Date.now() + mint.expiresInSeconds * 1000, secretFingerprint, @@ -564,8 +601,8 @@ async function resolveClientCredentialAccountToken( apiDomain: mint.apiDomain, } } catch (error) { - clientCredentialMintFailureCache.set(credentialId, { - error, + clientCredentialMintFailureCache.set(cacheIdentity, { + error: options?.privacyMode === 'selector' ? new Error('Credential mint failed') : error, secretFingerprint, expiresAtMs: Date.now() + CLIENT_CREDENTIAL_MINT_FAILURE_TTL_MS, }) @@ -577,6 +614,7 @@ async function resolveClientCredentialAccountToken( interface ServiceAccountTokenOptions { scopes?: string[] impersonateEmail?: string + privacyMode?: 'selector' } type ServiceAccountTokenResolver = ( @@ -601,11 +639,18 @@ const SERVICE_ACCOUNT_TOKEN_RESOLVERS: Record { + [GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID]: async ( + credentialId, + { scopes, impersonateEmail, privacyMode } + ) => { if (!scopes?.length) { throw new Error('Scopes are required for service account credentials') } - return { accessToken: await getServiceAccountToken(credentialId, scopes, impersonateEmail) } + return { + accessToken: await getServiceAccountToken(credentialId, scopes, impersonateEmail, { + privacyMode, + }), + } }, } @@ -620,7 +665,8 @@ export async function resolveServiceAccountToken( credentialId: string, providerId: string | null | undefined, scopes?: string[], - impersonateEmail?: string + impersonateEmail?: string, + options?: CredentialTokenResolutionOptions ): Promise { if (providerId && isTokenServiceAccountProviderId(providerId)) { const secret = await getTokenServiceAccountSecret(credentialId, providerId) @@ -632,7 +678,7 @@ export async function resolveServiceAccountToken( } } if (providerId && isClientCredentialAccountProviderId(providerId)) { - return resolveClientCredentialAccountToken(credentialId, providerId) + return resolveClientCredentialAccountToken(credentialId, providerId, options) } const resolver = providerId && Object.hasOwn(SERVICE_ACCOUNT_TOKEN_RESOLVERS, providerId) @@ -641,7 +687,7 @@ export async function resolveServiceAccountToken( if (!resolver) { throw new Error(`Unsupported service-account provider: ${providerId ?? 'unknown'}`) } - return resolver(credentialId, { scopes, impersonateEmail }) + return resolver(credentialId, { scopes, impersonateEmail, ...options }) } /** @@ -708,6 +754,7 @@ interface CoalescedRefreshOptions { providerAccountId?: string | null requestId?: string userId?: string + privacyMode?: 'selector' } /** @@ -731,6 +778,7 @@ async function performCoalescedRefresh({ providerAccountId, requestId, userId, + privacyMode, }: CoalescedRefreshOptions): Promise { /** * Slack bot tokens are per-installation (team × app): every account row for @@ -738,14 +786,18 @@ async function performCoalescedRefresh({ * dead-flagged, and written per installation rather than per row. */ const slackTeamId = isSlackProvider(providerId) ? extractSlackTeamId(providerAccountId) : null - const scopeKey = slackTeamId ? `slack:${slackTeamId}` : accountId + const rawScopeKey = slackTeamId ? `slack:${slackTeamId}` : accountId + const scopeKey = + privacyMode === 'selector' + ? privateCredentialIdentity('selector-oauth-refresh', rawScopeKey) + : rawScopeKey const logContext = { ...(requestId ? { requestId } : {}), - ...(userId ? { userId } : {}), - ...(slackTeamId ? { slackTeamId } : {}), + ...(privacyMode === 'selector' || !userId ? {} : { userId }), + ...(privacyMode === 'selector' || !slackTeamId ? {} : { slackTeamId }), providerId, - accountId, + ...(privacyMode === 'selector' ? {} : { accountId }), } const deadCode = await getRecentTerminalError(scopeKey) @@ -856,7 +908,7 @@ async function performCoalescedRefresh({ } catch (error) { logger.error('Refresh failed inside leader path', { ...logContext, - error: toError(error).message, + ...(privacyMode === 'selector' ? {} : { error: toError(error).message }), }) return null } @@ -883,7 +935,7 @@ async function performCoalescedRefresh({ } catch (error) { logger.warn('Follower DB read failed during refresh poll', { ...logContext, - error: toError(error).message, + ...(privacyMode === 'selector' ? {} : { error: toError(error).message }), }) return null } @@ -896,7 +948,7 @@ async function performCoalescedRefresh({ } catch (error) { logger.error('Coalesced refresh did not settle', { ...logContext, - error: toError(error).message, + ...(privacyMode === 'selector' ? {} : { error: toError(error).message }), }) return null } @@ -981,7 +1033,8 @@ export async function resolveCredentialAccessToken( userId: string, requestId: string, scopes?: string[], - impersonateEmail?: string + impersonateEmail?: string, + options?: CredentialTokenResolutionOptions ): Promise { const resolved = await resolveOAuthAccountId(credentialId) if (!resolved) { @@ -994,7 +1047,8 @@ export async function resolveCredentialAccessToken( resolved.credentialId, resolved.providerId, scopes, - impersonateEmail + impersonateEmail, + options ) } @@ -1052,6 +1106,7 @@ export async function resolveCredentialAccessToken( providerAccountId: credential.accountId, requestId, userId: credential.userId, + privacyMode: options?.privacyMode, }) if (fresh) return { accessToken: fresh } @@ -1088,14 +1143,16 @@ export async function refreshAccessTokenIfNeeded( userId: string, requestId: string, scopes?: string[], - impersonateEmail?: string + impersonateEmail?: string, + options?: CredentialTokenResolutionOptions ): Promise { const result = await resolveCredentialAccessToken( credentialId, userId, requestId, scopes, - impersonateEmail + impersonateEmail, + options ) return result?.accessToken ?? null } diff --git a/apps/sim/lib/selectors/application/execute-selector.test.ts b/apps/sim/lib/selectors/application/execute-selector.test.ts new file mode 100644 index 00000000000..006359c595a --- /dev/null +++ b/apps/sim/lib/selectors/application/execute-selector.test.ts @@ -0,0 +1,172 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + events: [] as string[], + authorizeCredential: vi.fn(), + executeAttachment: vi.fn(), + getAttachment: vi.fn(), + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, + resolvePermission: vi.fn(), + resolveReferences: vi.fn(), + resolveScope: vi.fn(), + sanitize: vi.fn(), +})) + +vi.mock('@sim/audit', () => ({ recordAudit: vi.fn() })) + +vi.mock('@sim/logger', () => ({ + createLogger: vi.fn(() => mocks.logger), +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => { + const rank = { read: 1, write: 2, admin: 3 } as const + return ( + actual !== null && rank[actual as keyof typeof rank] >= rank[required as keyof typeof rank] + ) + }, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@/lib/selectors/application/resolve-scope', () => ({ + resolveSelectorApplicationContext: mocks.resolveScope, +})) + +vi.mock('@/lib/selectors/server/credentials', () => ({ + authorizeSelectorCredential: mocks.authorizeCredential, +})) + +vi.mock('@/lib/selectors/server/references', () => ({ + resolveSelectorReferences: mocks.resolveReferences, +})) + +vi.mock('@/lib/selectors/server/registry', () => ({ + getServerSelectorAttachment: mocks.getAttachment, +})) + +vi.mock('@/lib/selectors/server/sanitize', () => ({ + sanitizeSelectorResult: mocks.sanitize, +})) + +import { executeSelector } from '@/lib/selectors/application/execute-selector' +import { getSelectorManifestEntry } from '@/lib/selectors/manifest' +import { + SelectorContextUnavailableError, + SelectorOptionsUnavailableError, +} from '@/lib/selectors/server/errors' + +const principal = { kind: 'session' as const, userId: 'user-1', sessionId: 'session-1' } +const scope = { kind: 'workspace' as const, workspaceId: 'workspace-1' } + +function execute(inputOverrides: Record = {}) { + return executeSelector.execute({ + principal, + input: { + selectorKey: 'gmail.labels', + scope, + context: { oauthCredential: '{{GMAIL_CREDENTIAL_ID}}' }, + request: { kind: 'list' as const }, + ...inputOverrides, + }, + }) +} + +describe('executeSelector', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.events.length = 0 + mocks.resolveScope.mockImplementation(async () => { + mocks.events.push('canonical-scope') + return { + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + selectorKey: 'gmail.labels', + selectorManifest: getSelectorManifestEntry('gmail.labels'), + selectorScope: scope, + } + }) + mocks.resolvePermission.mockImplementation(async () => { + mocks.events.push('workspace-authorization') + return 'read' + }) + mocks.resolveReferences.mockImplementation(async () => { + mocks.events.push('reference-resolution') + return { + context: { oauthCredential: 'credential-1' }, + request: { kind: 'list' }, + references: new Map(), + } + }) + mocks.authorizeCredential.mockImplementation(async () => { + mocks.events.push('credential-authorization') + return { suppliedId: 'credential-1' } + }) + mocks.executeAttachment.mockImplementation(async () => { + mocks.events.push('provider-execution') + return { kind: 'list', items: [{ id: 'label-1', label: 'Inbox' }] } + }) + mocks.getAttachment.mockReturnValue({ + destination: 'fixed', + credential: { kind: 'stored', field: 'oauthCredential', serviceIds: ['gmail'] }, + execute: mocks.executeAttachment, + }) + mocks.sanitize.mockImplementation((result) => { + mocks.events.push('sanitization') + return result + }) + }) + + it('authorizes canonical scope before references, credentials, and provider execution', async () => { + await expect(execute()).resolves.toEqual({ + kind: 'list', + items: [{ id: 'label-1', label: 'Inbox' }], + }) + + expect(mocks.events).toEqual([ + 'canonical-scope', + 'workspace-authorization', + 'reference-resolution', + 'credential-authorization', + 'provider-execution', + 'sanitization', + ]) + }) + + it('rejects extra context and unsupported capabilities before secret resolution', async () => { + await expect( + execute({ + context: { oauthCredential: 'credential-1', domain: 'tenant.example.com' }, + request: { kind: 'list', search: 'private query' }, + }) + ).rejects.toEqual(new SelectorContextUnavailableError()) + + expect(mocks.events).toEqual(['canonical-scope', 'workspace-authorization']) + expect(mocks.resolveReferences).not.toHaveBeenCalled() + expect(mocks.authorizeCredential).not.toHaveBeenCalled() + expect(mocks.executeAttachment).not.toHaveBeenCalled() + }) + + it('projects provider failures to a safe error and never logs request context', async () => { + mocks.executeAttachment.mockRejectedValueOnce( + new Error('upstream leaked selector-secret-canary for {{GMAIL_CREDENTIAL_ID}}') + ) + + await expect(execute()).rejects.toEqual(new SelectorOptionsUnavailableError()) + + expect(mocks.logger.warn).toHaveBeenCalledOnce() + const logged = JSON.stringify(mocks.logger.warn.mock.calls) + expect(logged).not.toContain('selector-secret-canary') + expect(logged).not.toContain('GMAIL_CREDENTIAL_ID') + expect(logged).not.toContain('credential-1') + expect(logged).not.toContain('context') + }) +}) diff --git a/apps/sim/lib/selectors/application/execute-selector.ts b/apps/sim/lib/selectors/application/execute-selector.ts new file mode 100644 index 00000000000..7f462bd76a7 --- /dev/null +++ b/apps/sim/lib/selectors/application/execute-selector.ts @@ -0,0 +1,147 @@ +import { createLogger } from '@sim/logger' +import type { ExecuteSelectorRequest } from '@/lib/api/contracts/selectors/execute' +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' +import { selectorOperations } from '@/lib/selectors/application/operations' +import { + resolveSelectorApplicationContext, + type SelectorApplicationContext, +} from '@/lib/selectors/application/resolve-scope' +import { isSelectorReady, type ServerSelectorKey } from '@/lib/selectors/manifest' +import { authorizeSelectorCredential } from '@/lib/selectors/server/credentials' +import { + SelectorConnectionUnavailableError, + SelectorContextUnavailableError, + SelectorOptionsUnavailableError, +} from '@/lib/selectors/server/errors' +import { createSelectorProtectedValues } from '@/lib/selectors/server/protected-values' +import { resolveSelectorReferences } from '@/lib/selectors/server/references' +import { getServerSelectorAttachment } from '@/lib/selectors/server/registry' +import { sanitizeSelectorResult } from '@/lib/selectors/server/sanitize' +import type { SelectorExecutionResult } from '@/lib/selectors/types' + +const logger = createLogger('ExecuteSelector') + +export interface ExecuteSelectorInput extends ExecuteSelectorRequest { + signal?: AbortSignal +} + +function validateAuthorizedInput( + input: ExecuteSelectorInput, + context: SelectorApplicationContext +): void { + const manifest = context.selectorManifest + if (!manifest.scopeKinds.includes(input.scope.kind)) { + throw new SelectorContextUnavailableError() + } + + if (input.request.kind === 'detail' && !manifest.supportsDetail) { + throw new SelectorContextUnavailableError() + } + if ( + input.request.kind === 'list' && + ((input.request.search !== undefined && !manifest.supportsSearch) || + (input.request.cursor !== undefined && manifest.listMode !== 'paginated')) + ) { + throw new SelectorContextUnavailableError() + } + + const allowedContext = new Set(manifest.context.allowed) + if (Object.keys(input.context).some((field) => !allowedContext.has(field))) { + throw new SelectorContextUnavailableError() + } + if (!isSelectorReady(input.selectorKey, input.context)) { + throw new SelectorContextUnavailableError() + } +} + +async function executeAuthorizedSelector(args: { + principal: { kind: 'session'; userId: string; sessionId: string } + input: ExecuteSelectorInput + context: SelectorApplicationContext +}): Promise { + const startedAt = Date.now() + const protectedValues = createSelectorProtectedValues() + + try { + const attachment = getServerSelectorAttachment(args.input.selectorKey as ServerSelectorKey) + const resolved = await resolveSelectorReferences({ + selectorKey: args.input.selectorKey as ServerSelectorKey, + context: args.input.context, + request: args.input.request, + requesterUserId: args.principal.userId, + workspaceId: args.context.workspaceId, + protectedValues, + }) + if (!isSelectorReady(args.input.selectorKey, resolved.context)) { + throw new SelectorContextUnavailableError() + } + + const credential = attachment.credential + ? await authorizeSelectorCredential({ + principal: args.principal, + context: resolved.context, + scope: args.input.scope, + workspaceId: args.context.workspaceId, + policy: attachment.credential, + protectedValues, + }) + : undefined + + const result = sanitizeSelectorResult( + await attachment.execute({ + selectorKey: args.input.selectorKey as ServerSelectorKey, + context: resolved.context, + request: resolved.request, + scope: args.input.scope, + workspaceId: args.context.workspaceId, + principal: args.principal, + requesterUserId: args.principal.userId, + credential, + references: resolved.references, + signal: args.input.signal, + protectedValues, + }), + protectedValues + ) + + logger.info('Executed selector', { + selectorKey: args.input.selectorKey, + requestKind: args.input.request.kind, + scopeKind: args.input.scope.kind, + workspaceId: args.context.workspaceId, + workflowId: args.input.scope.kind === 'workflow' ? args.input.scope.workflowId : undefined, + durationMs: Date.now() - startedAt, + itemCount: result.kind === 'list' ? result.items.length : result.item ? 1 : 0, + }) + return result + } catch (error) { + if (args.input.signal?.aborted) throw error + if ( + error instanceof SelectorContextUnavailableError || + error instanceof SelectorConnectionUnavailableError || + error instanceof SelectorOptionsUnavailableError + ) { + throw error + } + logger.warn('Selector provider execution failed', { + selectorKey: args.input.selectorKey, + requestKind: args.input.request.kind, + scopeKind: args.input.scope.kind, + workspaceId: args.context.workspaceId, + durationMs: Date.now() - startedAt, + }) + throw new SelectorOptionsUnavailableError() + } +} + +export const executeSelector = defineAuthorizedWorkspaceUseCase({ + operation: selectorOperations.execute, + resolveContext: ({ input }) => + resolveSelectorApplicationContext({ + selectorKey: input.selectorKey as ServerSelectorKey, + scope: input.scope, + }), + authorizationOptions: {}, + authorizeResource: ({ input, context }) => validateAuthorizedInput(input, context), + execute: executeAuthorizedSelector, +}) diff --git a/apps/sim/lib/selectors/application/operations.ts b/apps/sim/lib/selectors/application/operations.ts new file mode 100644 index 00000000000..72f00f08a3b --- /dev/null +++ b/apps/sim/lib/selectors/application/operations.ts @@ -0,0 +1,10 @@ +import { defineWorkspaceOperation } from '@/lib/core/application' + +export const selectorOperations = { + execute: defineWorkspaceOperation({ + id: 'selectors.execute', + minimumRole: 'read', + workspaceApiKey: 'deny', + principalKinds: ['session'], + }), +} as const diff --git a/apps/sim/lib/selectors/application/resolve-scope.ts b/apps/sim/lib/selectors/application/resolve-scope.ts new file mode 100644 index 00000000000..33fb54c6006 --- /dev/null +++ b/apps/sim/lib/selectors/application/resolve-scope.ts @@ -0,0 +1,41 @@ +import { getSelectorManifestEntry, type ServerSelectorKey } from '@/lib/selectors/manifest' +import { SelectorContextUnavailableError } from '@/lib/selectors/server/errors' +import type { SelectorManifestEntry, SelectorScope } from '@/lib/selectors/types' +import type { ActiveWorkflowApplicationContext } from '@/lib/workflows/application/context' +import { resolveActiveWorkflowApplicationContext } from '@/lib/workflows/application/context' +import type { ActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' +import { resolveActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' + +export type SelectorApplicationContext = ( + | ActiveWorkflowApplicationContext + | ActiveWorkspaceApplicationContext +) & { + selectorKey: ServerSelectorKey + selectorManifest: SelectorManifestEntry + selectorScope: SelectorScope +} + +export async function resolveSelectorApplicationContext(input: { + selectorKey: ServerSelectorKey + scope: SelectorScope +}): Promise { + const selectorManifest = getSelectorManifestEntry(input.selectorKey) + if (selectorManifest.classification === 'local') { + throw new SelectorContextUnavailableError() + } + + const workspaceContext = + input.scope.kind === 'workflow' + ? await resolveActiveWorkflowApplicationContext({ + workflowId: input.scope.workflowId, + assertedWorkspaceId: input.scope.workspaceId, + }) + : await resolveActiveWorkspaceApplicationContext(input.scope.workspaceId) + + return { + ...workspaceContext, + selectorKey: input.selectorKey, + selectorManifest, + selectorScope: input.scope, + } +} diff --git a/apps/sim/lib/selectors/client/execute-selector.ts b/apps/sim/lib/selectors/client/execute-selector.ts new file mode 100644 index 00000000000..90c20b6ed0e --- /dev/null +++ b/apps/sim/lib/selectors/client/execute-selector.ts @@ -0,0 +1,73 @@ +'use client' + +import { requestJson } from '@/lib/api/client/request' +import { executeSelectorContract } from '@/lib/api/contracts/selectors/execute' +import { localSelectorAttachments } from '@/lib/selectors/client/local' +import { + getSelectorManifestEntry, + type LocalSelectorKey, + type SelectorKey, +} from '@/lib/selectors/manifest' +import type { + SelectorContext, + SelectorExecutionResult, + SelectorRequest, + SelectorScope, +} from '@/lib/selectors/types' + +export interface ExecuteSelectorClientInput { + selectorKey: SelectorKey + scope?: SelectorScope + context: SelectorContext + request: SelectorRequest + signal?: AbortSignal +} + +export async function executeSelectorRequest( + input: ExecuteSelectorClientInput +): Promise { + const manifest = getSelectorManifestEntry(input.selectorKey) + if (manifest.classification === 'local') { + if (input.request.kind !== 'list') return { kind: 'detail', item: null } + return localSelectorAttachments[input.selectorKey as LocalSelectorKey]() + } + if (!input.scope) throw new Error('Selector scope is required') + return requestJson(executeSelectorContract, { + body: { + selectorKey: input.selectorKey, + scope: input.scope, + context: input.context, + request: input.request, + }, + signal: input.signal, + }) +} + +const MAX_LOAD_ALL_PAGES = 50 + +export async function loadAllSelectorOptions( + input: Omit & { search?: string } +) { + const supportsSearch = getSelectorManifestEntry(input.selectorKey).supportsSearch + const items: Array<{ + id: string + label: string + meta?: Record + }> = [] + let cursor: string | undefined + for (let page = 0; page < MAX_LOAD_ALL_PAGES; page += 1) { + const result = await executeSelectorRequest({ + ...input, + request: { + kind: 'list', + ...(supportsSearch && input.search !== undefined ? { search: input.search } : {}), + ...(cursor ? { cursor } : {}), + }, + }) + if (result.kind !== 'list') throw new Error('Selector returned an unexpected detail result') + items.push(...result.items) + cursor = result.nextCursor + if (!cursor) break + } + return items +} diff --git a/apps/sim/lib/selectors/client/local.ts b/apps/sim/lib/selectors/client/local.ts new file mode 100644 index 00000000000..d74a463218d --- /dev/null +++ b/apps/sim/lib/selectors/client/local.ts @@ -0,0 +1,25 @@ +'use client' + +import type { LocalSelectorKey } from '@/lib/selectors/manifest' +import type { SelectorExecutionResult } from '@/lib/selectors/types' + +type LocalSelectorAttachment = () => Promise + +export const localSelectorAttachments = { + 'workspace.triggerTypes': async () => { + const { getTriggerOptions } = await import('@/lib/logs/get-trigger-options') + const valuesByLabel = new Map() + for (const option of getTriggerOptions()) { + const values = valuesByLabel.get(option.label) + if (values) values.push(option.value) + else valuesByLabel.set(option.label, [option.value]) + } + return { + kind: 'list', + items: Array.from(valuesByLabel, ([label, values]) => ({ + id: values.join(','), + label, + })), + } + }, +} satisfies Record diff --git a/apps/sim/lib/selectors/context.ts b/apps/sim/lib/selectors/context.ts new file mode 100644 index 00000000000..c1e08f6201e --- /dev/null +++ b/apps/sim/lib/selectors/context.ts @@ -0,0 +1,186 @@ +import { getSelectorManifestEntry, type SelectorKey } from '@/lib/selectors/manifest' +import { + type SelectorContext, + type SelectorContextKey, + selectorContextKeys, +} from '@/lib/selectors/types' +import { + buildCanonicalIndex, + buildSubBlockValues, + type CanonicalModeOverrides, + evaluateSubBlockCondition, + resolveActiveCanonicalValue, +} from '@/lib/workflows/subblocks/visibility' +import { getBlock } from '@/blocks' +import type { SubBlockConfig } from '@/blocks/types' +import { isReference } from '@/executor/constants' +import type { SubBlockState } from '@/stores/workflows/workflow/types' + +export const SELECTOR_CONTEXT_FIELDS = new Set(selectorContextKeys) +const EXPLICIT_SELECTOR_HINT_FIELDS = ['impersonateUserEmail'] as const + +function isSurfaceSubBlock(subBlock: SubBlockConfig, triggerMode: boolean): boolean { + const triggerField = subBlock.mode === 'trigger' || subBlock.mode === 'trigger-advanced' + return triggerMode ? triggerField : !triggerField +} + +export function getSelectorContextSubBlocks( + subBlocks: SubBlockConfig[], + values: Record, + triggerMode = false +): SubBlockConfig[] { + return subBlocks.filter( + (subBlock) => + isSurfaceSubBlock(subBlock, triggerMode) && + evaluateSubBlockCondition(subBlock.condition, values) + ) +} + +function toContextValue(value: unknown): string | undefined { + if (value === null || value === undefined) return undefined + const normalized = typeof value === 'string' ? value : String(value) + if (!normalized || isReference(normalized) || /<[^<>]+>/.test(normalized)) return undefined + return normalized +} + +export function projectSelectorContext( + selectorKey: SelectorKey, + candidate: object +): SelectorContext { + const manifest = getSelectorManifestEntry(selectorKey) + const allowed = new Set(manifest.context.allowed) + const source = candidate as Record + const projectedCandidate: Record = { ...source } + + if (projectedCandidate.oauthCredential === undefined) { + projectedCandidate.oauthCredential = + source.credential ?? + source.botCredential ?? + source.customBotCredential ?? + source.manualBotCredential + } + for (const [target, sourceFields] of Object.entries(manifest.context.sourceFields ?? {})) { + for (const sourceField of sourceFields ?? []) { + const value = toContextValue(source[sourceField]) + if (value === undefined) continue + projectedCandidate[target] = value + break + } + } + + const context: SelectorContext = {} + for (const [field, value] of Object.entries(projectedCandidate)) { + if (!allowed.has(field)) continue + const normalized = toContextValue(value) + if (normalized !== undefined) context[field as SelectorContextKey] = normalized + } + return context +} + +export interface BuildSelectorRawContextInput { + selectorKey: SelectorKey + blockType: string + subBlocks: Record + dependsOn?: readonly string[] + canonicalModes?: CanonicalModeOverrides + triggerMode?: boolean + staticContext?: Readonly> +} + +export interface BuildSelectorContextFromValuesInput { + selectorKey: SelectorKey + contextConfigs: SubBlockConfig[] + values: Record + dependsOn?: readonly string[] + canonicalIndex?: ReturnType + canonicalModes?: CanonicalModeOverrides + staticContext?: Readonly> +} + +/** Shared active-value projection used by every selector surface. */ +export function buildSelectorContextFromValues( + input: BuildSelectorContextFromValuesInput +): SelectorContext { + const manifest = getSelectorManifestEntry(input.selectorKey) + const allowed = new Set(manifest.context.allowed) + const canonicalIndex = input.canonicalIndex ?? buildCanonicalIndex(input.contextConfigs) + const configById = new Map(input.contextConfigs.map((config) => [config.id, config])) + const dependencies = input.dependsOn ? new Set(input.dependsOn) : null + const candidate: Record = { ...(input.staticContext ?? {}) } + const resolvedGroups = new Set() + + const includeSubBlock = (subBlockId: string, canonicalId?: string): boolean => { + if (!dependencies) return true + return ( + dependencies.has(subBlockId) || (canonicalId !== undefined && dependencies.has(canonicalId)) + ) + } + + const includeValue = (subBlockId: string, value: unknown) => { + const config = configById.get(subBlockId) + if (!config) return + const canonicalId = canonicalIndex.canonicalIdBySubBlockId[subBlockId] + if (!includeSubBlock(subBlockId, canonicalId)) return + + if (canonicalId) { + if (resolvedGroups.has(canonicalId)) return + resolvedGroups.add(canonicalId) + candidate[canonicalId] = resolveActiveCanonicalValue( + canonicalIndex.groupsById[canonicalId], + input.values, + input.canonicalModes + ) + return + } + candidate[subBlockId] = value + } + + if (dependencies) { + for (const dependency of dependencies) { + const canonicalId = + canonicalIndex.groupsById[dependency]?.canonicalId ?? + canonicalIndex.canonicalIdBySubBlockId[dependency] + if (canonicalId) { + if (resolvedGroups.has(canonicalId)) continue + resolvedGroups.add(canonicalId) + candidate[canonicalId] = resolveActiveCanonicalValue( + canonicalIndex.groupsById[canonicalId], + input.values, + input.canonicalModes + ) + continue + } + candidate[dependency] = input.values[dependency] + } + } else { + for (const [subBlockId, value] of Object.entries(input.values)) { + includeValue(subBlockId, value) + } + } + + for (const hint of EXPLICIT_SELECTOR_HINT_FIELDS) { + if (allowed.has(hint)) candidate[hint] = input.values[hint] + } + + return projectSelectorContext(input.selectorKey, candidate) +} + +export function buildSelectorRawContext(input: BuildSelectorRawContextInput): SelectorContext { + const blockConfig = getBlock(input.blockType) + if (!blockConfig) return projectSelectorContext(input.selectorKey, input.staticContext ?? {}) + + const values = buildSubBlockValues(input.subBlocks) + const contextConfigs = getSelectorContextSubBlocks( + blockConfig.subBlocks, + values, + input.triggerMode + ) + return buildSelectorContextFromValues({ + selectorKey: input.selectorKey, + contextConfigs, + values, + dependsOn: input.dependsOn, + canonicalModes: input.canonicalModes, + staticContext: input.staticContext, + }) +} diff --git a/apps/sim/lib/selectors/manifest.test.ts b/apps/sim/lib/selectors/manifest.test.ts new file mode 100644 index 00000000000..3d3cc87f908 --- /dev/null +++ b/apps/sim/lib/selectors/manifest.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest' +import { localSelectorAttachments } from '@/lib/selectors/client/local' +import { selectorManifest } from '@/lib/selectors/manifest' +import { serverSelectorRegistry } from '@/lib/selectors/server/registry' + +describe('selector manifest', () => { + it('keeps the completed migration inventory exhaustive and legacy-free', () => { + const classifications = Object.values(selectorManifest).map((entry) => entry.classification) + const count = (classification: (typeof classifications)[number]) => + classifications.filter((value) => value === classification).length + + expect(Object.keys(selectorManifest)).toHaveLength(93) + expect(count('provider-server')).toBe(81) + expect(count('internal-server')).toBe(11) + expect(count('local')).toBe(1) + expect(classifications).not.toContain('provider-legacy') + }) + + it('attaches every manifest key exactly once on its declared execution side', () => { + const entries = Object.entries(selectorManifest) + const expectedServerKeys = entries + .filter(([, entry]) => entry.classification !== 'local') + .map(([key]) => key) + .sort() + const expectedLocalKeys = entries + .filter(([, entry]) => entry.classification === 'local') + .map(([key]) => key) + .sort() + + expect(Object.keys(serverSelectorRegistry).sort()).toEqual(expectedServerKeys) + expect(Object.keys(localSelectorAttachments).sort()).toEqual(expectedLocalKeys) + + const providerKeys = entries + .filter(([, entry]) => entry.classification === 'provider-server') + .map(([key]) => key) + const rawConnectionKeys = providerKeys.filter( + (key) => !serverSelectorRegistry[key as keyof typeof serverSelectorRegistry].credential + ) + expect(providerKeys).toHaveLength(81) + expect(rawConnectionKeys.sort()).toEqual([ + 'cloudwatch.logGroups', + 'cloudwatch.logStreams', + 'imap.mailboxes', + ]) + }) +}) diff --git a/apps/sim/lib/selectors/manifest.ts b/apps/sim/lib/selectors/manifest.ts new file mode 100644 index 00000000000..8fc62ab62d7 --- /dev/null +++ b/apps/sim/lib/selectors/manifest.ts @@ -0,0 +1,372 @@ +import type { + SelectorContextKey, + SelectorManifestEntry, + SelectorReadiness, +} from '@/lib/selectors/types' + +export const DEFAULT_SELECTOR_STALE_TIME = 30_000 +export const DEFAULT_SELECTOR_DETAIL_STALE_TIME = 300_000 +export const STANDARD_SELECTOR_STALE_TIME = 60_000 +export const SEARCH_SELECTOR_STALE_TIME = 15_000 + +const SERVER_SCOPE_KINDS = ['workflow', 'workspace'] as const + +interface ServerManifestOptions { + readiness?: SelectorReadiness + sensitive?: readonly SelectorContextKey[] + sourceFields?: Partial> + listMode?: 'flat' | 'paginated' + search?: boolean + detail?: boolean + unknownDetail?: boolean + staleTime?: number +} + +function providerSelector( + extraContext: readonly SelectorContextKey[] = [], + options: ServerManifestOptions = {} +): SelectorManifestEntry { + return { + classification: 'provider-server', + context: { + allowed: ['oauthCredential', ...extraContext], + readiness: options.readiness ?? { all: ['oauthCredential'] }, + ...(options.sensitive ? { sensitive: options.sensitive } : {}), + ...(options.sourceFields ? { sourceFields: options.sourceFields } : {}), + }, + scopeKinds: SERVER_SCOPE_KINDS, + listMode: options.listMode ?? 'flat', + supportsSearch: options.search ?? false, + supportsDetail: options.detail ?? false, + resolvesUnknownIds: options.unknownDetail ?? false, + staleTime: options.staleTime ?? STANDARD_SELECTOR_STALE_TIME, + } +} + +function rawProviderSelector( + context: readonly SelectorContextKey[], + options: ServerManifestOptions +): SelectorManifestEntry { + return { + classification: 'provider-server', + context: { + allowed: context, + ...(options.readiness ? { readiness: options.readiness } : {}), + ...(options.sensitive ? { sensitive: options.sensitive } : {}), + ...(options.sourceFields ? { sourceFields: options.sourceFields } : {}), + }, + scopeKinds: SERVER_SCOPE_KINDS, + listMode: options.listMode ?? 'flat', + supportsSearch: options.search ?? false, + supportsDetail: options.detail ?? false, + resolvesUnknownIds: options.unknownDetail ?? false, + staleTime: options.staleTime ?? STANDARD_SELECTOR_STALE_TIME, + } +} + +function internalSelector( + context: readonly SelectorContextKey[] = [], + options: ServerManifestOptions = {} +): SelectorManifestEntry { + return { + classification: 'internal-server', + context: { + allowed: context, + ...(options.readiness ? { readiness: options.readiness } : {}), + ...(options.sensitive ? { sensitive: options.sensitive } : {}), + ...(options.sourceFields ? { sourceFields: options.sourceFields } : {}), + }, + scopeKinds: SERVER_SCOPE_KINDS, + listMode: options.listMode ?? 'flat', + supportsSearch: options.search ?? false, + supportsDetail: options.detail ?? false, + resolvesUnknownIds: options.unknownDetail ?? false, + staleTime: options.staleTime ?? STANDARD_SELECTOR_STALE_TIME, + } +} + +export const selectorManifest = { + 'airtable.bases': providerSelector([], { detail: true }), + 'airtable.tables': providerSelector(['baseId'], { + readiness: { all: ['oauthCredential', 'baseId'] }, + detail: true, + }), + 'asana.workspaces': providerSelector([], { detail: true }), + 'attio.lists': providerSelector([], { detail: true }), + 'attio.objects': providerSelector([], { detail: true }), + 'bigquery.datasets': providerSelector(['projectId', 'impersonateUserEmail'], { + readiness: { all: ['oauthCredential', 'projectId'] }, + detail: true, + }), + 'bigquery.tables': providerSelector(['projectId', 'datasetId', 'impersonateUserEmail'], { + readiness: { all: ['oauthCredential', 'projectId', 'datasetId'] }, + detail: true, + }), + 'bitbucket.workspaces': providerSelector([], { listMode: 'paginated' }), + 'bitbucket.repositories': providerSelector(['workspaceSlug'], { + readiness: { all: ['oauthCredential', 'workspaceSlug'] }, + listMode: 'paginated', + }), + 'calcom.eventTypes': providerSelector([], { detail: true }), + 'calcom.schedules': providerSelector([], { detail: true }), + 'clickup.workspaces': providerSelector(), + 'clickup.spaces': providerSelector(['teamId'], { + readiness: { all: ['oauthCredential', 'teamId'] }, + }), + 'clickup.folders': providerSelector(['spaceId', 'listSpaceId'], { + readiness: { all: ['oauthCredential'], any: ['spaceId', 'listSpaceId'] }, + }), + 'clickup.lists': providerSelector(['folderId', 'spaceId'], { + readiness: { + all: ['oauthCredential'], + any: ['folderId', 'spaceId'], + }, + }), + 'confluence.spaces': providerSelector(['domain'], { + readiness: { all: ['oauthCredential', 'domain'] }, + listMode: 'paginated', + detail: true, + unknownDetail: true, + }), + 'confluence.pages': providerSelector(['domain'], { + readiness: { all: ['oauthCredential', 'domain'] }, + search: true, + detail: true, + }), + 'google.tasks.lists': providerSelector(['impersonateUserEmail'], { detail: true }), + 'gmail.labels': providerSelector(['impersonateUserEmail']), + 'google.calendar': providerSelector(['impersonateUserEmail']), + 'google.drive': providerSelector(['mimeType', 'fileId', 'impersonateUserEmail'], { + search: true, + detail: true, + staleTime: SEARCH_SELECTOR_STALE_TIME, + }), + 'google.sheets': providerSelector(['spreadsheetId', 'impersonateUserEmail'], { + readiness: { all: ['oauthCredential', 'spreadsheetId'] }, + }), + 'harmonic.savedSearches': providerSelector([], { detail: true, unknownDetail: true }), + 'hubspot.lists': providerSelector(), + 'hubspot.owners': providerSelector(), + 'hubspot.pipelines': providerSelector(['objectType', 'customObjectTypeId']), + 'hubspot.pipelineStages': providerSelector(['objectType', 'customObjectTypeId', 'pipelineId'], { + readiness: { all: ['oauthCredential', 'pipelineId'] }, + }), + 'hubspot.properties': providerSelector(['objectType', 'customObjectTypeId']), + 'jsm.requestTypes': providerSelector(['domain', 'serviceDeskId'], { + readiness: { all: ['oauthCredential', 'domain', 'serviceDeskId'] }, + detail: true, + }), + 'jsm.serviceDesks': providerSelector(['domain'], { + readiness: { all: ['oauthCredential', 'domain'] }, + detail: true, + }), + 'microsoft.planner.plans': providerSelector([], { detail: true }), + 'notion.databases': providerSelector([], { detail: true }), + 'notion.pages': providerSelector([], { detail: true }), + 'netsuite.recordTypes': providerSelector(['jobId'], { + detail: true, + unknownDetail: true, + }), + 'netsuite.asyncTasks': providerSelector(['jobId'], { + readiness: { all: ['oauthCredential', 'jobId'] }, + detail: true, + unknownDetail: true, + }), + 'pipedrive.pipelines': providerSelector([], { detail: true }), + 'sharepoint.lists': providerSelector(['siteId'], { + readiness: { all: ['oauthCredential', 'siteId'] }, + detail: true, + }), + 'trello.boards': providerSelector([], { detail: true }), + 'zoho_desk.organizations': providerSelector(), + 'zoho_desk.departments': providerSelector(['orgId'], { + readiness: { all: ['oauthCredential', 'orgId'] }, + }), + 'zoho_desk.agents': providerSelector(['orgId'], { + readiness: { all: ['oauthCredential', 'orgId'] }, + }), + 'zoom.meetings': providerSelector([], { detail: true }), + 'slack.channels': providerSelector([], { + sourceFields: { oauthCredential: ['botToken'] }, + }), + 'snowflake.databases': providerSelector(['database', 'schema'], { + detail: true, + unknownDetail: true, + }), + 'snowflake.schemas': providerSelector(['database', 'schema'], { + readiness: { all: ['oauthCredential', 'database'] }, + detail: true, + unknownDetail: true, + }), + 'snowflake.tables': providerSelector(['database', 'schema'], { + readiness: { all: ['oauthCredential', 'database', 'schema'] }, + detail: true, + unknownDetail: true, + }), + 'snowflake.warehouses': providerSelector(['database', 'schema'], { + detail: true, + unknownDetail: true, + }), + 'snowflake.roles': providerSelector(['database', 'schema'], { + detail: true, + unknownDetail: true, + }), + 'snowflake.fileFormats': providerSelector(['database', 'schema'], { + readiness: { all: ['oauthCredential', 'database', 'schema'] }, + detail: true, + unknownDetail: true, + }), + 'snowflake.procedures': providerSelector(['database', 'schema'], { + readiness: { all: ['oauthCredential', 'database', 'schema'] }, + detail: true, + unknownDetail: true, + }), + 'slack.users': providerSelector([], { + sourceFields: { oauthCredential: ['botToken'] }, + }), + 'outlook.folders': providerSelector(), + 'outlook.calendars': providerSelector(), + 'microsoft.teams': providerSelector(), + 'microsoft.chats': providerSelector(), + 'microsoft.channels': providerSelector(['teamId'], { + readiness: { all: ['oauthCredential', 'teamId'] }, + }), + 'microsoft.planner': providerSelector(['planId'], { + readiness: { all: ['oauthCredential', 'planId'] }, + detail: true, + }), + 'onedrive.files': providerSelector(), + 'onedrive.folders': providerSelector(['driveId']), + 'sharepoint.sites': providerSelector([], { detail: true }), + 'microsoft.excel': providerSelector(['driveId'], { search: true }), + 'microsoft.excel.drives': providerSelector(['siteId'], { + readiness: { all: ['oauthCredential', 'siteId'] }, + detail: true, + }), + 'microsoft.excel.sheets': providerSelector(['driveId', 'spreadsheetId'], { + readiness: { all: ['oauthCredential', 'spreadsheetId'] }, + }), + 'microsoft.word': providerSelector(['driveId'], { search: true }), + 'wealthbox.contacts': providerSelector([], { search: true }), + 'jira.issues': providerSelector(['domain', 'projectId'], { + readiness: { all: ['oauthCredential', 'domain'] }, + search: true, + detail: true, + staleTime: SEARCH_SELECTOR_STALE_TIME, + }), + 'jira.projects': providerSelector(['domain'], { + readiness: { all: ['oauthCredential', 'domain'] }, + search: true, + detail: true, + }), + 'linear.projects': providerSelector(['teamId'], { + readiness: { all: ['oauthCredential', 'teamId'] }, + }), + 'linear.teams': providerSelector(), + 'monday.boards': providerSelector([], { detail: true }), + 'monday.groups': providerSelector(['boardId'], { + readiness: { all: ['oauthCredential', 'boardId'] }, + detail: true, + }), + 'webflow.sites': providerSelector(), + 'webflow.collections': providerSelector(['siteId'], { + readiness: { all: ['oauthCredential', 'siteId'] }, + }), + 'webflow.items': providerSelector(['collectionId'], { + readiness: { all: ['oauthCredential', 'collectionId'] }, + search: true, + staleTime: SEARCH_SELECTOR_STALE_TIME, + }), + 'cloudwatch.logGroups': rawProviderSelector( + ['awsAccessKeyId', 'awsSecretAccessKey', 'awsRegion'], + { + readiness: { all: ['awsAccessKeyId', 'awsSecretAccessKey', 'awsRegion'] }, + sensitive: ['awsAccessKeyId', 'awsSecretAccessKey'], + search: true, + detail: true, + } + ), + 'cloudwatch.logStreams': rawProviderSelector( + ['awsAccessKeyId', 'awsSecretAccessKey', 'awsRegion', 'logGroupName'], + { + readiness: { + all: ['awsAccessKeyId', 'awsSecretAccessKey', 'awsRegion', 'logGroupName'], + }, + sensitive: ['awsAccessKeyId', 'awsSecretAccessKey'], + search: true, + detail: true, + } + ), + 'imap.mailboxes': rawProviderSelector(['host', 'port', 'secure', 'username', 'password'], { + readiness: { all: ['host', 'username', 'password'] }, + sensitive: ['username', 'password'], + }), + 'managedAgent.agents': providerSelector(), + 'managedAgent.environments': providerSelector(['environmentType']), + 'managedAgent.vaults': providerSelector(), + 'managedAgent.memoryStores': providerSelector(), + 'knowledge.documents': internalSelector(['knowledgeBaseId'], { + readiness: { all: ['knowledgeBaseId'] }, + listMode: 'paginated', + search: true, + detail: true, + }), + 'sim.workflows': internalSelector(['excludeWorkflowId'], { detail: true }), + 'table.columns': internalSelector(['tableId'], { + readiness: { all: ['tableId'] }, + detail: true, + staleTime: 0, + }), + 'table.outputColumns': internalSelector(['tableId'], { + readiness: { all: ['tableId'] }, + detail: true, + staleTime: 0, + }), + 'workspace.credentialProviders': internalSelector([], { detail: true }), + 'workspace.credentialGroups': internalSelector([], { detail: true }), + 'workspace.credentialGroupProviders': internalSelector(['credentialGroupId'], { + detail: true, + }), + 'workspace.secretNames': internalSelector(), + 'workspace.rawSecretNames': internalSelector(), + 'workspace.sandboxes': internalSelector(['language'], { detail: true }), + 'providers.openrouterEmbeddingModels': internalSelector(), + 'workspace.triggerTypes': { + classification: 'local', + context: { allowed: [] }, + scopeKinds: [], + listMode: 'flat', + supportsSearch: false, + supportsDetail: false, + resolvesUnknownIds: false, + staleTime: STANDARD_SELECTOR_STALE_TIME, + }, +} as const satisfies Record + +export type SelectorKey = keyof typeof selectorManifest +export type ServerSelectorKey = { + [K in SelectorKey]: (typeof selectorManifest)[K]['classification'] extends 'local' ? never : K +}[SelectorKey] +export type ProviderSelectorKey = { + [K in SelectorKey]: (typeof selectorManifest)[K]['classification'] extends 'provider-server' + ? K + : never +}[SelectorKey] +export type InternalSelectorKey = { + [K in SelectorKey]: (typeof selectorManifest)[K]['classification'] extends 'internal-server' + ? K + : never +}[SelectorKey] +export type LocalSelectorKey = Exclude + +export function getSelectorManifestEntry(key: SelectorKey): SelectorManifestEntry { + return selectorManifest[key] +} + +export function isSelectorReady(key: SelectorKey, context: Record): boolean { + const readiness = getSelectorManifestEntry(key).context.readiness + if (!readiness) return true + if (readiness.all?.some((field) => !context[field])) return false + if (readiness.any?.length && !readiness.any.some((field) => Boolean(context[field]))) return false + return true +} diff --git a/apps/sim/lib/selectors/server/credentials.test.ts b/apps/sim/lib/selectors/server/credentials.test.ts new file mode 100644 index 00000000000..3a5965d4a5b --- /dev/null +++ b/apps/sim/lib/selectors/server/credentials.test.ts @@ -0,0 +1,84 @@ +/** + * @vitest-environment node + */ + +import { credential } from '@sim/db/schema' +import { queueTableRows, resetDbChainMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + authorizeCredentialUse: vi.fn(), + credentialProviderMatchesService: vi.fn(), + getServiceConfig: vi.fn(), +})) + +vi.mock('@/lib/auth/credential-access', () => ({ + authorizeCredentialUseForAuth: mocks.authorizeCredentialUse, +})) + +vi.mock('@/lib/oauth/credential-service', () => ({ + resolveCredentialAccessToken: vi.fn(), +})) + +vi.mock('@/lib/oauth/utils', () => ({ + credentialProviderMatchesService: mocks.credentialProviderMatchesService, + getServiceConfigByServiceId: mocks.getServiceConfig, +})) + +import { authorizeSelectorCredential } from '@/lib/selectors/server/credentials' +import { SelectorConnectionUnavailableError } from '@/lib/selectors/server/errors' +import { createSelectorProtectedValues } from '@/lib/selectors/server/protected-values' + +const principal = { kind: 'session' as const, userId: 'user-1', sessionId: 'session-1' } +const policy = { + kind: 'stored' as const, + field: 'oauthCredential' as const, + serviceIds: ['gmail'], +} + +function authorize(): Promise { + return authorizeSelectorCredential({ + principal, + context: { oauthCredential: 'credential-1' }, + scope: { kind: 'workspace', workspaceId: 'workspace-1' }, + workspaceId: 'workspace-1', + policy, + protectedValues: createSelectorProtectedValues(), + }) +} + +describe('authorizeSelectorCredential', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mocks.getServiceConfig.mockReturnValue({ id: 'gmail' }) + }) + + it('conceals a credential authorized in a different workspace', async () => { + mocks.authorizeCredentialUse.mockResolvedValue({ + ok: true, + workspaceId: 'workspace-2', + credentialOwnerUserId: 'owner-1', + resolvedCredentialId: 'credential-1', + }) + + await expect(authorize()).rejects.toEqual(new SelectorConnectionUnavailableError()) + expect(mocks.credentialProviderMatchesService).not.toHaveBeenCalled() + }) + + it('conceals a stored credential whose trusted provider does not match the selector service', async () => { + mocks.authorizeCredentialUse.mockResolvedValue({ + ok: true, + workspaceId: 'workspace-1', + credentialOwnerUserId: 'owner-1', + resolvedCredentialId: 'credential-1', + }) + queueTableRows(credential, [{ accountId: 'account-1', providerId: 'microsoft' }]) + mocks.credentialProviderMatchesService.mockReturnValue(false) + + await expect(authorize()).rejects.toEqual(new SelectorConnectionUnavailableError()) + expect(mocks.credentialProviderMatchesService).toHaveBeenCalledWith('microsoft', { + id: 'gmail', + }) + }) +}) diff --git a/apps/sim/lib/selectors/server/credentials.ts b/apps/sim/lib/selectors/server/credentials.ts new file mode 100644 index 00000000000..56a9f3d276b --- /dev/null +++ b/apps/sim/lib/selectors/server/credentials.ts @@ -0,0 +1,136 @@ +import type { SessionPrincipal } from '@sim/auth/principal' +import { db } from '@sim/db' +import { account, credential } from '@sim/db/schema' +import { and, eq } from 'drizzle-orm' +import { + authorizeCredentialUseForAuth, + type CredentialAccessResult, +} from '@/lib/auth/credential-access' +import { AuthType } from '@/lib/auth/hybrid' +import { resolveCredentialAccessToken } from '@/lib/oauth/credential-service' +import { credentialProviderMatchesService, getServiceConfigByServiceId } from '@/lib/oauth/utils' +import { SelectorConnectionUnavailableError } from '@/lib/selectors/server/errors' +import type { + AuthorizedSelectorCredential, + SelectorCredentialPolicy, + SelectorProtectedValues, +} from '@/lib/selectors/server/types' +import type { SelectorContext, SelectorScope } from '@/lib/selectors/types' + +async function credentialMatchesService(input: { + credentialId: string + credentialOwnerUserId: string + serviceId: string +}): Promise { + const [credentialRow] = await db + .select({ accountId: credential.accountId, providerId: credential.providerId }) + .from(credential) + .where(eq(credential.id, input.credentialId)) + .limit(1) + + let providerId = credentialRow?.providerId ?? null + const accountId = credentialRow?.accountId ?? input.credentialId + if (!providerId) { + const [accountRow] = await db + .select({ providerId: account.providerId }) + .from(account) + .where(and(eq(account.id, accountId), eq(account.userId, input.credentialOwnerUserId))) + .limit(1) + providerId = accountRow?.providerId ?? null + } + + const service = getServiceConfigByServiceId(input.serviceId) + return Boolean(providerId && service && credentialProviderMatchesService(providerId, service)) +} + +async function requireCredentialProviderBinding( + credentialId: string, + access: CredentialAccessResult, + serviceIds: readonly string[] +): Promise { + if (!access.credentialOwnerUserId) throw new SelectorConnectionUnavailableError() + for (const serviceId of serviceIds) { + if ( + await credentialMatchesService({ + credentialId, + credentialOwnerUserId: access.credentialOwnerUserId, + serviceId, + }) + ) { + return + } + } + throw new SelectorConnectionUnavailableError() +} + +export async function authorizeSelectorCredential(input: { + principal: SessionPrincipal + context: SelectorContext + scope: SelectorScope + workspaceId: string + policy: SelectorCredentialPolicy + protectedValues: SelectorProtectedValues +}): Promise { + const suppliedId = input.context[input.policy.field] + if (!suppliedId) throw new SelectorConnectionUnavailableError() + input.protectedValues.add(suppliedId) + + if ( + input.policy.kind === 'stored-or-fixed-token' && + input.policy.tokenPrefixes.some((prefix) => suppliedId.startsWith(prefix)) + ) { + input.protectedValues.add(suppliedId) + return { suppliedId, fixedToken: suppliedId } + } + + const access = await authorizeCredentialUseForAuth( + { + success: true, + userId: input.principal.userId, + authType: AuthType.SESSION, + }, + { + credentialId: suppliedId, + ...(input.scope.kind === 'workflow' ? { workflowId: input.scope.workflowId } : {}), + } + ) + if (!access.ok || access.workspaceId !== input.workspaceId) { + throw new SelectorConnectionUnavailableError() + } + input.protectedValues.add(access.resolvedCredentialId) + + await requireCredentialProviderBinding(suppliedId, access, input.policy.serviceIds) + return { suppliedId, access } +} + +export async function resolveSelectorOAuthAccessToken(input: { + credential: AuthorizedSelectorCredential + serviceId: string + scopes?: readonly string[] + impersonateEmail?: string + protectedValues: SelectorProtectedValues +}): Promise { + if (input.credential.fixedToken) return input.credential.fixedToken + + const access = input.credential.access + if (!access?.credentialOwnerUserId || !access.resolvedCredentialId) { + throw new SelectorConnectionUnavailableError() + } + + const result = await resolveCredentialAccessToken( + input.credential.suppliedId, + access.credentialOwnerUserId, + 'selector-execution', + input.scopes ? [...input.scopes] : undefined, + input.impersonateEmail, + { privacyMode: 'selector' } + ) + const token = result?.accessToken + + if (!token) throw new SelectorConnectionUnavailableError() + input.protectedValues.add(token) + input.protectedValues.add(result.domain) + input.protectedValues.add(result.instanceUrl) + input.protectedValues.add(result.apiDomain) + return token +} diff --git a/apps/sim/lib/selectors/server/errors.ts b/apps/sim/lib/selectors/server/errors.ts new file mode 100644 index 00000000000..309c3258e8c --- /dev/null +++ b/apps/sim/lib/selectors/server/errors.ts @@ -0,0 +1,20 @@ +export class SelectorContextUnavailableError extends Error { + constructor() { + super('Context unavailable') + this.name = 'SelectorContextUnavailableError' + } +} + +export class SelectorConnectionUnavailableError extends Error { + constructor() { + super('Connection unavailable') + this.name = 'SelectorConnectionUnavailableError' + } +} + +export class SelectorOptionsUnavailableError extends Error { + constructor() { + super('Options unavailable') + this.name = 'SelectorOptionsUnavailableError' + } +} diff --git a/apps/sim/lib/selectors/server/internal.ts b/apps/sim/lib/selectors/server/internal.ts new file mode 100644 index 00000000000..cbe7deaf509 --- /dev/null +++ b/apps/sim/lib/selectors/server/internal.ts @@ -0,0 +1,310 @@ +import { listCredentialGroupSettings } from '@/lib/credential-groups/application/manage-groups' +import { getCredentialGroupProviderService } from '@/lib/credential-groups/providers' +import { listInternalCredentials } from '@/lib/credentials/application/credential-crud' +import { fetchOpenRouterEmbeddingModelCatalog } from '@/lib/embeddings/openrouter-model-catalog.server' +import { getEffectiveEnvironmentSnapshot } from '@/lib/environment/utils' +import { listWorkspaceSandboxes } from '@/lib/execution/remote-sandbox/workspace-sandboxes' +import { + listKnowledgeDocuments, + readKnowledgeDocument, +} from '@/lib/knowledge/application/documents' +import { getServiceConfigByProviderId } from '@/lib/oauth/utils' +import type { InternalSelectorKey } from '@/lib/selectors/manifest' +import { + detailSelectorResult, + type ExecuteServerSelectorArgs, + listSelectorResult, + type ServerSelectorAttachmentMap, +} from '@/lib/selectors/server/types' +import { readTableUseCase } from '@/lib/table/application/tables' +import { getColumnId } from '@/lib/table/column-keys' +import { listWorkflows } from '@/lib/workflows/application/list-workflows' +import { filterBlacklistedModels, isProviderBlacklisted } from '@/providers/utils' + +const MAX_WORKFLOW_PAGES = 20 +const WORKFLOW_PAGE_SIZE = 250 +const KNOWLEDGE_PAGE_SIZE = 100 + +function labelWorkflow( + workflow: { id: string; name: string | null; folderPath: string }, + duplicateNames: ReadonlySet +): string { + const base = workflow.name || `Workflow ${workflow.id.slice(0, 8)}` + if (!duplicateNames.has(base)) return base + const folder = + workflow.folderPath === '/' ? 'Root' : workflow.folderPath.slice(1).replaceAll('/', ' / ') + return `${base} (${folder})` +} + +async function loadWorkflows( + args: Parameters<(typeof listWorkflows)['execute']>[0]['principal'], + workspaceId: string +) { + const workflows: Array< + Awaited>['workflows'][number] + > = [] + let cursorKeys: Awaited>['nextCursorKeys'] = null + for (let page = 0; page < MAX_WORKFLOW_PAGES; page += 1) { + const result = await listWorkflows.execute({ + principal: args, + input: { + workspaceId, + scope: 'active', + deployedOnly: false, + sortBy: 'updatedAt', + sortOrder: 'desc', + limit: WORKFLOW_PAGE_SIZE, + ...(cursorKeys ? { cursorKeys } : {}), + }, + }) + workflows.push(...result.workflows) + cursorKeys = result.nextCursorKeys + if (!cursorKeys) break + } + return workflows +} + +async function loadCredentialGroups( + principal: Parameters<(typeof listCredentialGroupSettings)['execute']>[0]['principal'], + workspaceId: string +) { + return (await listCredentialGroupSettings.execute({ principal, input: { workspaceId } })) + .credentialGroups +} + +export const internalSelectorAttachments = { + 'knowledge.documents': { + destination: 'fixed', + async execute(args: ExecuteServerSelectorArgs) { + const knowledgeBaseId = args.context.knowledgeBaseId! + if (args.request.kind === 'detail') { + const result = await readKnowledgeDocument.execute({ + principal: args.principal, + input: { + knowledgeBaseId, + documentId: args.request.id, + assertedWorkspaceId: args.workspaceId, + }, + }) + return detailSelectorResult({ + id: result.document.id, + label: result.document.filename, + }) + } + + const offset = args.request.cursor ? Number(args.request.cursor) : 0 + if (!Number.isSafeInteger(offset) || offset < 0) throw new Error('Invalid selector cursor') + const result = await listKnowledgeDocuments.execute({ + principal: args.principal, + input: { + knowledgeBaseId, + assertedWorkspaceId: args.workspaceId, + enabledFilter: 'all', + search: args.request.search, + limit: KNOWLEDGE_PAGE_SIZE, + offset, + sortBy: 'filename', + sortOrder: 'asc', + }, + }) + const nextOffset = result.pagination.offset + result.pagination.limit + return listSelectorResult( + result.documents.map((document) => ({ id: document.id, label: document.filename })), + result.pagination.hasMore ? String(nextOffset) : undefined + ) + }, + }, + 'sim.workflows': { + destination: 'fixed', + async execute(args: ExecuteServerSelectorArgs) { + const workflows = (await loadWorkflows(args.principal, args.workspaceId)).filter( + (workflow) => workflow.id !== args.context.excludeWorkflowId + ) + const names = workflows.map( + (workflow) => workflow.name || `Workflow ${workflow.id.slice(0, 8)}` + ) + const seen = new Set() + const duplicates = new Set() + for (const name of names) { + if (seen.has(name)) duplicates.add(name) + seen.add(name) + } + const options = workflows + .map((workflow) => ({ + id: workflow.id, + label: labelWorkflow(workflow, duplicates), + })) + .sort((left, right) => left.label.localeCompare(right.label)) + if (args.request.kind === 'detail') { + const detailId = args.request.id + return detailSelectorResult(options.find((option) => option.id === detailId) ?? null) + } + return listSelectorResult(options) + }, + }, + 'table.columns': { + destination: 'fixed', + async execute(args: ExecuteServerSelectorArgs) { + const { table } = await readTableUseCase.execute({ + principal: args.principal, + input: { tableId: args.context.tableId!, workspaceId: args.workspaceId }, + }) + const options = (table.schema?.columns ?? []) + .filter((column) => column.unique) + .map((column) => ({ id: getColumnId(column), label: column.name })) + if (args.request.kind === 'detail') { + const detailId = args.request.id + return detailSelectorResult(options.find((option) => option.id === detailId) ?? null) + } + return listSelectorResult(options) + }, + }, + 'table.outputColumns': { + destination: 'fixed', + async execute(args: ExecuteServerSelectorArgs) { + const { table } = await readTableUseCase.execute({ + principal: args.principal, + input: { tableId: args.context.tableId!, workspaceId: args.workspaceId }, + }) + const options = (table.schema?.columns ?? []).map((column) => ({ + id: getColumnId(column), + label: column.name, + })) + if (args.request.kind === 'detail') { + const detailId = args.request.id + return detailSelectorResult(options.find((option) => option.id === detailId) ?? null) + } + return listSelectorResult(options) + }, + }, + 'workspace.credentialProviders': { + destination: 'fixed', + async execute(args: ExecuteServerSelectorArgs) { + const result = await listInternalCredentials.execute({ + principal: args.principal, + input: { workspaceId: args.workspaceId, type: 'oauth' }, + }) + if (result.mode !== 'list') throw new Error('Unexpected credential lookup result') + const seen = new Set() + const options = result.credentials + .flatMap((credential) => { + if (!credential.providerId || seen.has(credential.providerId)) return [] + seen.add(credential.providerId) + const service = getServiceConfigByProviderId(credential.providerId) + return [{ id: credential.providerId, label: service?.name ?? credential.providerId }] + }) + .sort((left, right) => left.label.localeCompare(right.label)) + if (args.request.kind === 'detail') { + const detailId = args.request.id + return detailSelectorResult( + options.find((option) => option.id === detailId) ?? { + id: detailId, + label: getServiceConfigByProviderId(detailId)?.name ?? detailId, + } + ) + } + return listSelectorResult(options) + }, + }, + 'workspace.credentialGroups': { + destination: 'fixed', + async execute(args: ExecuteServerSelectorArgs) { + const options = (await loadCredentialGroups(args.principal, args.workspaceId)) + .filter((group) => group.status === 'active') + .map((group) => ({ id: group.id, label: group.name })) + .sort((left, right) => left.label.localeCompare(right.label)) + if (args.request.kind === 'detail') { + const detailId = args.request.id + return detailSelectorResult(options.find((option) => option.id === detailId) ?? null) + } + return listSelectorResult(options) + }, + }, + 'workspace.credentialGroupProviders': { + destination: 'fixed', + async execute(args: ExecuteServerSelectorArgs) { + const group = (await loadCredentialGroups(args.principal, args.workspaceId)).find( + (candidate) => candidate.id === args.context.credentialGroupId + ) + const options = (group?.options ?? []) + .filter((option) => option.status === 'active') + .map((option) => { + const service = getCredentialGroupProviderService(option.provider) + return { id: service.providerId, label: service.name } + }) + .sort((left, right) => left.label.localeCompare(right.label)) + if (args.request.kind === 'detail') { + const detailId = args.request.id + return detailSelectorResult(options.find((option) => option.id === detailId) ?? null) + } + return listSelectorResult(options) + }, + }, + 'workspace.secretNames': { + destination: 'fixed', + async execute(args: ExecuteServerSelectorArgs) { + const snapshot = await getEffectiveEnvironmentSnapshot(args.requesterUserId, args.workspaceId) + for (const value of Object.values(snapshot.personalDecrypted)) args.protectedValues.add(value) + for (const value of Object.values(snapshot.workspaceDecrypted)) + args.protectedValues.add(value) + const names = new Set([ + ...Object.keys(snapshot.personalDecrypted), + ...Object.keys(snapshot.workspaceDecrypted), + ]) + return listSelectorResult([...names].sort().map((name) => ({ id: name, label: name }))) + }, + }, + 'workspace.rawSecretNames': { + destination: 'fixed', + async execute(args: ExecuteServerSelectorArgs) { + const result = await listInternalCredentials.execute({ + principal: args.principal, + input: { workspaceId: args.workspaceId }, + }) + if (result.mode !== 'list') throw new Error('Unexpected credential lookup result') + const names = new Set( + result.credentials.flatMap((credential) => + (credential.type === 'env_workspace' || credential.type === 'env_personal') && + credential.role === 'admin' && + credential.envKey + ? [credential.envKey] + : [] + ) + ) + return listSelectorResult([...names].sort().map((name) => ({ id: name, label: name }))) + }, + }, + 'workspace.sandboxes': { + destination: 'fixed', + async execute(args: ExecuteServerSelectorArgs) { + const sandboxes = await listWorkspaceSandboxes(args.workspaceId) + const language = args.context.language + if (args.request.kind === 'detail') { + const detailId = args.request.id + const sandbox = sandboxes.find((candidate) => candidate.id === detailId) + if (!sandbox) return detailSelectorResult(null) + const wrongLanguage = + (language === 'python' || language === 'javascript') && sandbox.language !== language + return detailSelectorResult({ + id: sandbox.id, + label: wrongLanguage ? `${sandbox.name} · wrong language for this block` : sandbox.name, + }) + } + return listSelectorResult( + sandboxes + .filter((sandbox) => !language || language === 'shell' || sandbox.language === language) + .map((sandbox) => ({ id: sandbox.id, label: sandbox.name })) + ) + }, + }, + 'providers.openrouterEmbeddingModels': { + destination: 'fixed', + async execute() { + if (isProviderBlacklisted('openrouter')) return listSelectorResult([]) + const models = filterBlacklistedModels( + (await fetchOpenRouterEmbeddingModelCatalog()).map((model) => model.id) + ) + return listSelectorResult([...new Set(models)].map((model) => ({ id: model, label: model }))) + }, + }, +} as const satisfies ServerSelectorAttachmentMap diff --git a/apps/sim/lib/selectors/server/protected-values.ts b/apps/sim/lib/selectors/server/protected-values.ts new file mode 100644 index 00000000000..d90bc738231 --- /dev/null +++ b/apps/sim/lib/selectors/server/protected-values.ts @@ -0,0 +1,16 @@ +import type { SelectorProtectedValues } from '@/lib/selectors/server/types' + +export function createSelectorProtectedValues(): SelectorProtectedValues { + const values = new Set() + return { + add(value) { + if (value) values.add(value) + }, + contains(value) { + for (const protectedValue of values) { + if (value.includes(protectedValue)) return true + } + return false + }, + } +} diff --git a/apps/sim/lib/selectors/server/providers/airtable.ts b/apps/sim/lib/selectors/server/providers/airtable.ts new file mode 100644 index 00000000000..93e72903df0 --- /dev/null +++ b/apps/sim/lib/selectors/server/providers/airtable.ts @@ -0,0 +1,121 @@ +import { z } from 'zod' +import type { ServerSelectorKey } from '@/lib/selectors/manifest' +import { resolveSelectorOAuthAccessToken } from '@/lib/selectors/server/credentials' +import { + SelectorConnectionUnavailableError, + SelectorContextUnavailableError, + SelectorOptionsUnavailableError, +} from '@/lib/selectors/server/errors' +import { flatSelectorResult } from '@/lib/selectors/server/providers/flat-results' +import { fetchProviderJson } from '@/lib/selectors/server/providers/provider-http' +import type { + ExecuteServerSelectorArgs, + ServerSelectorAttachmentMap, +} from '@/lib/selectors/server/types' + +type AirtableSelectorKey = Extract + +const AIRTABLE_MAX_BASE_PAGES = 50 +const AIRTABLE_BASES_URL = 'https://api.airtable.com/v0/meta/bases' + +const airtableBaseSchema = z.object({ + id: z.string().min(1), + name: z.string().min(1), +}) + +const airtableBasesPageSchema = z.object({ + bases: z.array(airtableBaseSchema).max(1_000).optional(), + offset: z.string().min(1).max(4_096).optional(), +}) + +const airtableTablesResponseSchema = z.object({ + tables: z.array(airtableBaseSchema).max(10_000).optional(), +}) + +function requireCredential(args: ExecuteServerSelectorArgs) { + if (!args.credential) throw new SelectorConnectionUnavailableError() + return args.credential +} + +async function getAccessToken(args: ExecuteServerSelectorArgs): Promise { + return resolveSelectorOAuthAccessToken({ + credential: requireCredential(args), + serviceId: 'airtable', + protectedValues: args.protectedValues, + }) +} + +async function listBases(args: ExecuteServerSelectorArgs, accessToken: string) { + const bases: z.infer[] = [] + let offset: string | undefined + + for (let page = 0; page < AIRTABLE_MAX_BASE_PAGES; page++) { + const url = new URL(AIRTABLE_BASES_URL) + if (offset) url.searchParams.set('offset', offset) + + const body = await fetchProviderJson(url, { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + }, + redirect: 'error', + signal: args.signal, + }) + const parsed = airtableBasesPageSchema.safeParse(body) + if (!parsed.success) throw new SelectorOptionsUnavailableError() + + bases.push(...(parsed.data.bases ?? [])) + offset = parsed.data.offset + if (!offset) break + } + + return bases.map((base) => ({ id: base.id, label: base.name })) +} + +async function listTables(args: ExecuteServerSelectorArgs) { + const baseId = args.context.baseId + if (!baseId || !/^app[A-Za-z0-9]{14}$/.test(baseId)) { + throw new SelectorContextUnavailableError() + } + const accessToken = await getAccessToken(args) + + const body = await fetchProviderJson( + `https://api.airtable.com/v0/meta/bases/${encodeURIComponent(baseId)}/tables`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + }, + redirect: 'error', + signal: args.signal, + } + ) + const parsed = airtableTablesResponseSchema.safeParse(body) + if (!parsed.success) throw new SelectorOptionsUnavailableError() + + return (parsed.data.tables ?? []).map((table) => ({ id: table.id, label: table.name })) +} + +const credential = { + kind: 'stored', + field: 'oauthCredential', + serviceIds: ['airtable'], +} as const + +export const airtableSelectorAttachments = { + 'airtable.bases': { + credential, + destination: 'fixed', + async execute(args) { + const accessToken = await getAccessToken(args) + return flatSelectorResult(args.request, await listBases(args, accessToken), true) + }, + }, + 'airtable.tables': { + credential, + destination: 'fixed', + async execute(args) { + return flatSelectorResult(args.request, await listTables(args), true) + }, + }, +} satisfies ServerSelectorAttachmentMap diff --git a/apps/sim/lib/selectors/server/providers/asana.ts b/apps/sim/lib/selectors/server/providers/asana.ts new file mode 100644 index 00000000000..afe4ffdadfa --- /dev/null +++ b/apps/sim/lib/selectors/server/providers/asana.ts @@ -0,0 +1,85 @@ +import { z } from 'zod' +import type { ServerSelectorKey } from '@/lib/selectors/manifest' +import { resolveSelectorOAuthAccessToken } from '@/lib/selectors/server/credentials' +import { + SelectorConnectionUnavailableError, + SelectorOptionsUnavailableError, +} from '@/lib/selectors/server/errors' +import { flatSelectorResult } from '@/lib/selectors/server/providers/flat-results' +import { fetchProviderJson } from '@/lib/selectors/server/providers/provider-http' +import type { + ExecuteServerSelectorArgs, + ServerSelectorAttachmentMap, +} from '@/lib/selectors/server/types' + +type AsanaSelectorKey = Extract + +const ASANA_WORKSPACES_URL = 'https://app.asana.com/api/1.0/workspaces' +const ASANA_PAGE_LIMIT = 100 +const ASANA_MAX_PAGES = 50 + +const asanaWorkspaceSchema = z.object({ + gid: z.string().min(1), + name: z.string().min(1), +}) + +const asanaPageSchema = z.object({ + data: z.array(asanaWorkspaceSchema).max(ASANA_PAGE_LIMIT).optional(), + next_page: z + .object({ offset: z.string().min(1).max(4_096).optional() }) + .nullable() + .optional(), +}) + +function requireCredential(args: ExecuteServerSelectorArgs) { + if (!args.credential) throw new SelectorConnectionUnavailableError() + return args.credential +} + +async function listWorkspaces(args: ExecuteServerSelectorArgs) { + const accessToken = await resolveSelectorOAuthAccessToken({ + credential: requireCredential(args), + serviceId: 'asana', + protectedValues: args.protectedValues, + }) + const workspaces: z.infer[] = [] + let offset: string | undefined + + for (let page = 0; page < ASANA_MAX_PAGES; page++) { + const url = new URL(ASANA_WORKSPACES_URL) + url.searchParams.set('limit', String(ASANA_PAGE_LIMIT)) + if (offset) url.searchParams.set('offset', offset) + + const body = await fetchProviderJson(url, { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + }, + redirect: 'error', + signal: args.signal, + }) + const parsed = asanaPageSchema.safeParse(body) + if (!parsed.success) throw new SelectorOptionsUnavailableError() + + workspaces.push(...(parsed.data.data ?? [])) + offset = parsed.data.next_page?.offset + if (!offset) break + } + + return workspaces.map((workspace) => ({ id: workspace.gid, label: workspace.name })) +} + +export const asanaSelectorAttachments = { + 'asana.workspaces': { + credential: { + kind: 'stored', + field: 'oauthCredential', + serviceIds: ['asana'], + }, + destination: 'fixed', + async execute(args) { + const items = await listWorkspaces(args) + return flatSelectorResult(args.request, items, true) + }, + }, +} satisfies ServerSelectorAttachmentMap diff --git a/apps/sim/lib/selectors/server/providers/atlassian.ts b/apps/sim/lib/selectors/server/providers/atlassian.ts new file mode 100644 index 00000000000..b2d2089089e --- /dev/null +++ b/apps/sim/lib/selectors/server/providers/atlassian.ts @@ -0,0 +1,64 @@ +import { selectAtlassianCloudId } from '@/lib/atlassian/discovery' +import { + SelectorContextUnavailableError, + SelectorOptionsUnavailableError, +} from '@/lib/selectors/server/errors' +import { fetchProviderJson } from '@/lib/selectors/server/providers/provider-http' + +const ATLASSIAN_ACCESSIBLE_RESOURCES_URL = + 'https://api.atlassian.com/oauth/token/accessible-resources' +const ATLASSIAN_CLOUD_ID_PATTERN = /^[A-Za-z0-9_-]{1,100}$/ + +interface AtlassianAccessibleResource { + id?: string + url?: string +} + +function requireCloudId(value: string): string { + if (!ATLASSIAN_CLOUD_ID_PATTERN.test(value)) { + throw new SelectorOptionsUnavailableError() + } + return value +} + +/** + * Resolves an Atlassian cloud id without putting a reference-resolved domain in + * the shared discovery cache key. The endpoint is fixed and provider failures + * are deliberately collapsed before they reach the selector response boundary. + */ +export async function resolveSelectorAtlassianCloudId(input: { + accessToken: string + domain: string | undefined + providedCloudId?: string + product: 'Jira' | 'Confluence' + signal?: AbortSignal +}): Promise { + if (input.providedCloudId) return requireCloudId(input.providedCloudId) + + const domain = input.domain?.trim() + if (!domain) throw new SelectorContextUnavailableError() + + const resources = await fetchProviderJson( + ATLASSIAN_ACCESSIBLE_RESOURCES_URL, + { + headers: { + Authorization: `Bearer ${input.accessToken}`, + Accept: 'application/json', + }, + redirect: 'error', + signal: input.signal, + } + ) + + try { + return requireCloudId(selectAtlassianCloudId(resources, domain, input.product)) + } catch (error) { + if ( + error instanceof SelectorContextUnavailableError || + error instanceof SelectorOptionsUnavailableError + ) { + throw error + } + throw new SelectorOptionsUnavailableError() + } +} diff --git a/apps/sim/lib/selectors/server/providers/attio.ts b/apps/sim/lib/selectors/server/providers/attio.ts new file mode 100644 index 00000000000..062ac8ac6b7 --- /dev/null +++ b/apps/sim/lib/selectors/server/providers/attio.ts @@ -0,0 +1,90 @@ +import { z } from 'zod' +import type { ServerSelectorKey } from '@/lib/selectors/manifest' +import { resolveSelectorOAuthAccessToken } from '@/lib/selectors/server/credentials' +import { + SelectorConnectionUnavailableError, + SelectorOptionsUnavailableError, +} from '@/lib/selectors/server/errors' +import { flatSelectorResult } from '@/lib/selectors/server/providers/flat-results' +import { fetchProviderJson } from '@/lib/selectors/server/providers/provider-http' +import type { + ExecuteServerSelectorArgs, + ServerSelectorAttachmentMap, +} from '@/lib/selectors/server/types' + +type AttioSelectorKey = Extract + +const attioListSchema = z.object({ + api_slug: z.string().min(1), + name: z.string().min(1), +}) + +const attioObjectSchema = z.object({ + api_slug: z.string().min(1), + singular_noun: z.string().min(1), +}) + +function responseSchema(item: T) { + return z.object({ data: z.array(item).max(10_000).optional() }) +} + +function requireCredential(args: ExecuteServerSelectorArgs) { + if (!args.credential) throw new SelectorConnectionUnavailableError() + return args.credential +} + +async function accessToken(args: ExecuteServerSelectorArgs) { + return resolveSelectorOAuthAccessToken({ + credential: requireCredential(args), + serviceId: 'attio', + protectedValues: args.protectedValues, + }) +} + +async function fetchAttioOptions(args: ExecuteServerSelectorArgs, kind: 'lists' | 'objects') { + const token = await accessToken(args) + const body = await fetchProviderJson(`https://api.attio.com/v2/${kind}`, { + headers: { + Authorization: `Bearer ${token}`, + Accept: 'application/json', + }, + redirect: 'error', + signal: args.signal, + }) + + if (kind === 'lists') { + const parsed = responseSchema(attioListSchema).safeParse(body) + if (!parsed.success) throw new SelectorOptionsUnavailableError() + return (parsed.data.data ?? []).map((list) => ({ id: list.api_slug, label: list.name })) + } + + const parsed = responseSchema(attioObjectSchema).safeParse(body) + if (!parsed.success) throw new SelectorOptionsUnavailableError() + return (parsed.data.data ?? []).map((object) => ({ + id: object.api_slug, + label: object.singular_noun, + })) +} + +const credential = { + kind: 'stored', + field: 'oauthCredential', + serviceIds: ['attio'], +} as const + +export const attioSelectorAttachments = { + 'attio.lists': { + credential, + destination: 'fixed', + async execute(args) { + return flatSelectorResult(args.request, await fetchAttioOptions(args, 'lists'), true) + }, + }, + 'attio.objects': { + credential, + destination: 'fixed', + async execute(args) { + return flatSelectorResult(args.request, await fetchAttioOptions(args, 'objects'), true) + }, + }, +} satisfies ServerSelectorAttachmentMap diff --git a/apps/sim/lib/selectors/server/providers/bigquery.ts b/apps/sim/lib/selectors/server/providers/bigquery.ts new file mode 100644 index 00000000000..782fea32635 --- /dev/null +++ b/apps/sim/lib/selectors/server/providers/bigquery.ts @@ -0,0 +1,158 @@ +import { z } from 'zod' +import { getScopesForService } from '@/lib/oauth/utils' +import type { ServerSelectorKey } from '@/lib/selectors/manifest' +import { resolveSelectorOAuthAccessToken } from '@/lib/selectors/server/credentials' +import { + SelectorConnectionUnavailableError, + SelectorContextUnavailableError, + SelectorOptionsUnavailableError, +} from '@/lib/selectors/server/errors' +import { flatSelectorResult } from '@/lib/selectors/server/providers/flat-results' +import { fetchProviderJson } from '@/lib/selectors/server/providers/provider-http' +import type { + ExecuteServerSelectorArgs, + ServerSelectorAttachmentMap, +} from '@/lib/selectors/server/types' + +type BigQuerySelectorKey = Extract + +const BIGQUERY_MAX_PAGES = 20 +const BIGQUERY_PAGE_SIZE = 200 +const BIGQUERY_SCOPES = getScopesForService('google-bigquery') + +const bigQueryDatasetSchema = z.object({ + datasetReference: z.object({ + datasetId: z.string().min(1), + projectId: z.string().min(1), + }), + friendlyName: z.string().optional(), +}) + +const bigQueryTableSchema = z.object({ + tableReference: z.object({ tableId: z.string().min(1) }), + friendlyName: z.string().optional(), +}) + +const datasetsPageSchema = z.object({ + datasets: z.array(bigQueryDatasetSchema).max(BIGQUERY_PAGE_SIZE).optional(), + nextPageToken: z.string().min(1).max(4_096).optional(), +}) + +const tablesPageSchema = z.object({ + tables: z.array(bigQueryTableSchema).max(BIGQUERY_PAGE_SIZE).optional(), + nextPageToken: z.string().min(1).max(4_096).optional(), +}) + +function requireCredential(args: ExecuteServerSelectorArgs) { + if (!args.credential) throw new SelectorConnectionUnavailableError() + return args.credential +} + +function requireContext(value: string | undefined): string { + if (!value) throw new SelectorContextUnavailableError() + return value +} + +async function getAccessToken(args: ExecuteServerSelectorArgs): Promise { + return resolveSelectorOAuthAccessToken({ + credential: requireCredential(args), + serviceId: 'google-bigquery', + scopes: BIGQUERY_SCOPES, + impersonateEmail: args.context.impersonateUserEmail, + protectedValues: args.protectedValues, + }) +} + +async function listDatasets(args: ExecuteServerSelectorArgs) { + const projectId = requireContext(args.context.projectId) + const accessToken = await getAccessToken(args) + const datasets: z.infer[] = [] + let pageToken: string | undefined + + for (let page = 0; page < BIGQUERY_MAX_PAGES; page++) { + const url = new URL( + `https://bigquery.googleapis.com/bigquery/v2/projects/${encodeURIComponent(projectId)}/datasets` + ) + url.searchParams.set('maxResults', String(BIGQUERY_PAGE_SIZE)) + if (pageToken) url.searchParams.set('pageToken', pageToken) + + const body = await fetchProviderJson(url, { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + }, + redirect: 'error', + signal: args.signal, + }) + const parsed = datasetsPageSchema.safeParse(body) + if (!parsed.success) throw new SelectorOptionsUnavailableError() + + datasets.push(...(parsed.data.datasets ?? [])) + pageToken = parsed.data.nextPageToken + if (!pageToken) break + } + + return datasets.map((dataset) => ({ + id: dataset.datasetReference.datasetId, + label: dataset.friendlyName || dataset.datasetReference.datasetId, + })) +} + +async function listTables(args: ExecuteServerSelectorArgs) { + const projectId = requireContext(args.context.projectId) + const datasetId = requireContext(args.context.datasetId) + const accessToken = await getAccessToken(args) + const tables: z.infer[] = [] + let pageToken: string | undefined + + for (let page = 0; page < BIGQUERY_MAX_PAGES; page++) { + const url = new URL( + `https://bigquery.googleapis.com/bigquery/v2/projects/${encodeURIComponent(projectId)}/datasets/${encodeURIComponent(datasetId)}/tables` + ) + url.searchParams.set('maxResults', String(BIGQUERY_PAGE_SIZE)) + if (pageToken) url.searchParams.set('pageToken', pageToken) + + const body = await fetchProviderJson(url, { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + }, + redirect: 'error', + signal: args.signal, + }) + const parsed = tablesPageSchema.safeParse(body) + if (!parsed.success) throw new SelectorOptionsUnavailableError() + + tables.push(...(parsed.data.tables ?? [])) + pageToken = parsed.data.nextPageToken + if (!pageToken) break + } + + return tables.map((table) => ({ + id: table.tableReference.tableId, + label: table.friendlyName || table.tableReference.tableId, + })) +} + +const credential = { + kind: 'stored', + field: 'oauthCredential', + serviceIds: ['google-bigquery'], +} as const + +export const bigQuerySelectorAttachments = { + 'bigquery.datasets': { + credential, + destination: 'fixed', + async execute(args) { + return flatSelectorResult(args.request, await listDatasets(args), true) + }, + }, + 'bigquery.tables': { + credential, + destination: 'fixed', + async execute(args) { + return flatSelectorResult(args.request, await listTables(args), true) + }, + }, +} satisfies ServerSelectorAttachmentMap diff --git a/apps/sim/lib/selectors/server/providers/bitbucket.test.ts b/apps/sim/lib/selectors/server/providers/bitbucket.test.ts new file mode 100644 index 00000000000..525d80698f2 --- /dev/null +++ b/apps/sim/lib/selectors/server/providers/bitbucket.test.ts @@ -0,0 +1,119 @@ +/** + * @vitest-environment node + */ +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockFetch, mockResolveSelectorOAuthAccessToken } = vi.hoisted(() => ({ + mockFetch: vi.fn(), + mockResolveSelectorOAuthAccessToken: vi.fn(), +})) + +vi.mock('@/lib/selectors/server/credentials', () => ({ + resolveSelectorOAuthAccessToken: mockResolveSelectorOAuthAccessToken, +})) + +import { SelectorContextUnavailableError } from '@/lib/selectors/server/errors' +import { createSelectorProtectedValues } from '@/lib/selectors/server/protected-values' +import { bitbucketSelectorAttachments } from '@/lib/selectors/server/providers/bitbucket' +import type { ExecuteServerSelectorArgs } from '@/lib/selectors/server/types' + +function repositoryArgs( + overrides: Partial = {} +): ExecuteServerSelectorArgs { + return { + selectorKey: 'bitbucket.repositories', + context: { oauthCredential: 'credential-1', workspaceSlug: 'acme-platform' }, + request: { kind: 'list' }, + scope: { kind: 'workspace', workspaceId: 'workspace-1' }, + workspaceId: 'workspace-1', + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + requesterUserId: 'user-1', + credential: { suppliedId: 'credential-1' }, + references: new Map(), + protectedValues: createSelectorProtectedValues(), + ...overrides, + } +} + +function providerResponse(body: unknown): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) +} + +describe('Bitbucket server selector adapters', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.stubGlobal('fetch', mockFetch) + mockResolveSelectorOAuthAccessToken.mockResolvedValue('server-only-token') + }) + + afterAll(() => vi.unstubAllGlobals()) + + it('keeps a referenced workspace server-only while returning an origin-bound page cursor', async () => { + mockFetch.mockResolvedValueOnce( + providerResponse({ + values: [ + { + slug: 'payments-api', + uuid: '{repository-uuid}', + name: 'Payments API', + full_name: 'acme-platform/payments-api', + }, + ], + next: 'https://api.bitbucket.org/2.0/repositories/acme-platform?page=2&pagelen=100', + }) + ) + + const result = await bitbucketSelectorAttachments['bitbucket.repositories'].execute( + repositoryArgs({ + references: new Map([ + [ + 'workspaceSlug', + { + field: 'workspaceSlug', + name: 'BITBUCKET_WORKSPACE', + scope: 'workspace', + visible: false, + }, + ], + ]), + }) + ) + + expect(result).toEqual({ + kind: 'list', + items: [ + { + id: 'payments-api', + label: 'Payments API', + meta: { slug: 'payments-api', uuid: '{repository-uuid}' }, + }, + ], + nextCursor: 'page=2', + }) + const requestUrl = new URL(String(mockFetch.mock.calls[0]?.[0])) + expect(requestUrl.origin).toBe('https://api.bitbucket.org') + expect(requestUrl.pathname).toBe('/2.0/repositories/acme-platform') + expect(new Headers(mockFetch.mock.calls[0]?.[1]?.headers).get('Authorization')).toBe( + 'Bearer server-only-token' + ) + }) + + it('rejects a cursor that attempts to select another destination before resolving a token', async () => { + await expect( + bitbucketSelectorAttachments['bitbucket.repositories'].execute( + repositoryArgs({ + request: { + kind: 'list', + cursor: 'https://evil.example/2.0/repositories/acme-platform?page=2', + }, + }) + ) + ).rejects.toBeInstanceOf(SelectorContextUnavailableError) + + expect(mockResolveSelectorOAuthAccessToken).not.toHaveBeenCalled() + expect(mockFetch).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/selectors/server/providers/bitbucket.ts b/apps/sim/lib/selectors/server/providers/bitbucket.ts new file mode 100644 index 00000000000..cb65a17fb4e --- /dev/null +++ b/apps/sim/lib/selectors/server/providers/bitbucket.ts @@ -0,0 +1,266 @@ +import { z } from 'zod' +import type { ServerSelectorKey } from '@/lib/selectors/manifest' +import { resolveSelectorOAuthAccessToken } from '@/lib/selectors/server/credentials' +import { + SelectorConnectionUnavailableError, + SelectorContextUnavailableError, + SelectorOptionsUnavailableError, +} from '@/lib/selectors/server/errors' +import { fetchProviderJson } from '@/lib/selectors/server/providers/provider-http' +import { + type ExecuteServerSelectorArgs, + listSelectorResult, + requireListRequest, + type ServerSelectorAttachmentMap, +} from '@/lib/selectors/server/types' + +type BitbucketSelectorKey = Extract< + ServerSelectorKey, + 'bitbucket.workspaces' | 'bitbucket.repositories' +> + +const BITBUCKET_API_ORIGIN = 'https://api.bitbucket.org' +const BITBUCKET_WORKSPACES_PATH = '/2.0/user/workspaces' +const BITBUCKET_REPOSITORIES_PATH = '/2.0/repositories' +const BITBUCKET_PAGE_SIZE = 100 +const BITBUCKET_CURSOR_MAX_LENGTH = 4_096 +const BITBUCKET_WORKSPACE_FIELDS = + 'values.administrator,values.workspace.slug,values.workspace.uuid,values.workspace.name,next' +const BITBUCKET_REPOSITORY_FIELDS = 'values.slug,values.uuid,values.name,values.full_name,next' + +const bitbucketSlugSchema = z.string().trim().min(1).max(255) +const bitbucketUuidSchema = z.string().trim().min(1).max(100) +const bitbucketNameSchema = z.string().trim().min(1).max(512) +const workspaceUuidPattern = + /^(?:\{[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\}|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i +const workspaceSlugPattern = /^[a-z0-9][a-z0-9_-]*$/i + +const workspaceSlugSchema = bitbucketSlugSchema.refine( + (slug) => workspaceSlugPattern.test(slug) && !workspaceUuidPattern.test(slug) +) +const repositorySlugSchema = bitbucketSlugSchema.max(62) + +const workspacePageSchema = z.object({ + values: z + .array( + z.object({ + administrator: z.boolean(), + workspace: z.object({ + slug: workspaceSlugSchema, + uuid: bitbucketUuidSchema, + name: bitbucketNameSchema.optional(), + }), + }) + ) + .max(BITBUCKET_PAGE_SIZE), + next: z.string().min(1).max(BITBUCKET_CURSOR_MAX_LENGTH).optional(), +}) + +const repositoryPageSchema = z.object({ + values: z + .array( + z.object({ + slug: repositorySlugSchema.optional(), + uuid: bitbucketUuidSchema, + name: bitbucketNameSchema.optional(), + full_name: bitbucketNameSchema, + }) + ) + .max(BITBUCKET_PAGE_SIZE), + next: z.string().min(1).max(BITBUCKET_CURSOR_MAX_LENGTH).optional(), +}) + +function requireCredential(args: ExecuteServerSelectorArgs) { + if (!args.credential) throw new SelectorConnectionUnavailableError() + return args.credential +} + +function requireWorkspaceSlug(value: string | undefined): string { + const parsed = workspaceSlugSchema.safeParse(value) + if (!parsed.success) throw new SelectorContextUnavailableError() + return parsed.data +} + +function requireCursorParams(cursor: string): URLSearchParams { + if (!cursor || cursor.length > BITBUCKET_CURSOR_MAX_LENGTH) { + throw new SelectorContextUnavailableError() + } + + const input = new URLSearchParams(cursor) + const output = new URLSearchParams() + for (const [key, value] of input) { + if (key === 'page') { + if (!/^[1-9][0-9]{0,8}$/.test(value) || output.has(key)) { + throw new SelectorContextUnavailableError() + } + output.set(key, value) + continue + } + if (key === 'after') { + if (!value || value.length > 512 || output.has(key)) { + throw new SelectorContextUnavailableError() + } + output.set(key, value) + continue + } + throw new SelectorContextUnavailableError() + } + + if (output.size === 0) throw new SelectorContextUnavailableError() + return output +} + +function encodeNextCursor(next: string, expectedPath: string): string { + let url: URL + try { + url = new URL(next) + } catch { + throw new SelectorOptionsUnavailableError() + } + + if ( + url.origin !== BITBUCKET_API_ORIGIN || + url.username || + url.password || + url.hash || + url.pathname.toLowerCase() !== expectedPath.toLowerCase() + ) { + throw new SelectorOptionsUnavailableError() + } + + const cursor = new URLSearchParams() + for (const [key, value] of url.searchParams) { + if (key === 'page' || key === 'after') cursor.append(key, value) + else if (key === 'pagelen' && value !== String(BITBUCKET_PAGE_SIZE)) { + throw new SelectorOptionsUnavailableError() + } + } + + try { + return requireCursorParams(cursor.toString()).toString() + } catch { + throw new SelectorOptionsUnavailableError() + } +} + +function buildPageUrl(path: string, fields: string, cursor: string | undefined): URL { + const url = new URL(path, BITBUCKET_API_ORIGIN) + url.searchParams.set('pagelen', String(BITBUCKET_PAGE_SIZE)) + url.searchParams.set('fields', fields) + if (cursor) { + for (const [key, value] of requireCursorParams(cursor)) { + url.searchParams.set(key, value) + } + } + return url +} + +async function getAccessToken(args: ExecuteServerSelectorArgs): Promise { + return resolveSelectorOAuthAccessToken({ + credential: requireCredential(args), + serviceId: 'bitbucket', + protectedValues: args.protectedValues, + }) +} + +async function listWorkspaces(args: ExecuteServerSelectorArgs) { + const request = requireListRequest(args.selectorKey, args.request) + const url = buildPageUrl(BITBUCKET_WORKSPACES_PATH, BITBUCKET_WORKSPACE_FIELDS, request.cursor) + const accessToken = await getAccessToken(args) + const body = await fetchProviderJson(url, { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + }, + redirect: 'error', + signal: args.signal, + }) + const parsed = workspacePageSchema.safeParse(body) + if (!parsed.success) throw new SelectorOptionsUnavailableError() + + return listSelectorResult( + parsed.data.values.map(({ administrator, workspace }) => ({ + id: workspace.slug, + label: workspace.name ?? workspace.slug, + meta: { + slug: workspace.slug, + uuid: workspace.uuid, + fullName: workspace.name ?? workspace.slug, + administrator, + }, + })), + parsed.data.next ? encodeNextCursor(parsed.data.next, BITBUCKET_WORKSPACES_PATH) : undefined + ) +} + +async function listRepositories(args: ExecuteServerSelectorArgs) { + const request = requireListRequest(args.selectorKey, args.request) + const workspaceSlug = requireWorkspaceSlug(args.context.workspaceSlug) + const path = `${BITBUCKET_REPOSITORIES_PATH}/${workspaceSlug}` + const url = buildPageUrl(path, BITBUCKET_REPOSITORY_FIELDS, request.cursor) + const accessToken = await getAccessToken(args) + const body = await fetchProviderJson(url, { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + }, + redirect: 'error', + signal: args.signal, + }) + const parsed = repositoryPageSchema.safeParse(body) + if (!parsed.success) throw new SelectorOptionsUnavailableError() + + const normalized = parsed.data.values.map((repository) => { + const separator = repository.full_name.indexOf('/') + if (separator <= 0 || separator !== repository.full_name.lastIndexOf('/')) { + throw new SelectorOptionsUnavailableError() + } + const responseWorkspace = repository.full_name.slice(0, separator) + const fullNameSlug = repository.full_name.slice(separator + 1) + const slug = repository.slug ?? fullNameSlug + if ( + responseWorkspace.toLowerCase() !== workspaceSlug.toLowerCase() || + slug !== fullNameSlug || + !repositorySlugSchema.safeParse(slug).success + ) { + throw new SelectorOptionsUnavailableError() + } + + const meta = { + slug, + uuid: repository.uuid, + ...(!args.references.has('workspaceSlug') + ? { fullName: repository.full_name, workspaceSlug } + : {}), + } + return { + id: slug, + label: repository.name ?? slug, + meta, + } + }) + + return listSelectorResult( + normalized, + parsed.data.next ? encodeNextCursor(parsed.data.next, path) : undefined + ) +} + +const credential = { + kind: 'stored', + field: 'oauthCredential', + serviceIds: ['bitbucket'], +} as const + +export const bitbucketSelectorAttachments = { + 'bitbucket.workspaces': { + credential, + destination: 'fixed', + execute: listWorkspaces, + }, + 'bitbucket.repositories': { + credential, + destination: 'fixed', + execute: listRepositories, + }, +} satisfies ServerSelectorAttachmentMap diff --git a/apps/sim/lib/selectors/server/providers/calcom.ts b/apps/sim/lib/selectors/server/providers/calcom.ts new file mode 100644 index 00000000000..3640ecb6c62 --- /dev/null +++ b/apps/sim/lib/selectors/server/providers/calcom.ts @@ -0,0 +1,107 @@ +import { z } from 'zod' +import type { ServerSelectorKey } from '@/lib/selectors/manifest' +import { resolveSelectorOAuthAccessToken } from '@/lib/selectors/server/credentials' +import { + SelectorConnectionUnavailableError, + SelectorOptionsUnavailableError, +} from '@/lib/selectors/server/errors' +import { flatSelectorResult } from '@/lib/selectors/server/providers/flat-results' +import { fetchProviderJson } from '@/lib/selectors/server/providers/provider-http' +import type { + ExecuteServerSelectorArgs, + ServerSelectorAttachmentMap, +} from '@/lib/selectors/server/types' + +type CalcomSelectorKey = Extract + +const calcomIdSchema = z.union([z.string().min(1), z.number().finite()]).transform(String) + +const eventTypesResponseSchema = z.object({ + data: z + .array( + z.object({ + id: calcomIdSchema, + title: z.string(), + slug: z.string().min(1), + }) + ) + .max(10_000) + .optional(), +}) + +const schedulesResponseSchema = z.object({ + data: z + .array( + z.object({ + id: calcomIdSchema, + name: z.string().min(1), + }) + ) + .max(10_000) + .optional(), +}) + +function requireCredential(args: ExecuteServerSelectorArgs) { + if (!args.credential) throw new SelectorConnectionUnavailableError() + return args.credential +} + +async function getAccessToken(args: ExecuteServerSelectorArgs): Promise { + return resolveSelectorOAuthAccessToken({ + credential: requireCredential(args), + serviceId: 'calcom', + protectedValues: args.protectedValues, + }) +} + +async function getOptions(args: ExecuteServerSelectorArgs, kind: 'event-types' | 'schedules') { + const accessToken = await getAccessToken(args) + const body = await fetchProviderJson(`https://api.cal.com/v2/${kind}`, { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + 'cal-api-version': kind === 'event-types' ? '2024-06-14' : '2024-06-11', + }, + redirect: 'error', + signal: args.signal, + }) + + if (kind === 'event-types') { + const parsed = eventTypesResponseSchema.safeParse(body) + if (!parsed.success) throw new SelectorOptionsUnavailableError() + return (parsed.data.data ?? []).map((eventType) => ({ + id: eventType.id, + label: eventType.title || eventType.slug, + })) + } + + const parsed = schedulesResponseSchema.safeParse(body) + if (!parsed.success) throw new SelectorOptionsUnavailableError() + return (parsed.data.data ?? []).map((schedule) => ({ + id: schedule.id, + label: schedule.name, + })) +} + +const credential = { + kind: 'stored', + field: 'oauthCredential', + serviceIds: ['calcom'], +} as const + +export const calcomSelectorAttachments = { + 'calcom.eventTypes': { + credential, + destination: 'fixed', + async execute(args) { + return flatSelectorResult(args.request, await getOptions(args, 'event-types'), true) + }, + }, + 'calcom.schedules': { + credential, + destination: 'fixed', + async execute(args) { + return flatSelectorResult(args.request, await getOptions(args, 'schedules'), true) + }, + }, +} satisfies ServerSelectorAttachmentMap diff --git a/apps/sim/lib/selectors/server/providers/clickup.ts b/apps/sim/lib/selectors/server/providers/clickup.ts new file mode 100644 index 00000000000..914d60f2d56 --- /dev/null +++ b/apps/sim/lib/selectors/server/providers/clickup.ts @@ -0,0 +1,135 @@ +import { z } from 'zod' +import type { ServerSelectorKey } from '@/lib/selectors/manifest' +import { resolveSelectorOAuthAccessToken } from '@/lib/selectors/server/credentials' +import { + SelectorConnectionUnavailableError, + SelectorContextUnavailableError, + SelectorOptionsUnavailableError, +} from '@/lib/selectors/server/errors' +import { fetchProviderJson } from '@/lib/selectors/server/providers/provider-http' +import { + type ExecuteServerSelectorArgs, + listSelectorResult, + requireListRequest, + type ServerSelectorAttachmentMap, +} from '@/lib/selectors/server/types' +import { CLICKUP_API_BASE_URL, clickupAuthorizationHeader } from '@/tools/clickup/shared' + +type ClickupSelectorKey = Extract< + ServerSelectorKey, + 'clickup.workspaces' | 'clickup.spaces' | 'clickup.folders' | 'clickup.lists' +> + +const clickupResourceSchema = z.object({ + id: z.union([z.string().min(1), z.number().finite()]).transform(String), + name: z.string().optional(), +}) + +const clickupResponseSchema = z.object({ + teams: z.array(clickupResourceSchema).max(10_000).optional(), + spaces: z.array(clickupResourceSchema).max(10_000).optional(), + folders: z.array(clickupResourceSchema).max(10_000).optional(), + lists: z.array(clickupResourceSchema).max(10_000).optional(), +}) + +type ClickupResponseField = 'teams' | 'spaces' | 'folders' | 'lists' + +const fallbackLabels: Record = { + teams: 'Workspace', + spaces: 'Space', + folders: 'Folder', + lists: 'List', +} + +function requireCredential(args: ExecuteServerSelectorArgs) { + if (!args.credential) throw new SelectorConnectionUnavailableError() + return args.credential +} + +function requireClickupId(value: string | undefined): string { + if (!value || value.length > 100 || !/^[A-Za-z0-9_-]+$/.test(value)) { + throw new SelectorContextUnavailableError() + } + return value +} + +async function getAccessToken(args: ExecuteServerSelectorArgs): Promise { + return resolveSelectorOAuthAccessToken({ + credential: requireCredential(args), + serviceId: 'clickup', + protectedValues: args.protectedValues, + }) +} + +async function fetchClickupOptions( + args: ExecuteServerSelectorArgs, + field: ClickupResponseField, + path: string +) { + requireListRequest(args.selectorKey, args.request) + const accessToken = await getAccessToken(args) + const body = await fetchProviderJson(`${CLICKUP_API_BASE_URL}${path}`, { + headers: { + Authorization: clickupAuthorizationHeader(accessToken), + Accept: 'application/json', + }, + redirect: 'error', + signal: args.signal, + }) + const parsed = clickupResponseSchema.safeParse(body) + if (!parsed.success) throw new SelectorOptionsUnavailableError() + + const resources = parsed.data[field] + return (resources ?? []).map((resource) => ({ + id: resource.id, + label: resource.name || `${fallbackLabels[field]} ${resource.id}`, + })) +} + +const credential = { + kind: 'stored', + field: 'oauthCredential', + serviceIds: ['clickup'], +} as const + +export const clickupSelectorAttachments = { + 'clickup.workspaces': { + credential, + destination: 'fixed', + async execute(args) { + return listSelectorResult(await fetchClickupOptions(args, 'teams', '/team')) + }, + }, + 'clickup.spaces': { + credential, + destination: 'fixed', + async execute(args) { + const teamId = requireClickupId(args.context.teamId) + return listSelectorResult( + await fetchClickupOptions(args, 'spaces', `/team/${encodeURIComponent(teamId)}/space`) + ) + }, + }, + 'clickup.folders': { + credential, + destination: 'fixed', + async execute(args) { + const spaceId = requireClickupId(args.context.spaceId || args.context.listSpaceId) + return listSelectorResult( + await fetchClickupOptions(args, 'folders', `/space/${encodeURIComponent(spaceId)}/folder`) + ) + }, + }, + 'clickup.lists': { + credential, + destination: 'fixed', + async execute(args) { + const folderId = args.context.folderId + const spaceId = args.context.spaceId + const path = folderId + ? `/folder/${encodeURIComponent(requireClickupId(folderId))}/list` + : `/space/${encodeURIComponent(requireClickupId(spaceId))}/list` + return listSelectorResult(await fetchClickupOptions(args, 'lists', path)) + }, + }, +} satisfies ServerSelectorAttachmentMap diff --git a/apps/sim/lib/selectors/server/providers/cloudwatch.ts b/apps/sim/lib/selectors/server/providers/cloudwatch.ts new file mode 100644 index 00000000000..316ca493de6 --- /dev/null +++ b/apps/sim/lib/selectors/server/providers/cloudwatch.ts @@ -0,0 +1,70 @@ +import { validateAwsRegion } from '@/lib/core/security/input-validation' +import type { ServerSelectorAttachmentMap } from '@/lib/selectors/server/types' +import { detailSelectorResult, listSelectorResult } from '@/lib/selectors/server/types' +import { + type CloudWatchListingCredentials, + listCloudWatchLogGroups, + listCloudWatchLogStreams, +} from '@/tools/cloudwatch/listing' + +type CloudWatchSelectorKey = 'cloudwatch.logGroups' | 'cloudwatch.logStreams' + +function credentials(context: { + awsAccessKeyId?: string + awsSecretAccessKey?: string + awsRegion?: string +}): CloudWatchListingCredentials { + if ( + !context.awsAccessKeyId || + !context.awsSecretAccessKey || + !context.awsRegion || + !validateAwsRegion(context.awsRegion).isValid + ) { + throw new Error('Invalid CloudWatch connection context') + } + return { + accessKeyId: context.awsAccessKeyId, + secretAccessKey: context.awsSecretAccessKey, + region: context.awsRegion, + } +} + +export const cloudWatchSelectorAttachments = { + 'cloudwatch.logGroups': { + destination: 'fixed', + async execute(args) { + if (args.request.kind === 'detail') { + return detailSelectorResult({ id: args.request.id, label: args.request.id }) + } + const groups = await listCloudWatchLogGroups({ + credentials: credentials(args.context), + prefix: args.request.search, + signal: args.signal, + }) + return listSelectorResult( + groups + .filter((group) => group.logGroupName) + .map((group) => ({ id: group.logGroupName, label: group.logGroupName })) + ) + }, + }, + 'cloudwatch.logStreams': { + destination: 'fixed', + async execute(args) { + if (args.request.kind === 'detail') { + return detailSelectorResult({ id: args.request.id, label: args.request.id }) + } + const streams = await listCloudWatchLogStreams({ + credentials: credentials(args.context), + logGroupName: args.context.logGroupName!, + prefix: args.request.search, + signal: args.signal, + }) + return listSelectorResult( + streams + .filter((stream) => stream.logStreamName) + .map((stream) => ({ id: stream.logStreamName, label: stream.logStreamName })) + ) + }, + }, +} satisfies ServerSelectorAttachmentMap diff --git a/apps/sim/lib/selectors/server/providers/confluence.ts b/apps/sim/lib/selectors/server/providers/confluence.ts new file mode 100644 index 00000000000..854110384fa --- /dev/null +++ b/apps/sim/lib/selectors/server/providers/confluence.ts @@ -0,0 +1,208 @@ +import { getScopesForService } from '@/lib/oauth/utils' +import type { ServerSelectorKey } from '@/lib/selectors/manifest' +import { + SelectorContextUnavailableError, + SelectorOptionsUnavailableError, +} from '@/lib/selectors/server/errors' +import { resolveSelectorAtlassianCloudId } from '@/lib/selectors/server/providers/atlassian' +import { resolveSelectorCredentialBundle } from '@/lib/selectors/server/providers/credential-bundle' +import { fetchProviderJson } from '@/lib/selectors/server/providers/provider-http' +import { + detailSelectorResult, + type ExecuteServerSelectorArgs, + listSelectorResult, + type ServerSelectorAttachmentMap, +} from '@/lib/selectors/server/types' +import { fetchConfluencePage } from '@/tools/confluence/client' + +type ConfluenceSelectorKey = Extract + +const CONFLUENCE_SCOPES = getScopesForService('confluence') +const SPACE_PAGE_LIMIT = 250 +const PAGE_LIST_LIMIT = 50 + +type SpaceStatus = 'current' | 'archived' + +interface ConfluenceSpace { + id: string + name: string + key: string + status?: SpaceStatus +} + +interface ConfluenceSpacesResponse { + results?: ConfluenceSpace[] + _links?: { next?: string } +} + +interface ConfluencePage { + id: string + title: string +} + +interface ConfluencePagesResponse { + results?: ConfluencePage[] +} + +function parseSpaceCursor(raw: string | undefined): { status: SpaceStatus; inner?: string } { + if (!raw) return { status: 'current' } + const separator = raw.indexOf(':') + if (separator < 0) return { status: 'current' } + const status = raw.slice(0, separator) === 'archived' ? 'archived' : 'current' + const inner = raw.slice(separator + 1) + return { status, ...(inner ? { inner } : {}) } +} + +function spaceOption(space: ConfluenceSpace, fallbackStatus: SpaceStatus) { + const status = space.status ?? fallbackStatus + const base = `${space.name} (${space.key})` + return { + id: space.key, + label: status === 'archived' ? `${base} — archived` : base, + } +} + +async function resolveConfluenceAuth(args: ExecuteServerSelectorArgs) { + const domain = args.context.domain + if (!domain) throw new SelectorContextUnavailableError() + + const bundle = await resolveSelectorCredentialBundle({ + credential: args.credential, + scopes: CONFLUENCE_SCOPES, + protectedValues: args.protectedValues, + }) + const cloudId = await resolveSelectorAtlassianCloudId({ + accessToken: bundle.accessToken, + domain, + providedCloudId: bundle.cloudId, + product: 'Confluence', + signal: args.signal, + }) + return { accessToken: bundle.accessToken, cloudId } +} + +async function requestSpaces(input: { + accessToken: string + cloudId: string + params: URLSearchParams + signal?: AbortSignal +}): Promise { + const url = new URL(`https://api.atlassian.com/ex/confluence/${input.cloudId}/wiki/api/v2/spaces`) + url.search = input.params.toString() + return fetchProviderJson(url, { + headers: { Accept: 'application/json', Authorization: `Bearer ${input.accessToken}` }, + signal: input.signal, + }) +} + +async function executeSpaces(args: ExecuteServerSelectorArgs) { + const auth = await resolveConfluenceAuth(args) + + if (args.request.kind === 'detail') { + const key = args.request.id.trim() + if (!key || key.length > 255) throw new SelectorContextUnavailableError() + const paramsFor = (status: SpaceStatus) => + new URLSearchParams({ + keys: key, + limit: String(SPACE_PAGE_LIMIT), + status, + }) + const [current, archived] = await Promise.allSettled([ + requestSpaces({ ...auth, params: paramsFor('current'), signal: args.signal }), + requestSpaces({ ...auth, params: paramsFor('archived'), signal: args.signal }), + ]) + if (current.status === 'rejected' && archived.status === 'rejected') { + throw new SelectorOptionsUnavailableError() + } + const spaces = [ + ...(current.status === 'fulfilled' + ? (current.value.results ?? []).map((space) => ({ space, status: 'current' as const })) + : []), + ...(archived.status === 'fulfilled' + ? (archived.value.results ?? []).map((space) => ({ space, status: 'archived' as const })) + : []), + ] + const match = spaces.find(({ space }) => space.key === key) + return detailSelectorResult(match ? spaceOption(match.space, match.status) : null) + } + + const { status, inner } = parseSpaceCursor(args.request.cursor) + const params = new URLSearchParams({ limit: String(SPACE_PAGE_LIMIT), status }) + if (inner) params.set('cursor', inner) + const data = await requestSpaces({ ...auth, params, signal: args.signal }) + + let nextInner: string | undefined + if (data._links?.next) { + try { + nextInner = + new URL(data._links.next, 'https://api.atlassian.com').searchParams.get('cursor') || + undefined + } catch { + nextInner = undefined + } + } + const nextCursor = nextInner + ? `${status}:${nextInner}` + : status === 'current' + ? 'archived:' + : undefined + return listSelectorResult( + (data.results ?? []).map((space) => spaceOption(space, status)), + nextCursor + ) +} + +async function executePages(args: ExecuteServerSelectorArgs) { + const auth = await resolveConfluenceAuth(args) + if (args.request.kind === 'detail') { + const pageId = args.request.id.trim() + if (!/^[A-Za-z0-9_-]{1,255}$/.test(pageId)) { + throw new SelectorContextUnavailableError() + } + let response: Response + try { + response = await fetchConfluencePage({ + ...auth, + pageId, + signal: args.signal, + }) + } catch { + throw new SelectorOptionsUnavailableError() + } + if (!response.ok) throw new SelectorOptionsUnavailableError() + let page: ConfluencePage + try { + page = (await response.json()) as ConfluencePage + } catch { + throw new SelectorOptionsUnavailableError() + } + if (!page.id || !page.title) throw new SelectorOptionsUnavailableError() + return detailSelectorResult({ id: page.id, label: page.title }) + } + + const url = new URL(`https://api.atlassian.com/ex/confluence/${auth.cloudId}/wiki/api/v2/pages`) + url.searchParams.set('limit', String(PAGE_LIST_LIMIT)) + if (args.request.search) url.searchParams.set('title', args.request.search) + const data = await fetchProviderJson(url, { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${auth.accessToken}`, + }, + signal: args.signal, + }) + return listSelectorResult( + (data.results ?? []) + .filter((page) => page.id && page.title) + .map((page) => ({ + id: page.id, + label: page.title, + })) + ) +} + +const credential = { kind: 'stored', field: 'oauthCredential', serviceIds: ['confluence'] } as const + +export const confluenceSelectorAttachments = { + 'confluence.spaces': { credential, destination: 'fixed', execute: executeSpaces }, + 'confluence.pages': { credential, destination: 'fixed', execute: executePages }, +} satisfies ServerSelectorAttachmentMap diff --git a/apps/sim/lib/selectors/server/providers/credential-bundle.ts b/apps/sim/lib/selectors/server/providers/credential-bundle.ts new file mode 100644 index 00000000000..dbd23c81a72 --- /dev/null +++ b/apps/sim/lib/selectors/server/providers/credential-bundle.ts @@ -0,0 +1,52 @@ +import { + resolveCredentialAccessToken, + type ServiceAccountTokenResult, +} from '@/lib/oauth/credential-service' +import { SelectorConnectionUnavailableError } from '@/lib/selectors/server/errors' +import type { + AuthorizedSelectorCredential, + SelectorProtectedValues, +} from '@/lib/selectors/server/types' + +/** + * Resolves credentials whose service-account variants need provider metadata in + * addition to the access token (for example Atlassian's cloud id). + */ +export async function resolveSelectorCredentialBundle(input: { + credential: AuthorizedSelectorCredential | undefined + scopes?: readonly string[] + impersonateEmail?: string + protectedValues: SelectorProtectedValues +}): Promise { + const credential = input.credential + if (!credential) throw new SelectorConnectionUnavailableError() + + if (credential.fixedToken) { + input.protectedValues.add(credential.fixedToken) + return { accessToken: credential.fixedToken } + } + + const ownerUserId = credential.access?.credentialOwnerUserId + if (!ownerUserId) throw new SelectorConnectionUnavailableError() + + let bundle: ServiceAccountTokenResult | null + try { + bundle = await resolveCredentialAccessToken( + credential.suppliedId, + ownerUserId, + 'selector-execution', + input.scopes ? [...input.scopes] : undefined, + input.impersonateEmail, + { privacyMode: 'selector' } + ) + } catch { + throw new SelectorConnectionUnavailableError() + } + if (!bundle?.accessToken) throw new SelectorConnectionUnavailableError() + + input.protectedValues.add(bundle.accessToken) + input.protectedValues.add(bundle.domain) + input.protectedValues.add(bundle.instanceUrl) + input.protectedValues.add(bundle.apiDomain) + return bundle +} diff --git a/apps/sim/lib/selectors/server/providers/flat-results.ts b/apps/sim/lib/selectors/server/providers/flat-results.ts new file mode 100644 index 00000000000..b5ea28e5cc0 --- /dev/null +++ b/apps/sim/lib/selectors/server/providers/flat-results.ts @@ -0,0 +1,18 @@ +import { SelectorOptionsUnavailableError } from '@/lib/selectors/server/errors' +import { detailSelectorResult, listSelectorResult } from '@/lib/selectors/server/types' +import type { + SafeSelectorOption, + SelectorExecutionResult, + SelectorRequest, +} from '@/lib/selectors/types' + +/** Projects a bounded provider list into the selector operation's list/detail result. */ +export function flatSelectorResult( + request: SelectorRequest, + items: SafeSelectorOption[], + supportsDetail = false +): SelectorExecutionResult { + if (request.kind === 'list') return listSelectorResult(items) + if (!supportsDetail) throw new SelectorOptionsUnavailableError() + return detailSelectorResult(items.find((item) => item.id === request.id) ?? null) +} diff --git a/apps/sim/lib/selectors/server/providers/google.ts b/apps/sim/lib/selectors/server/providers/google.ts new file mode 100644 index 00000000000..37a94c9866d --- /dev/null +++ b/apps/sim/lib/selectors/server/providers/google.ts @@ -0,0 +1,376 @@ +import { getScopesForService } from '@/lib/oauth/utils' +import type { ServerSelectorKey } from '@/lib/selectors/manifest' +import { resolveSelectorOAuthAccessToken } from '@/lib/selectors/server/credentials' +import { + SelectorConnectionUnavailableError, + SelectorContextUnavailableError, + SelectorOptionsUnavailableError, +} from '@/lib/selectors/server/errors' +import { fetchProviderJson } from '@/lib/selectors/server/providers/provider-http' +import { + detailSelectorResult, + type ExecuteServerSelectorArgs, + listSelectorResult, + requireListRequest, + type ServerSelectorAttachmentMap, +} from '@/lib/selectors/server/types' + +type GoogleSelectorKey = Extract< + ServerSelectorKey, + 'google.tasks.lists' | 'gmail.labels' | 'google.calendar' | 'google.drive' | 'google.sheets' +> + +interface GoogleTaskList { + id: string + title: string +} + +interface CalendarListItem { + id: string + summary: string + primary?: boolean +} + +interface DriveFile { + id: string + name: string + mimeType: string + shortcutDetails?: { targetId?: string } +} + +interface GmailLabel { + id: string + name: string + type?: 'system' | 'user' +} + +interface Sheet { + properties: { sheetId: number; title: string; index: number } +} + +async function googleAccessToken(args: ExecuteServerSelectorArgs, serviceId: string) { + if (!args.credential) throw new SelectorConnectionUnavailableError() + try { + return await resolveSelectorOAuthAccessToken({ + credential: args.credential, + serviceId, + scopes: getScopesForService(serviceId), + impersonateEmail: args.context.impersonateUserEmail, + protectedValues: args.protectedValues, + }) + } catch (error) { + if (error instanceof SelectorConnectionUnavailableError) throw error + throw new SelectorConnectionUnavailableError() + } +} + +async function drainGooglePages(input: { + accessToken: string + maxPages: number + buildUrl(pageToken: string | undefined): URL + getItems(page: R): T[] | undefined + signal?: AbortSignal +}): Promise { + const items: T[] = [] + let pageToken: string | undefined + for (let page = 0; page < input.maxPages; page++) { + const body = await fetchProviderJson(input.buildUrl(pageToken), { + headers: { Authorization: `Bearer ${input.accessToken}` }, + signal: input.signal, + }) + items.push(...(input.getItems(body) ?? [])) + pageToken = body.nextPageToken?.trim() || undefined + if (!pageToken) break + } + return items +} + +async function listTaskLists(args: ExecuteServerSelectorArgs): Promise { + const accessToken = await googleAccessToken(args, 'google-tasks') + return drainGooglePages({ + accessToken, + maxPages: 20, + buildUrl: (pageToken) => { + const url = new URL('https://tasks.googleapis.com/tasks/v1/users/@me/lists') + url.searchParams.set('maxResults', '1000') + if (pageToken) url.searchParams.set('pageToken', pageToken) + return url + }, + getItems: (page) => page.items, + signal: args.signal, + }) +} + +async function executeTaskLists(args: ExecuteServerSelectorArgs) { + const taskLists = await listTaskLists(args) + if (args.request.kind === 'detail') { + const detailId = args.request.id + const match = taskLists.find((list) => list.id === detailId) + return detailSelectorResult(match ? { id: match.id, label: match.title } : null) + } + return listSelectorResult( + taskLists + .filter((list) => list.id && list.title) + .map((list) => ({ + id: list.id, + label: list.title, + })) + ) +} + +function gmailLabelName(label: GmailLabel): string { + if (label.type !== 'system') return label.name + return label.name.charAt(0).toUpperCase() + label.name.slice(1).toLowerCase() +} + +async function executeGmailLabels(args: ExecuteServerSelectorArgs) { + requireListRequest(args.selectorKey, args.request) + const accessToken = await googleAccessToken(args, 'gmail') + const data = await fetchProviderJson<{ labels?: GmailLabel[] }>( + 'https://gmail.googleapis.com/gmail/v1/users/me/labels', + { headers: { Authorization: `Bearer ${accessToken}` }, signal: args.signal } + ) + if (!Array.isArray(data.labels)) throw new SelectorOptionsUnavailableError() + return listSelectorResult( + data.labels + .filter((label) => label.id && label.name) + .map((label) => ({ + id: label.id, + label: gmailLabelName(label), + })) + ) +} + +async function executeCalendars(args: ExecuteServerSelectorArgs) { + requireListRequest(args.selectorKey, args.request) + const accessToken = await googleAccessToken(args, 'google-calendar') + const calendars = await drainGooglePages< + CalendarListItem, + { items?: CalendarListItem[]; nextPageToken?: string } + >({ + accessToken, + maxPages: 20, + buildUrl: (pageToken) => { + const url = new URL('https://www.googleapis.com/calendar/v3/users/me/calendarList') + url.searchParams.set('maxResults', '250') + if (pageToken) url.searchParams.set('pageToken', pageToken) + return url + }, + getItems: (page) => page.items, + signal: args.signal, + }) + calendars.sort((a, b) => { + if (a.primary && !b.primary) return -1 + if (!a.primary && b.primary) return 1 + return a.summary.localeCompare(b.summary) + }) + return listSelectorResult( + calendars + .filter((calendar) => calendar.id && calendar.summary) + .map((calendar) => ({ + id: calendar.id, + label: calendar.summary, + })) + ) +} + +function escapeDriveQuery(value: string): string { + return value.replace(/\\/g, '\\\\').replace(/'/g, "\\'") +} + +function requireGoogleId(value: string | undefined, maxLength = 255): string { + const trimmed = value?.trim() ?? '' + if (!trimmed || trimmed.length > maxLength || !/^[A-Za-z0-9_-]+$/.test(trimmed)) { + throw new SelectorContextUnavailableError() + } + return trimmed +} + +async function fetchSharedDrives(accessToken: string, signal?: AbortSignal): Promise { + try { + const data = await fetchProviderJson<{ drives?: Array<{ id: string; name: string }> }>( + 'https://www.googleapis.com/drive/v3/drives?pageSize=100&fields=drives(id,name)', + { headers: { Authorization: `Bearer ${accessToken}` }, signal } + ) + return (data.drives ?? []).map((drive) => ({ + id: drive.id, + name: drive.name, + mimeType: 'application/vnd.google-apps.folder', + })) + } catch (error) { + if (signal?.aborted) throw error + return [] + } +} + +async function listDriveFiles( + args: ExecuteServerSelectorArgs, + accessToken: string +): Promise { + const folderId = args.context.fileId?.trim() + if (folderId) requireGoogleId(folderId, 50) + + const mimeType = args.context.mimeType + const search = args.request.kind === 'list' ? args.request.search : undefined + const clauses = ['trashed = false'] + if (folderId) clauses.push(`'${escapeDriveQuery(folderId)}' in parents`) + if (mimeType) clauses.push(`mimeType = '${escapeDriveQuery(mimeType)}'`) + if (search) clauses.push(`name contains '${escapeDriveQuery(search)}'`) + + let files = await drainGooglePages({ + accessToken, + maxPages: 20, + buildUrl: (pageToken) => { + const url = new URL('https://www.googleapis.com/drive/v3/files') + url.searchParams.set('q', clauses.join(' and ')) + url.searchParams.set('corpora', 'allDrives') + url.searchParams.set('supportsAllDrives', 'true') + url.searchParams.set('includeItemsFromAllDrives', 'true') + url.searchParams.set('pageSize', '100') + url.searchParams.set('fields', 'nextPageToken,files(id,name,mimeType)') + if (pageToken) url.searchParams.set('pageToken', pageToken) + return url + }, + getItems: (page) => page.files, + signal: args.signal, + }) + + if (!folderId && mimeType === 'application/vnd.google-apps.folder' && !search) { + files = [...(await fetchSharedDrives(accessToken, args.signal)), ...files] + } + return files +} + +async function fetchDriveDetail( + args: ExecuteServerSelectorArgs, + accessToken: string, + fileId: string +): Promise { + const id = requireGoogleId(fileId) + const headers = { Authorization: `Bearer ${accessToken}` } + let response: Response + try { + response = await fetch( + `https://www.googleapis.com/drive/v3/files/${id}?fields=id,name,mimeType,shortcutDetails&supportsAllDrives=true`, + { headers, redirect: 'error', signal: args.signal } + ) + } catch (error) { + if (args.signal?.aborted) throw error + throw new SelectorOptionsUnavailableError() + } + + if (response.status === 404) { + const drive = await fetchProviderJson<{ id: string; name: string }>( + `https://www.googleapis.com/drive/v3/drives/${id}?fields=id,name`, + { headers, signal: args.signal } + ) + return { id: drive.id, name: drive.name, mimeType: 'application/vnd.google-apps.folder' } + } + if (!response.ok) throw new SelectorOptionsUnavailableError() + + let file: DriveFile + try { + file = (await response.json()) as DriveFile + } catch { + throw new SelectorOptionsUnavailableError() + } + const targetId = + file.mimeType === 'application/vnd.google-apps.shortcut' + ? file.shortcutDetails?.targetId + : undefined + if (!targetId) return file + + let validatedTargetId: string + try { + validatedTargetId = requireGoogleId(targetId) + } catch { + return file + } + try { + return await fetchProviderJson( + `https://www.googleapis.com/drive/v3/files/${validatedTargetId}?fields=id,name,mimeType&supportsAllDrives=true`, + { headers, signal: args.signal } + ) + } catch (error) { + if (args.signal?.aborted) throw error + return file + } +} + +async function executeDrive(args: ExecuteServerSelectorArgs) { + const accessToken = await googleAccessToken(args, 'google-drive') + if (args.request.kind === 'detail') { + const file = await fetchDriveDetail(args, accessToken, args.request.id) + if (!file.id || !file.name) throw new SelectorOptionsUnavailableError() + return detailSelectorResult({ id: file.id, label: file.name }) + } + const files = await listDriveFiles(args, accessToken) + return listSelectorResult( + files + .filter((file) => file.id && file.name) + .map((file) => ({ + id: file.id, + label: file.name, + })) + ) +} + +async function executeSheets(args: ExecuteServerSelectorArgs) { + requireListRequest(args.selectorKey, args.request) + const spreadsheetId = args.context.spreadsheetId?.trim() + if (!spreadsheetId) throw new SelectorContextUnavailableError() + const validatedSpreadsheetId = requireGoogleId(spreadsheetId) + + const accessToken = await googleAccessToken(args, 'google-sheets') + const data = await fetchProviderJson<{ sheets?: Sheet[] }>( + `https://sheets.googleapis.com/v4/spreadsheets/${validatedSpreadsheetId}?fields=sheets.properties`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + signal: args.signal, + } + ) + const sheets = data.sheets ?? [] + sheets.sort((a, b) => a.properties.index - b.properties.index) + return listSelectorResult( + sheets + .filter((sheet) => sheet.properties?.title) + .map((sheet) => ({ + id: sheet.properties.title, + label: sheet.properties.title, + })) + ) +} + +const storedCredential = (serviceIds: readonly string[]) => + ({ kind: 'stored', field: 'oauthCredential', serviceIds }) as const + +export const googleSelectorAttachments = { + 'google.tasks.lists': { + credential: storedCredential(['google-tasks']), + destination: 'fixed', + execute: executeTaskLists, + }, + 'gmail.labels': { + credential: storedCredential(['gmail']), + destination: 'fixed', + execute: executeGmailLabels, + }, + 'google.calendar': { + credential: storedCredential(['google-calendar']), + destination: 'fixed', + execute: executeCalendars, + }, + 'google.drive': { + credential: storedCredential(['google-drive', 'google-docs', 'google-sheets', 'google-forms']), + destination: 'fixed', + execute: executeDrive, + }, + 'google.sheets': { + credential: storedCredential(['google-sheets']), + destination: 'fixed', + execute: executeSheets, + }, +} satisfies ServerSelectorAttachmentMap diff --git a/apps/sim/lib/selectors/server/providers/harmonic.ts b/apps/sim/lib/selectors/server/providers/harmonic.ts new file mode 100644 index 00000000000..a6871bf4899 --- /dev/null +++ b/apps/sim/lib/selectors/server/providers/harmonic.ts @@ -0,0 +1,126 @@ +import { isPlainRecord } from '@sim/utils/object' +import { + HARMONIC_SAVED_SEARCH_SELECTOR_MAX_OPTIONS, + harmonicPeopleSavedSearchProviderSchema, +} from '@/lib/api/contracts/selectors/harmonic' +import { readResponseJsonWithLimit } from '@/lib/core/utils/stream-limits' +import type { ServerSelectorKey } from '@/lib/selectors/manifest' +import { SelectorOptionsUnavailableError } from '@/lib/selectors/server/errors' +import { resolveSelectorCredentialBundle } from '@/lib/selectors/server/providers/credential-bundle' +import { + detailSelectorResult, + type ExecuteServerSelectorArgs, + listSelectorResult, + type ServerSelectorAttachmentMap, +} from '@/lib/selectors/server/types' + +type HarmonicSelectorKey = Extract + +const HARMONIC_URL = 'https://api.harmonic.ai/savedSearches' +const MAX_RESPONSE_BYTES = 1024 * 1024 +const MAX_PROVIDER_ROWS = 2_000 +const FETCH_TIMEOUT_MS = 10_000 + +interface SavedSearch { + id: string + urn: string + name: string +} + +function normalizeSavedSearches(value: unknown): SavedSearch[] { + if (!Array.isArray(value) || value.length > MAX_PROVIDER_ROWS) { + throw new SelectorOptionsUnavailableError() + } + + const byUrn = new Map() + const urnById = new Map() + for (const item of value) { + if (!isPlainRecord(item) || item.type !== 'PERSONS') continue + const parsed = harmonicPeopleSavedSearchProviderSchema.safeParse(item) + if (!parsed.success) throw new SelectorOptionsUnavailableError() + const option = { + id: String(parsed.data.id), + urn: parsed.data.entity_urn, + name: parsed.data.name, + } + const existing = byUrn.get(option.urn) + const existingUrn = urnById.get(option.id) + if ( + (existing && (existing.id !== option.id || existing.name !== option.name)) || + (existingUrn && existingUrn !== option.urn) + ) { + throw new SelectorOptionsUnavailableError() + } + if (existing) continue + if (byUrn.size >= HARMONIC_SAVED_SEARCH_SELECTOR_MAX_OPTIONS) break + byUrn.set(option.urn, option) + urnById.set(option.id, option.urn) + } + return [...byUrn.values()].sort( + (left, right) => left.name.localeCompare(right.name) || left.urn.localeCompare(right.urn) + ) +} + +async function listSavedSearches(args: ExecuteServerSelectorArgs): Promise { + const { accessToken } = await resolveSelectorCredentialBundle({ + credential: args.credential, + protectedValues: args.protectedValues, + }) + const timeoutSignal = AbortSignal.timeout(FETCH_TIMEOUT_MS) + const signal = args.signal ? AbortSignal.any([args.signal, timeoutSignal]) : timeoutSignal + + let response: Response + try { + response = await fetch(HARMONIC_URL, { + headers: { Accept: 'application/json', apikey: accessToken }, + redirect: 'error', + signal, + }) + } catch (error) { + if (args.signal?.aborted) throw error + throw new SelectorOptionsUnavailableError() + } + if (!response.ok) { + await response.body?.cancel().catch(() => {}) + throw new SelectorOptionsUnavailableError() + } + + try { + const body = await readResponseJsonWithLimit(response, { + label: 'Harmonic saved-search response', + maxBytes: MAX_RESPONSE_BYTES, + signal, + }) + return normalizeSavedSearches(body) + } catch (error) { + if (args.signal?.aborted) throw error + if (error instanceof SelectorOptionsUnavailableError) throw error + throw new SelectorOptionsUnavailableError() + } +} + +function toOption(search: SavedSearch) { + return { + id: search.urn, + label: search.name, + meta: { id: search.id, urn: search.urn, name: search.name }, + } +} + +async function executeSavedSearches(args: ExecuteServerSelectorArgs) { + const searches = await listSavedSearches(args) + if (args.request.kind === 'detail') { + const id = args.request.id.trim() + const match = searches.find((search) => search.urn === id || search.id === id) + return detailSelectorResult(match ? toOption(match) : null) + } + return listSelectorResult(searches.map(toOption)) +} + +export const harmonicSelectorAttachments = { + 'harmonic.savedSearches': { + credential: { kind: 'stored', field: 'oauthCredential', serviceIds: ['harmonic'] }, + destination: 'fixed', + execute: executeSavedSearches, + }, +} satisfies ServerSelectorAttachmentMap diff --git a/apps/sim/lib/selectors/server/providers/hubspot.ts b/apps/sim/lib/selectors/server/providers/hubspot.ts new file mode 100644 index 00000000000..6fc5be7243c --- /dev/null +++ b/apps/sim/lib/selectors/server/providers/hubspot.ts @@ -0,0 +1,194 @@ +import { getScopesForService } from '@/lib/oauth/utils' +import type { ServerSelectorKey } from '@/lib/selectors/manifest' +import { resolveSelectorOAuthAccessToken } from '@/lib/selectors/server/credentials' +import { + SelectorConnectionUnavailableError, + SelectorContextUnavailableError, + SelectorOptionsUnavailableError, +} from '@/lib/selectors/server/errors' +import { fetchProviderJson } from '@/lib/selectors/server/providers/provider-http' +import { + type ExecuteServerSelectorArgs, + listSelectorResult, + requireListRequest, + type ServerSelectorAttachmentMap, +} from '@/lib/selectors/server/types' + +type HubSpotSelectorKey = Extract< + ServerSelectorKey, + | 'hubspot.properties' + | 'hubspot.lists' + | 'hubspot.pipelines' + | 'hubspot.pipelineStages' + | 'hubspot.owners' +> + +const BUILT_IN_PATH: Record = { + contact: 'contacts', + company: 'companies', + deal: 'deals', + ticket: 'tickets', +} + +function resolveObjectType(args: ExecuteServerSelectorArgs): string | null { + const selected = args.context.objectType ?? 'contact' + if (selected !== 'custom') return selected + return args.context.customObjectTypeId?.trim() || null +} + +async function hubspotToken(args: ExecuteServerSelectorArgs): Promise { + if (!args.credential) throw new SelectorConnectionUnavailableError() + try { + return await resolveSelectorOAuthAccessToken({ + credential: args.credential, + serviceId: 'hubspot', + scopes: getScopesForService('hubspot'), + protectedValues: args.protectedValues, + }) + } catch (error) { + if (error instanceof SelectorConnectionUnavailableError) throw error + throw new SelectorConnectionUnavailableError() + } +} + +async function executeProperties(args: ExecuteServerSelectorArgs) { + requireListRequest(args.selectorKey, args.request) + const objectType = resolveObjectType(args) + if (!objectType) return listSelectorResult([]) + const accessToken = await hubspotToken(args) + const path = BUILT_IN_PATH[objectType] ?? objectType + const data = await fetchProviderJson<{ + results?: Array<{ + name: string + label: string + hidden?: boolean + archived?: boolean + }> + }>(`https://api.hubapi.com/crm/v3/properties/${encodeURIComponent(path)}`, { + headers: { Authorization: `Bearer ${accessToken}` }, + signal: args.signal, + }) + if (!Array.isArray(data.results)) throw new SelectorOptionsUnavailableError() + return listSelectorResult( + data.results + .filter((property) => !property.hidden && !property.archived && property.name) + .map((property) => ({ id: property.name, label: property.label || property.name })) + .sort((left, right) => left.label.localeCompare(right.label)) + ) +} + +async function executeLists(args: ExecuteServerSelectorArgs) { + requireListRequest(args.selectorKey, args.request) + const accessToken = await hubspotToken(args) + const data = await fetchProviderJson<{ + lists?: Array<{ listId: string; name: string; deletedAt?: string | null }> + }>('https://api.hubapi.com/crm/v3/lists/search?count=500', { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + query: '', + processingTypes: ['MANUAL', 'DYNAMIC', 'SNAPSHOT'], + }), + signal: args.signal, + }) + return listSelectorResult( + (data.lists ?? []) + .filter((list) => !list.deletedAt && list.listId && list.name) + .map((list) => ({ id: list.listId, label: list.name })) + .sort((left, right) => left.label.localeCompare(right.label)) + ) +} + +interface HubSpotPipeline { + id: string + label: string + stages?: Array<{ id: string; label: string }> + archived?: boolean +} + +async function loadPipelines(args: ExecuteServerSelectorArgs): Promise { + const objectType = resolveObjectType(args) + if (!objectType) return [] + const accessToken = await hubspotToken(args) + const path = BUILT_IN_PATH[objectType] ?? objectType + const data = await fetchProviderJson<{ results?: HubSpotPipeline[] }>( + `https://api.hubapi.com/crm/v3/pipelines/${encodeURIComponent(path)}`, + { headers: { Authorization: `Bearer ${accessToken}` }, signal: args.signal } + ) + return (data.results ?? []).filter((pipeline) => !pipeline.archived) +} + +async function executePipelines(args: ExecuteServerSelectorArgs) { + requireListRequest(args.selectorKey, args.request) + const pipelines = await loadPipelines(args) + return listSelectorResult( + pipelines + .filter((pipeline) => pipeline.id && pipeline.label) + .map((pipeline) => ({ id: pipeline.id, label: pipeline.label })) + .sort((left, right) => left.label.localeCompare(right.label)) + ) +} + +async function executePipelineStages(args: ExecuteServerSelectorArgs) { + requireListRequest(args.selectorKey, args.request) + const pipelineId = args.context.pipelineId + if (!pipelineId) throw new SelectorContextUnavailableError() + const pipeline = (await loadPipelines(args)).find((candidate) => candidate.id === pipelineId) + return listSelectorResult( + (pipeline?.stages ?? []) + .filter((stage) => stage.id && stage.label) + .map((stage) => ({ id: stage.id, label: stage.label })) + ) +} + +async function executeOwners(args: ExecuteServerSelectorArgs) { + requireListRequest(args.selectorKey, args.request) + const accessToken = await hubspotToken(args) + const owners: Array<{ + id: string + email?: string + firstName?: string + lastName?: string + archived?: boolean + }> = [] + let after: string | undefined + for (let page = 0; page < 10; page++) { + const url = new URL('https://api.hubapi.com/crm/v3/owners') + url.searchParams.set('limit', '100') + if (after) url.searchParams.set('after', after) + const data = await fetchProviderJson<{ + results?: typeof owners + paging?: { next?: { after?: string } } + }>(url, { headers: { Authorization: `Bearer ${accessToken}` }, signal: args.signal }) + owners.push(...(data.results ?? [])) + after = data.paging?.next?.after + if (!after) break + } + return listSelectorResult( + owners + .filter((owner) => !owner.archived && owner.id) + .map((owner) => ({ + id: owner.id, + label: + [owner.firstName, owner.lastName].filter(Boolean).join(' ') || owner.email || owner.id, + })) + .sort((left, right) => left.label.localeCompare(right.label)) + ) +} + +const credential = { kind: 'stored', field: 'oauthCredential', serviceIds: ['hubspot'] } as const + +export const hubspotSelectorAttachments = { + 'hubspot.properties': { credential, destination: 'fixed', execute: executeProperties }, + 'hubspot.lists': { credential, destination: 'fixed', execute: executeLists }, + 'hubspot.pipelines': { credential, destination: 'fixed', execute: executePipelines }, + 'hubspot.pipelineStages': { + credential, + destination: 'fixed', + execute: executePipelineStages, + }, + 'hubspot.owners': { credential, destination: 'fixed', execute: executeOwners }, +} satisfies ServerSelectorAttachmentMap diff --git a/apps/sim/lib/selectors/server/providers/imap.ts b/apps/sim/lib/selectors/server/providers/imap.ts new file mode 100644 index 00000000000..210f5064a4f --- /dev/null +++ b/apps/sim/lib/selectors/server/providers/imap.ts @@ -0,0 +1,28 @@ +import { listImapMailboxes, normalizeLiteralImapConnection } from '@/lib/imap/connection.server' +import { SelectorConnectionUnavailableError } from '@/lib/selectors/server/errors' +import { listSelectorResult, type ServerSelectorAttachmentMap } from '@/lib/selectors/server/types' + +export const imapSelectorAttachments = { + 'imap.mailboxes': { + destination: 'user-controlled', + async execute(args) { + const hiddenSharedAuth = ['username', 'password'].some((field) => { + const reference = args.references.get(field) + return reference !== undefined && !reference.visible + }) + if (hiddenSharedAuth) throw new SelectorConnectionUnavailableError() + + const connection = normalizeLiteralImapConnection({ + host: args.context.host, + port: args.context.port, + secure: args.context.secure, + username: args.context.username, + password: args.context.password, + }) + const mailboxes = await listImapMailboxes(connection, args.signal) + return listSelectorResult( + mailboxes.map((mailbox) => ({ id: mailbox.path, label: mailbox.name })) + ) + }, + }, +} satisfies ServerSelectorAttachmentMap<'imap.mailboxes'> diff --git a/apps/sim/lib/selectors/server/providers/jira.ts b/apps/sim/lib/selectors/server/providers/jira.ts new file mode 100644 index 00000000000..15700fab424 --- /dev/null +++ b/apps/sim/lib/selectors/server/providers/jira.ts @@ -0,0 +1,200 @@ +import { z } from 'zod' +import { getScopesForService } from '@/lib/oauth/utils' +import type { ServerSelectorKey } from '@/lib/selectors/manifest' +import { + SelectorContextUnavailableError, + SelectorOptionsUnavailableError, +} from '@/lib/selectors/server/errors' +import { resolveSelectorAtlassianCloudId } from '@/lib/selectors/server/providers/atlassian' +import { resolveSelectorCredentialBundle } from '@/lib/selectors/server/providers/credential-bundle' +import { fetchProviderJson } from '@/lib/selectors/server/providers/provider-http' +import { + detailSelectorResult, + type ExecuteServerSelectorArgs, + listSelectorResult, + type ServerSelectorAttachmentMap, +} from '@/lib/selectors/server/types' + +type JiraSelectorKey = Extract + +const JIRA_SCOPES = getScopesForService('jira') +const JIRA_PROJECTS_PAGE_SIZE = 50 +const MAX_JIRA_PROJECTS_PAGES = 40 +const JIRA_ISSUES_LIMIT = 25 + +const jiraProjectSchema = z.object({ + id: z.string().min(1).max(100), + name: z.string().min(1).max(1_000), +}) + +const jiraProjectPageSchema = z.object({ + values: z.array(jiraProjectSchema).max(JIRA_PROJECTS_PAGE_SIZE).optional(), + isLast: z.boolean().optional(), + maxResults: z.number().int().positive().max(JIRA_PROJECTS_PAGE_SIZE).optional(), +}) + +const jiraIssueSchema = z.object({ + key: z.string().min(1).max(100), + fields: z + .object({ + summary: z.string().max(10_000).nullable().optional(), + }) + .optional(), +}) + +const jiraIssuePageSchema = z.object({ + issues: z.array(jiraIssueSchema).max(100).optional(), +}) + +function requirePathId(value: string | undefined): string { + const trimmed = value?.trim() ?? '' + if (!/^[A-Za-z0-9_-]{1,100}$/.test(trimmed)) { + throw new SelectorContextUnavailableError() + } + return trimmed +} + +function requireIssueKey(value: string): string { + const trimmed = value.trim() + if (!/^[A-Za-z][A-Za-z0-9_]*-\d+$/.test(trimmed)) { + throw new SelectorContextUnavailableError() + } + return trimmed +} + +function escapeJql(value: string): string { + return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"') +} + +async function resolveJiraAuth(args: ExecuteServerSelectorArgs) { + const bundle = await resolveSelectorCredentialBundle({ + credential: args.credential, + scopes: JIRA_SCOPES, + protectedValues: args.protectedValues, + }) + const cloudId = await resolveSelectorAtlassianCloudId({ + accessToken: bundle.accessToken, + domain: args.context.domain, + providedCloudId: bundle.cloudId, + product: 'Jira', + signal: args.signal, + }) + return { accessToken: bundle.accessToken, cloudId } +} + +function jiraHeaders(accessToken: string) { + return { Authorization: `Bearer ${accessToken}`, Accept: 'application/json' } +} + +async function listProjects(args: ExecuteServerSelectorArgs) { + const auth = await resolveJiraAuth(args) + const projects: z.infer[] = [] + let startAt = 0 + + for (let page = 0; page < MAX_JIRA_PROJECTS_PAGES; page++) { + const url = new URL( + `https://api.atlassian.com/ex/jira/${auth.cloudId}/rest/api/3/project/search` + ) + if (args.request.kind === 'list' && args.request.search) { + url.searchParams.set('query', args.request.search) + } + url.searchParams.set('orderBy', 'name') + url.searchParams.set('expand', 'description,lead,url,projectKeys') + url.searchParams.set('startAt', String(startAt)) + url.searchParams.set('maxResults', String(JIRA_PROJECTS_PAGE_SIZE)) + + const body = await fetchProviderJson(url, { + headers: jiraHeaders(auth.accessToken), + redirect: 'error', + signal: args.signal, + }) + const parsed = jiraProjectPageSchema.safeParse(body) + if (!parsed.success) throw new SelectorOptionsUnavailableError() + const values = parsed.data.values ?? [] + projects.push(...values) + + const pageSize = parsed.data.maxResults ?? JIRA_PROJECTS_PAGE_SIZE + if (parsed.data.isLast === true || values.length < pageSize || values.length === 0) break + startAt += values.length + } + + return projects.map((project) => ({ id: project.id, label: project.name })) +} + +async function getProject(args: ExecuteServerSelectorArgs, projectId: string) { + const auth = await resolveJiraAuth(args) + const body = await fetchProviderJson( + `https://api.atlassian.com/ex/jira/${auth.cloudId}/rest/api/3/project/${encodeURIComponent(projectId)}`, + { + headers: jiraHeaders(auth.accessToken), + redirect: 'error', + signal: args.signal, + } + ) + const parsed = jiraProjectSchema.safeParse(body) + if (!parsed.success) throw new SelectorOptionsUnavailableError() + return { id: parsed.data.id, label: parsed.data.name } +} + +async function fetchIssues( + args: ExecuteServerSelectorArgs, + issueKey?: string +): Promise> { + const auth = await resolveJiraAuth(args) + const jqlParts: string[] = [] + + if (issueKey) { + jqlParts.push(`issueKey = "${escapeJql(issueKey)}"`) + } else { + const projectId = args.context.projectId + const search = args.request.kind === 'list' ? args.request.search : undefined + if (!projectId && !search) return [] + if (projectId) jqlParts.push(`project = "${escapeJql(requirePathId(projectId))}"`) + if (search) { + const escaped = escapeJql(search) + jqlParts.push(`(key ~ "${escaped}" OR summary ~ "${escaped}")`) + } + } + + const url = new URL(`https://api.atlassian.com/ex/jira/${auth.cloudId}/rest/api/3/search/jql`) + url.searchParams.set( + 'jql', + issueKey ? jqlParts.join(' AND ') : `${jqlParts.join(' AND ')} ORDER BY updated DESC` + ) + url.searchParams.set('fields', 'summary,key,updated') + url.searchParams.set('maxResults', String(issueKey ? 1 : JIRA_ISSUES_LIMIT)) + + const body = await fetchProviderJson(url, { + headers: jiraHeaders(auth.accessToken), + redirect: 'error', + signal: args.signal, + }) + const parsed = jiraIssuePageSchema.safeParse(body) + if (!parsed.success) throw new SelectorOptionsUnavailableError() + return (parsed.data.issues ?? []).map((issue) => ({ + id: issue.key, + label: issue.fields?.summary || issue.key, + })) +} + +async function executeProjects(args: ExecuteServerSelectorArgs) { + if (args.request.kind === 'detail') { + return detailSelectorResult(await getProject(args, requirePathId(args.request.id))) + } + return listSelectorResult(await listProjects(args)) +} + +async function executeIssues(args: ExecuteServerSelectorArgs) { + if (args.request.kind === 'detail') { + const issues = await fetchIssues(args, requireIssueKey(args.request.id)) + return detailSelectorResult(issues[0] ?? null) + } + return listSelectorResult(await fetchIssues(args)) +} + +const credential = { kind: 'stored', field: 'oauthCredential', serviceIds: ['jira'] } as const + +export const jiraSelectorAttachments = { + 'jira.projects': { credential, destination: 'fixed', execute: executeProjects }, + 'jira.issues': { credential, destination: 'fixed', execute: executeIssues }, +} satisfies ServerSelectorAttachmentMap diff --git a/apps/sim/lib/selectors/server/providers/jsm.ts b/apps/sim/lib/selectors/server/providers/jsm.ts new file mode 100644 index 00000000000..a355355977c --- /dev/null +++ b/apps/sim/lib/selectors/server/providers/jsm.ts @@ -0,0 +1,149 @@ +import { z } from 'zod' +import { getScopesForService } from '@/lib/oauth/utils' +import type { ServerSelectorKey } from '@/lib/selectors/manifest' +import { + SelectorContextUnavailableError, + SelectorOptionsUnavailableError, +} from '@/lib/selectors/server/errors' +import { resolveSelectorAtlassianCloudId } from '@/lib/selectors/server/providers/atlassian' +import { resolveSelectorCredentialBundle } from '@/lib/selectors/server/providers/credential-bundle' +import { fetchProviderJson } from '@/lib/selectors/server/providers/provider-http' +import { + detailSelectorResult, + type ExecuteServerSelectorArgs, + listSelectorResult, + type ServerSelectorAttachmentMap, +} from '@/lib/selectors/server/types' +import { getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils' + +type JsmSelectorKey = Extract + +const JIRA_SCOPES = getScopesForService('jira') +const JSM_PAGE_SIZE = 100 +const MAX_JSM_PAGES = 50 + +const serviceDeskSchema = z.object({ + id: z.string().min(1).max(100), + projectName: z.string().min(1).max(1_000), +}) + +const requestTypeSchema = z.object({ + id: z.string().min(1).max(100), + name: z.string().min(1).max(1_000), +}) + +function pagedSchema(item: T) { + return z.object({ + values: z.array(item).max(JSM_PAGE_SIZE).optional(), + isLastPage: z.boolean().optional(), + _links: z.object({ next: z.string().max(4_096).optional() }).optional(), + }) +} + +function requireServiceDeskId(value: string | undefined): string { + const trimmed = value?.trim() ?? '' + if (!/^[A-Za-z0-9_-]{1,100}$/.test(trimmed)) { + throw new SelectorContextUnavailableError() + } + return trimmed +} + +function requireDetailId(value: string): string { + const trimmed = value.trim() + if (!trimmed || trimmed.length > 100) throw new SelectorContextUnavailableError() + return trimmed +} + +async function resolveJsmAuth(args: ExecuteServerSelectorArgs) { + const bundle = await resolveSelectorCredentialBundle({ + credential: args.credential, + scopes: JIRA_SCOPES, + protectedValues: args.protectedValues, + }) + const cloudId = await resolveSelectorAtlassianCloudId({ + accessToken: bundle.accessToken, + domain: args.context.domain, + providedCloudId: bundle.cloudId, + product: 'Jira', + signal: args.signal, + }) + return { accessToken: bundle.accessToken, cloudId } +} + +async function drainJsmPages(input: { + args: ExecuteServerSelectorArgs + accessToken: string + baseUrl: string + schema: z.ZodType +}): Promise { + const rows: T[] = [] + let start = 0 + + for (let page = 0; page < MAX_JSM_PAGES; page++) { + const url = new URL(input.baseUrl) + url.searchParams.set('start', String(start)) + url.searchParams.set('limit', String(JSM_PAGE_SIZE)) + const body = await fetchProviderJson(url, { + headers: getJsmHeaders(input.accessToken), + redirect: 'error', + signal: input.args.signal, + }) + const parsed = pagedSchema(input.schema).safeParse(body) + if (!parsed.success) throw new SelectorOptionsUnavailableError() + const values = parsed.data.values ?? [] + rows.push(...values) + if (parsed.data.isLastPage === true || !parsed.data._links?.next || values.length === 0) { + break + } + start += values.length + } + + return rows +} + +async function serviceDeskOptions(args: ExecuteServerSelectorArgs) { + const auth = await resolveJsmAuth(args) + const rows = await drainJsmPages({ + args, + ...auth, + baseUrl: `${getJsmApiBaseUrl(auth.cloudId)}/servicedesk`, + schema: serviceDeskSchema, + }) + return rows.map((row) => ({ id: row.id, label: row.projectName })) +} + +async function requestTypeOptions(args: ExecuteServerSelectorArgs) { + const serviceDeskId = requireServiceDeskId(args.context.serviceDeskId) + const auth = await resolveJsmAuth(args) + const rows = await drainJsmPages({ + args, + ...auth, + baseUrl: `${getJsmApiBaseUrl(auth.cloudId)}/servicedesk/${encodeURIComponent(serviceDeskId)}/requesttype`, + schema: requestTypeSchema, + }) + return rows.map((row) => ({ id: row.id, label: row.name })) +} + +function resultForRequest( + args: ExecuteServerSelectorArgs, + items: Array<{ id: string; label: string }> +) { + if (args.request.kind === 'list') return listSelectorResult(items) + const id = requireDetailId(args.request.id) + return detailSelectorResult(items.find((item) => item.id === id) ?? null) +} + +const credential = { kind: 'stored', field: 'oauthCredential', serviceIds: ['jira'] } as const + +export const jsmSelectorAttachments = { + 'jsm.serviceDesks': { + credential, + destination: 'fixed', + execute: async (args) => resultForRequest(args, await serviceDeskOptions(args)), + }, + 'jsm.requestTypes': { + credential, + destination: 'fixed', + execute: async (args) => resultForRequest(args, await requestTypeOptions(args)), + }, +} satisfies ServerSelectorAttachmentMap diff --git a/apps/sim/lib/selectors/server/providers/linear.ts b/apps/sim/lib/selectors/server/providers/linear.ts new file mode 100644 index 00000000000..a409b68f226 --- /dev/null +++ b/apps/sim/lib/selectors/server/providers/linear.ts @@ -0,0 +1,117 @@ +import type { Project, Team } from '@linear/sdk' +import { LinearClient } from '@linear/sdk' +import { getScopesForService } from '@/lib/oauth/utils' +import type { ServerSelectorKey } from '@/lib/selectors/manifest' +import { resolveSelectorOAuthAccessToken } from '@/lib/selectors/server/credentials' +import { + SelectorConnectionUnavailableError, + SelectorContextUnavailableError, + SelectorOptionsUnavailableError, +} from '@/lib/selectors/server/errors' +import { + type ExecuteServerSelectorArgs, + listSelectorResult, + requireListRequest, + type ServerSelectorAttachmentMap, +} from '@/lib/selectors/server/types' + +type LinearSelectorKey = Extract + +const LINEAR_SCOPES = getScopesForService('linear') +const LINEAR_PAGE_SIZE = 250 +const MAX_LINEAR_PAGES = 10 +const MAX_SELECTED_TEAMS = 100 + +async function linearClient(args: ExecuteServerSelectorArgs) { + if (!args.credential) throw new SelectorConnectionUnavailableError() + const token = await resolveSelectorOAuthAccessToken({ + credential: args.credential, + serviceId: 'linear', + scopes: LINEAR_SCOPES, + protectedValues: args.protectedValues, + }) + return token.startsWith('lin_api_') + ? new LinearClient({ apiKey: token, redirect: 'error', signal: args.signal }) + : new LinearClient({ accessToken: token, redirect: 'error', signal: args.signal }) +} + +async function fetchAllTeams(client: LinearClient): Promise { + const teams: Team[] = [] + let after: string | undefined + + for (let page = 0; page < MAX_LINEAR_PAGES; page++) { + const result = await client.teams({ first: LINEAR_PAGE_SIZE, after }) + teams.push(...result.nodes) + if (!result.pageInfo.hasNextPage || !result.pageInfo.endCursor) break + after = result.pageInfo.endCursor + } + return teams +} + +async function fetchAllProjects(team: Team): Promise { + const projects: Project[] = [] + let after: string | undefined + + for (let page = 0; page < MAX_LINEAR_PAGES; page++) { + const result = await team.projects({ first: LINEAR_PAGE_SIZE, after }) + projects.push(...result.nodes) + if (!result.pageInfo.hasNextPage || !result.pageInfo.endCursor) break + after = result.pageInfo.endCursor + } + return projects +} + +function selectedTeamIds(raw: string | undefined): string[] { + const ids = (raw ?? '') + .split(',') + .map((id) => id.trim()) + .filter(Boolean) + if (ids.length === 0 || ids.length > MAX_SELECTED_TEAMS || ids.some((id) => id.length > 100)) { + throw new SelectorContextUnavailableError() + } + return ids +} + +async function executeTeams(args: ExecuteServerSelectorArgs) { + requireListRequest(args.selectorKey, args.request) + const client = await linearClient(args) + try { + return listSelectorResult( + (await fetchAllTeams(client)).map((team) => ({ id: team.id, label: team.name })) + ) + } catch (error) { + if (args.signal?.aborted) throw error + throw new SelectorOptionsUnavailableError() + } +} + +async function executeProjects(args: ExecuteServerSelectorArgs) { + requireListRequest(args.selectorKey, args.request) + const teamIds = selectedTeamIds(args.context.teamId) + const client = await linearClient(args) + try { + const perTeam = await Promise.all( + teamIds.map(async (teamId) => fetchAllProjects(await client.team(teamId))) + ) + const seen = new Set() + const options: Array<{ id: string; label: string }> = [] + for (const projects of perTeam) { + for (const project of projects) { + if (seen.has(project.id)) continue + seen.add(project.id) + options.push({ id: project.id, label: project.name }) + } + } + return listSelectorResult(options) + } catch (error) { + if (args.signal?.aborted) throw error + throw new SelectorOptionsUnavailableError() + } +} + +const credential = { kind: 'stored', field: 'oauthCredential', serviceIds: ['linear'] } as const + +export const linearSelectorAttachments = { + 'linear.teams': { credential, destination: 'fixed', execute: executeTeams }, + 'linear.projects': { credential, destination: 'fixed', execute: executeProjects }, +} satisfies ServerSelectorAttachmentMap diff --git a/apps/sim/lib/selectors/server/providers/managed-agent.ts b/apps/sim/lib/selectors/server/providers/managed-agent.ts new file mode 100644 index 00000000000..77d038cc820 --- /dev/null +++ b/apps/sim/lib/selectors/server/providers/managed-agent.ts @@ -0,0 +1,119 @@ +import { AGENT_MEMORY_BETA, managedAgentsList } from '@/lib/managed-agents/session-client' +import type { ServerSelectorKey } from '@/lib/selectors/manifest' +import { resolveSelectorCredentialBundle } from '@/lib/selectors/server/providers/credential-bundle' +import { + type ExecuteServerSelectorArgs, + listSelectorResult, + requireListRequest, + type ServerSelectorAttachmentMap, +} from '@/lib/selectors/server/types' +import type { SafeSelectorOption } from '@/lib/selectors/types' + +type ManagedAgentSelectorKey = Extract< + ServerSelectorKey, + | 'managedAgent.agents' + | 'managedAgent.environments' + | 'managedAgent.vaults' + | 'managedAgent.memoryStores' +> + +type ManagedAgentResource = 'agents' | 'environments' | 'vaults' | 'memory-stores' + +interface ManagedAgentRow { + id?: unknown + name?: unknown + config?: { type?: unknown } +} + +const RESOURCE_ENDPOINTS: Record = { + agents: { path: '/v1/agents' }, + environments: { path: '/v1/environments' }, + vaults: { path: '/v1/vaults' }, + 'memory-stores': { path: '/v1/memory_stores', beta: AGENT_MEMORY_BETA }, +} + +function toOption( + resource: ManagedAgentResource, + row: ManagedAgentRow, + environmentType: string | undefined +): SafeSelectorOption | null { + if (typeof row.id !== 'string' || !row.id) return null + const name = typeof row.name === 'string' ? row.name.trim() : '' + + if (resource === 'environments') { + const type = row.config?.type + const validType = type === 'cloud' || type === 'self_hosted' ? type : undefined + if ( + (environmentType === 'cloud' || environmentType === 'self_hosted') && + validType !== undefined && + validType !== environmentType + ) { + return null + } + return { + id: row.id, + label: `${name || row.id}${validType ? ` (${validType})` : ''}`, + ...(validType ? { meta: { type: validType } } : {}), + } + } + + if (resource === 'vaults') return { id: row.id, label: name || row.id } + return { id: row.id, label: name ? `${name} (${row.id})` : row.id } +} + +async function executeResource(args: ExecuteServerSelectorArgs, resource: ManagedAgentResource) { + requireListRequest(args.selectorKey, args.request) + const bundle = await resolveSelectorCredentialBundle({ + credential: args.credential, + protectedValues: args.protectedValues, + }) + const endpoint = RESOURCE_ENDPOINTS[resource] + + try { + const rows = await managedAgentsList({ + apiKey: bundle.accessToken, + path: endpoint.path, + beta: endpoint.beta, + signal: args.signal, + }) + return listSelectorResult( + rows + .map((row) => toOption(resource, row, args.context.environmentType)) + .filter((option): option is SafeSelectorOption => option !== null) + ) + } catch (error) { + if (args.signal?.aborted) throw error + // Preserve the existing editor behavior for beta resources that are not + // enabled in a Claude workspace: an unavailable collection is an empty list. + return listSelectorResult([]) + } +} + +const credential = { + kind: 'stored', + field: 'oauthCredential', + serviceIds: ['claude-platform'], +} as const + +export const managedAgentSelectorAttachments = { + 'managedAgent.agents': { + credential, + destination: 'fixed', + execute: (args) => executeResource(args, 'agents'), + }, + 'managedAgent.environments': { + credential, + destination: 'fixed', + execute: (args) => executeResource(args, 'environments'), + }, + 'managedAgent.vaults': { + credential, + destination: 'fixed', + execute: (args) => executeResource(args, 'vaults'), + }, + 'managedAgent.memoryStores': { + credential, + destination: 'fixed', + execute: (args) => executeResource(args, 'memory-stores'), + }, +} satisfies ServerSelectorAttachmentMap diff --git a/apps/sim/lib/selectors/server/providers/microsoft.ts b/apps/sim/lib/selectors/server/providers/microsoft.ts new file mode 100644 index 00000000000..47694ad2ca4 --- /dev/null +++ b/apps/sim/lib/selectors/server/providers/microsoft.ts @@ -0,0 +1,443 @@ +import { + validateMicrosoftGraphId, + validatePathSegment, + validateSharePointSiteId, +} from '@/lib/core/security/input-validation' +import type { ServerSelectorKey } from '@/lib/selectors/manifest' +import { resolveSelectorOAuthAccessToken } from '@/lib/selectors/server/credentials' +import { + SelectorConnectionUnavailableError, + SelectorContextUnavailableError, +} from '@/lib/selectors/server/errors' +import { flatSelectorResult } from '@/lib/selectors/server/providers/flat-results' +import { fetchProviderJson } from '@/lib/selectors/server/providers/provider-http' +import type { + ExecuteServerSelectorArgs, + SelectorCredentialPolicy, + ServerSelectorAttachmentMap, +} from '@/lib/selectors/server/types' +import type { SafeSelectorOption } from '@/lib/selectors/types' +import { GRAPH_ID_PATTERN, getItemBasePath } from '@/tools/microsoft_excel/utils' +import { assertGraphNextPageUrl, getGraphNextPageUrl } from '@/tools/sharepoint/utils' + +type MicrosoftSelectorKey = Extract< + ServerSelectorKey, + | 'microsoft.planner.plans' + | 'outlook.folders' + | 'outlook.calendars' + | 'microsoft.teams' + | 'microsoft.chats' + | 'microsoft.channels' + | 'microsoft.planner' + | 'onedrive.files' + | 'onedrive.folders' + | 'microsoft.excel.sheets' + | 'microsoft.excel.drives' + | 'microsoft.excel' + | 'microsoft.word' +> + +function microsoftCredential(serviceId: string): SelectorCredentialPolicy { + return { kind: 'stored', field: 'oauthCredential', serviceIds: [serviceId] } +} + +async function graphToken(args: ExecuteServerSelectorArgs, serviceId: string): Promise { + if (!args.credential) throw new SelectorConnectionUnavailableError() + return resolveSelectorOAuthAccessToken({ + credential: args.credential, + serviceId, + protectedValues: args.protectedValues, + }) +} + +async function drainGraph(input: { + args: ExecuteServerSelectorArgs + serviceId: string + initialUrl: string + maxPages: number + token?: string +}): Promise { + const token = input.token ?? (await graphToken(input.args, input.serviceId)) + const values: T[] = [] + let nextUrl: string | undefined = input.initialUrl + for (let page = 0; page < input.maxPages && nextUrl; page++) { + const data = await fetchProviderJson<{ value?: T[] } & Record>(nextUrl, { + headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, + signal: input.args.signal, + redirect: 'error', + }) + if (Array.isArray(data.value)) values.push(...data.value) + const nextLink = getGraphNextPageUrl(data) + nextUrl = nextLink ? assertGraphNextPageUrl(nextLink) : undefined + } + return values +} + +function requireGraphId(value: string | undefined, label: string): string { + if (!value) throw new SelectorContextUnavailableError() + const validation = validateMicrosoftGraphId(value, label) + if (!validation.isValid) throw new SelectorContextUnavailableError() + return validation.sanitized ?? value +} + +function requireDriveId(value: string | undefined): string | undefined { + if (!value) return undefined + const validation = validatePathSegment(value, { + paramName: 'driveId', + customPattern: GRAPH_ID_PATTERN, + }) + if (!validation.isValid) throw new SelectorContextUnavailableError() + return validation.sanitized ?? value +} + +function encodeGraphSearch(value: string): string { + return encodeURIComponent(value).replace(/'/g, '%27') +} + +async function listPlannerPlans(args: ExecuteServerSelectorArgs): Promise { + const plans = await drainGraph<{ id: string; title: string }>({ + args, + serviceId: 'microsoft-planner', + initialUrl: 'https://graph.microsoft.com/v1.0/me/planner/plans', + maxPages: 20, + }) + return plans.map((plan) => ({ id: plan.id, label: plan.title })) +} + +async function listPlannerTasks(args: ExecuteServerSelectorArgs): Promise { + const planId = requireGraphId(args.context.planId, 'planId') + const tasks = await drainGraph<{ id: string; title: string }>({ + args, + serviceId: 'microsoft-planner', + initialUrl: `https://graph.microsoft.com/v1.0/planner/plans/${encodeURIComponent(planId)}/tasks`, + maxPages: 20, + }) + return tasks.map((task) => ({ id: task.id, label: task.title })) +} + +async function listOutlookFolders(args: ExecuteServerSelectorArgs): Promise { + const folders = await drainGraph<{ id: string; displayName: string }>({ + args, + serviceId: 'outlook', + initialUrl: 'https://graph.microsoft.com/v1.0/me/mailFolders?$top=999', + maxPages: 20, + }) + return folders.map((folder) => ({ id: folder.id, label: folder.displayName })) +} + +async function listOutlookCalendars( + args: ExecuteServerSelectorArgs +): Promise { + const calendars = await drainGraph<{ id: string; name: string }>({ + args, + serviceId: 'outlook', + initialUrl: 'https://graph.microsoft.com/v1.0/me/calendars?$top=100', + maxPages: 10, + }) + return calendars.map((calendar) => ({ id: calendar.id, label: calendar.name })) +} + +async function listTeams(args: ExecuteServerSelectorArgs): Promise { + const teams = await drainGraph<{ id: string; displayName?: string }>({ + args, + serviceId: 'microsoft-teams', + initialUrl: 'https://graph.microsoft.com/v1.0/me/joinedTeams', + maxPages: 20, + }) + return teams.map((team) => ({ id: team.id, label: team.displayName || team.id })) +} + +async function listChannels(args: ExecuteServerSelectorArgs): Promise { + const teamId = requireGraphId(args.context.teamId, 'teamId') + const channels = await drainGraph<{ id: string; displayName?: string }>({ + args, + serviceId: 'microsoft-teams', + initialUrl: `https://graph.microsoft.com/v1.0/teams/${encodeURIComponent(teamId)}/channels`, + maxPages: 20, + }) + return channels.map((channel) => ({ id: channel.id, label: channel.displayName || channel.id })) +} + +async function chatDisplayName( + chat: { id: string; topic?: string }, + token: string, + signal?: AbortSignal +): Promise { + if (chat.topic?.trim() && chat.topic !== 'null') return chat.topic + const validation = validateMicrosoftGraphId(chat.id, 'chatId') + if (!validation.isValid) return `Chat ${chat.id.slice(0, 8)}...` + const headers = { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' } + try { + const members = await fetchProviderJson<{ value?: Array<{ displayName?: string }> }>( + `https://graph.microsoft.com/v1.0/chats/${encodeURIComponent(chat.id)}/members`, + { headers, signal, redirect: 'error' } + ) + const names = (members.value ?? []) + .flatMap((member) => + member.displayName && member.displayName !== 'Unknown' ? [member.displayName] : [] + ) + .slice(0, 3) + if (names.length === 1) return names[0] + if (names.length === 2) return names.join(' & ') + if (names.length > 2) return `${names.slice(0, 2).join(', ')} & ${names.length - 2} more` + } catch { + signal?.throwIfAborted() + // A label enrichment failure must not hide an otherwise selectable chat. + } + try { + const messages = await fetchProviderJson<{ + value?: Array<{ + eventDetail?: { chatDisplayName?: string } + from?: { user?: { displayName?: string } } + }> + }>( + `https://graph.microsoft.com/v1.0/chats/${encodeURIComponent(chat.id)}/messages?$top=10&$orderby=createdDateTime desc`, + { headers, signal, redirect: 'error' } + ) + for (const message of messages.value ?? []) { + if (message.eventDetail?.chatDisplayName) return message.eventDetail.chatDisplayName + } + const names = [ + ...new Set( + (messages.value ?? []).flatMap((message) => { + const name = message.from?.user?.displayName + return name && name !== 'Unknown' ? [name] : [] + }) + ), + ].slice(0, 3) + if (names.length === 1) return names[0] + if (names.length === 2) return names.join(' & ') + if (names.length > 2) return `${names.slice(0, 2).join(', ')} & ${names.length - 2} more` + } catch { + signal?.throwIfAborted() + // Fall through to the stable id-based label. + } + return `Chat ${chat.id.split(':')[0] || chat.id.slice(0, 8)}...` +} + +async function listChats(args: ExecuteServerSelectorArgs): Promise { + const token = await graphToken(args, 'microsoft-teams') + const chats = await drainGraph<{ id: string; topic?: string }>({ + args, + serviceId: 'microsoft-teams', + token, + initialUrl: 'https://graph.microsoft.com/v1.0/me/chats?$top=50', + maxPages: 20, + }) + return Promise.all( + chats.map(async (chat) => ({ + id: chat.id, + label: await chatDisplayName(chat, token, args.signal), + })) + ) +} + +interface DriveItem { + id: string + name: string + file?: { mimeType?: string } + folder?: Record + mimeType?: string +} + +async function listOneDriveFiles(args: ExecuteServerSelectorArgs): Promise { + const query = new URLSearchParams() + query.set( + '$select', + 'id,name,file,folder,webUrl,size,createdDateTime,lastModifiedDateTime,createdBy,thumbnails' + ) + query.set('$top', '999') + const files = await drainGraph({ + args, + serviceId: 'onedrive', + initialUrl: `https://graph.microsoft.com/v1.0/me/drive/root/children?${query}`, + maxPages: 20, + }) + return files + .filter((item) => item.file && !item.folder) + .map((item) => ({ id: item.id, label: item.name })) +} + +async function listOneDriveFolders(args: ExecuteServerSelectorArgs): Promise { + const driveId = requireDriveId(args.context.driveId) + const drivePath = driveId ? `drives/${encodeURIComponent(driveId)}` : 'me/drive' + const folders = await drainGraph({ + args, + serviceId: 'onedrive', + initialUrl: `https://graph.microsoft.com/v1.0/${drivePath}/root/children?$filter=folder ne null&$select=id,name,folder,webUrl,createdDateTime,lastModifiedDateTime&$top=999`, + maxPages: 20, + }) + return folders.filter((item) => item.folder).map((item) => ({ id: item.id, label: item.name })) +} + +async function listWorksheets(args: ExecuteServerSelectorArgs): Promise { + const spreadsheetId = requireGraphId(args.context.spreadsheetId, 'spreadsheetId') + const driveId = requireDriveId(args.context.driveId) + let basePath: string + try { + basePath = getItemBasePath(spreadsheetId, driveId) + } catch { + throw new SelectorContextUnavailableError() + } + const token = await graphToken(args, 'microsoft-excel') + const data = await fetchProviderJson<{ + value?: Array<{ id: string; name: string; position: number }> + }>(`${basePath}/workbook/worksheets`, { + headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, + signal: args.signal, + redirect: 'error', + }) + return (data.value ?? []) + .sort((left, right) => left.position - right.position) + .map((sheet) => ({ id: sheet.name, label: sheet.name })) +} + +function requireSiteId(value: string | undefined): string { + if (!value) throw new SelectorContextUnavailableError() + const validation = validateSharePointSiteId(value, 'siteId') + if (!validation.isValid) throw new SelectorContextUnavailableError() + return validation.sanitized ?? value +} + +async function executeDrives(args: ExecuteServerSelectorArgs) { + const siteId = requireSiteId(args.context.siteId) + const token = await graphToken(args, 'microsoft-excel') + if (args.request.kind === 'detail') { + const driveId = requireDriveId(args.request.id) + if (!driveId) throw new SelectorContextUnavailableError() + const drive = await fetchProviderJson<{ id: string; name: string }>( + `https://graph.microsoft.com/v1.0/sites/${encodeURIComponent(siteId)}/drives/${encodeURIComponent(driveId)}?$select=id,name,driveType,webUrl`, + { headers: { Authorization: `Bearer ${token}` }, signal: args.signal, redirect: 'error' } + ) + return flatSelectorResult(args.request, [{ id: drive.id, label: drive.name }], true) + } + const drives = await drainGraph<{ id: string; name: string }>({ + args, + serviceId: 'microsoft-excel', + token, + initialUrl: `https://graph.microsoft.com/v1.0/sites/${encodeURIComponent(siteId)}/drives?$select=id,name,driveType,webUrl&$top=999`, + maxPages: 10, + }) + return flatSelectorResult( + args.request, + drives.map((drive) => ({ id: drive.id, label: drive.name })), + true + ) +} + +const OFFICE_FILE_TYPES = { + excel: { + extension: '.xlsx', + mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + serviceId: 'microsoft-excel', + }, + word: { + extension: '.docx', + mimeType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + serviceId: 'microsoft-word', + }, +} as const + +async function listOfficeFiles( + args: ExecuteServerSelectorArgs, + fileType: keyof typeof OFFICE_FILE_TYPES +): Promise { + const config = OFFICE_FILE_TYPES[fileType] + const driveId = requireDriveId(args.context.driveId) + const drivePath = driveId ? `drives/${encodeURIComponent(driveId)}` : 'me/drive' + const search = args.request.kind === 'list' ? (args.request.search ?? '') : '' + const searchQuery = search ? `${search} ${config.extension}` : config.extension + const params = new URLSearchParams() + params.set( + '$select', + 'id,name,mimeType,webUrl,thumbnails,createdDateTime,lastModifiedDateTime,size,createdBy' + ) + params.set('$top', '999') + const files = await drainGraph({ + args, + serviceId: config.serviceId, + initialUrl: `https://graph.microsoft.com/v1.0/${drivePath}/root/search(q='${encodeGraphSearch(searchQuery)}')?${params}`, + maxPages: 20, + }) + return files + .filter( + (file) => + file.name?.toLowerCase().endsWith(config.extension) || file.mimeType === config.mimeType + ) + .map((file) => ({ id: file.id, label: file.name })) +} + +const plannerCredential = microsoftCredential('microsoft-planner') +const outlookCredential = microsoftCredential('outlook') +const teamsCredential = microsoftCredential('microsoft-teams') +const oneDriveCredential = microsoftCredential('onedrive') +const excelCredential = microsoftCredential('microsoft-excel') +const wordCredential = microsoftCredential('microsoft-word') + +export const microsoftSelectorAttachments = { + 'microsoft.planner.plans': { + credential: plannerCredential, + destination: 'fixed', + execute: async (args) => flatSelectorResult(args.request, await listPlannerPlans(args), true), + }, + 'microsoft.planner': { + credential: plannerCredential, + destination: 'fixed', + execute: async (args) => flatSelectorResult(args.request, await listPlannerTasks(args), true), + }, + 'outlook.folders': { + credential: outlookCredential, + destination: 'fixed', + execute: async (args) => flatSelectorResult(args.request, await listOutlookFolders(args)), + }, + 'outlook.calendars': { + credential: outlookCredential, + destination: 'fixed', + execute: async (args) => flatSelectorResult(args.request, await listOutlookCalendars(args)), + }, + 'microsoft.teams': { + credential: teamsCredential, + destination: 'fixed', + execute: async (args) => flatSelectorResult(args.request, await listTeams(args)), + }, + 'microsoft.chats': { + credential: teamsCredential, + destination: 'fixed', + execute: async (args) => flatSelectorResult(args.request, await listChats(args)), + }, + 'microsoft.channels': { + credential: teamsCredential, + destination: 'fixed', + execute: async (args) => flatSelectorResult(args.request, await listChannels(args)), + }, + 'onedrive.files': { + credential: oneDriveCredential, + destination: 'fixed', + execute: async (args) => flatSelectorResult(args.request, await listOneDriveFiles(args)), + }, + 'onedrive.folders': { + credential: oneDriveCredential, + destination: 'fixed', + execute: async (args) => flatSelectorResult(args.request, await listOneDriveFolders(args)), + }, + 'microsoft.excel.sheets': { + credential: excelCredential, + destination: 'fixed', + execute: async (args) => flatSelectorResult(args.request, await listWorksheets(args)), + }, + 'microsoft.excel.drives': { + credential: excelCredential, + destination: 'fixed', + execute: executeDrives, + }, + 'microsoft.excel': { + credential: excelCredential, + destination: 'fixed', + execute: async (args) => flatSelectorResult(args.request, await listOfficeFiles(args, 'excel')), + }, + 'microsoft.word': { + credential: wordCredential, + destination: 'fixed', + execute: async (args) => flatSelectorResult(args.request, await listOfficeFiles(args, 'word')), + }, +} satisfies ServerSelectorAttachmentMap diff --git a/apps/sim/lib/selectors/server/providers/monday.ts b/apps/sim/lib/selectors/server/providers/monday.ts new file mode 100644 index 00000000000..a25cf2a6fee --- /dev/null +++ b/apps/sim/lib/selectors/server/providers/monday.ts @@ -0,0 +1,104 @@ +import { validateMondayNumericId } from '@/lib/core/security/input-validation' +import type { ServerSelectorKey } from '@/lib/selectors/manifest' +import { resolveSelectorOAuthAccessToken } from '@/lib/selectors/server/credentials' +import { + SelectorContextUnavailableError, + SelectorOptionsUnavailableError, +} from '@/lib/selectors/server/errors' +import { flatSelectorResult } from '@/lib/selectors/server/providers/flat-results' +import { fetchProviderJson } from '@/lib/selectors/server/providers/provider-http' +import type { + ExecuteServerSelectorArgs, + ServerSelectorAttachmentMap, +} from '@/lib/selectors/server/types' +import type { SafeSelectorOption } from '@/lib/selectors/types' +import { MONDAY_API_URL, mondayHeaders } from '@/tools/monday/utils' + +type MondaySelectorKey = Extract + +const credential = { + kind: 'stored', + field: 'oauthCredential', + serviceIds: ['monday'], +} as const + +const PAGE_SIZE = 100 +const MAX_PAGES = 50 + +interface MondayResponse { + errors?: Array<{ message?: string }> + error_message?: string + data?: T +} + +function requireMondayData(response: MondayResponse): T | undefined { + if (response.errors?.length || response.error_message) { + throw new SelectorOptionsUnavailableError() + } + return response.data +} + +async function accessToken(args: ExecuteServerSelectorArgs): Promise { + if (!args.credential) throw new SelectorOptionsUnavailableError() + return resolveSelectorOAuthAccessToken({ + credential: args.credential, + serviceId: 'monday', + protectedValues: args.protectedValues, + }) +} + +async function listBoards(args: ExecuteServerSelectorArgs): Promise { + const token = await accessToken(args) + const items: SafeSelectorOption[] = [] + for (let page = 1; page <= MAX_PAGES; page++) { + const response = await fetchProviderJson< + MondayResponse<{ boards?: Array<{ id: string; name: string }> }> + >(MONDAY_API_URL, { + method: 'POST', + headers: mondayHeaders(token), + body: JSON.stringify({ + query: `{ boards(limit: ${PAGE_SIZE}, page: ${page}, state: active) { id name } }`, + }), + signal: args.signal, + redirect: 'error', + }) + const boards = requireMondayData(response)?.boards ?? [] + items.push(...boards.map((board) => ({ id: board.id, label: board.name }))) + if (boards.length < PAGE_SIZE) break + } + return items +} + +async function listGroups(args: ExecuteServerSelectorArgs): Promise { + const boardId = args.context.boardId + if (!boardId) throw new SelectorContextUnavailableError() + const validated = validateMondayNumericId(boardId, 'boardId') + if (!validated.isValid) throw new SelectorContextUnavailableError() + const token = await accessToken(args) + const response = await fetchProviderJson< + MondayResponse<{ boards?: Array<{ groups?: Array<{ id: string; title: string }> }> }> + >(MONDAY_API_URL, { + method: 'POST', + headers: mondayHeaders(token), + body: JSON.stringify({ + query: `{ boards(ids: [${validated.sanitized}]) { groups { id title } } }`, + }), + signal: args.signal, + redirect: 'error', + }) + const groups = requireMondayData(response)?.boards?.[0]?.groups ?? [] + return groups.map((group) => ({ id: group.id, label: group.title })) +} + +export const mondaySelectorAttachments = { + 'monday.boards': { + credential, + destination: 'fixed', + execute: async (args) => flatSelectorResult(args.request, await listBoards(args), true), + }, + 'monday.groups': { + credential, + destination: 'fixed', + execute: async (args) => flatSelectorResult(args.request, await listGroups(args), true), + }, +} satisfies ServerSelectorAttachmentMap diff --git a/apps/sim/lib/selectors/server/providers/netsuite.ts b/apps/sim/lib/selectors/server/providers/netsuite.ts new file mode 100644 index 00000000000..316e8b27bf6 --- /dev/null +++ b/apps/sim/lib/selectors/server/providers/netsuite.ts @@ -0,0 +1,242 @@ +import { isPlainRecord } from '@sim/utils/object' +import { NETSUITE_SERVICE_ACCOUNT_PROVIDER_ID } from '@/lib/credentials/client-credential-accounts/descriptors' +import { resolveOAuthAccountId } from '@/lib/oauth/credential-service' +import type { ServerSelectorKey } from '@/lib/selectors/manifest' +import { + SelectorConnectionUnavailableError, + SelectorContextUnavailableError, + SelectorOptionsUnavailableError, +} from '@/lib/selectors/server/errors' +import { resolveSelectorCredentialBundle } from '@/lib/selectors/server/providers/credential-bundle' +import { flatSelectorResult } from '@/lib/selectors/server/providers/flat-results' +import type { + ExecuteServerSelectorArgs, + ServerSelectorAttachmentMap, +} from '@/lib/selectors/server/types' +import type { SafeSelectorOption } from '@/lib/selectors/types' +import { netsuiteGetAsyncStatusTool } from '@/tools/netsuite/get_async_status' +import { netsuiteListRecordTypesTool } from '@/tools/netsuite/list_record_types' +import type { NetSuiteAuthParams } from '@/tools/netsuite/types' +import { normalizeSuiteTalkUrl } from '@/tools/netsuite/utils' +import type { ToolResponse } from '@/tools/types' + +type NetSuiteSelectorKey = Extract< + ServerSelectorKey, + 'netsuite.recordTypes' | 'netsuite.asyncTasks' +> + +type NetSuiteSelectorKind = 'record_types' | 'async_tasks' + +const NETSUITE_SELECTOR_KIND = { + 'netsuite.recordTypes': 'record_types', + 'netsuite.asyncTasks': 'async_tasks', +} as const satisfies Record + +const MAX_RECORD_TYPES = 1_000 +const MAX_ASYNC_TASKS = 100 +const MAX_ID_LENGTH = 512 + +interface NetSuiteSelectorObject { + id: string + label: string + detail: string | null +} + +function requireString(value: unknown, maxLength: number): string { + if (typeof value !== 'string' || !value.trim()) throw new SelectorOptionsUnavailableError() + const normalized = value.trim() + if (normalized.length > maxLength) throw new SelectorOptionsUnavailableError() + return normalized +} + +function requireItems(data: unknown): Record[] { + if (!isPlainRecord(data) || !Array.isArray(data.items) || !data.items.every(isPlainRecord)) { + throw new SelectorOptionsUnavailableError() + } + return data.items +} + +function dedupeAndSort(objects: NetSuiteSelectorObject[]): NetSuiteSelectorObject[] { + const unique = new Map() + for (const object of objects) { + if (!unique.has(object.id)) unique.set(object.id, object) + } + return [...unique.values()].sort( + (left, right) => left.label.localeCompare(right.label) || left.id.localeCompare(right.id) + ) +} + +function normalizeRecordTypes(data: unknown): NetSuiteSelectorObject[] { + const objects: NetSuiteSelectorObject[] = [] + const names = new Set() + for (const item of requireItems(data)) { + const name = requireString(item.name, MAX_ID_LENGTH) + if (names.has(name)) continue + if (names.size >= MAX_RECORD_TYPES) throw new SelectorOptionsUnavailableError() + names.add(name) + objects.push({ id: name, label: name, detail: null }) + } + return dedupeAndSort(objects) +} + +function taskIdFromHref(href: unknown, origin: string, jobId: string): string { + const hrefValue = requireString(href, 4_096) + let url: URL + try { + url = new URL(hrefValue, origin) + } catch { + throw new SelectorOptionsUnavailableError() + } + if ( + url.protocol !== 'https:' || + url.origin !== origin || + url.username || + url.password || + url.search || + url.hash + ) { + throw new SelectorOptionsUnavailableError() + } + + const match = url.pathname.match(/^\/services\/rest\/async\/v1\/job\/([^/]+)\/task\/([^/]+)$/) + if (!match?.[1] || !match[2]) throw new SelectorOptionsUnavailableError() + + let linkedJobId: string + let taskId: string + try { + linkedJobId = decodeURIComponent(match[1]) + taskId = decodeURIComponent(match[2]) + } catch { + throw new SelectorOptionsUnavailableError() + } + if (linkedJobId !== jobId || !taskId || taskId.length > MAX_ID_LENGTH) { + throw new SelectorOptionsUnavailableError() + } + + const canonicalPath = `/services/rest/async/v1/job/${encodeURIComponent(linkedJobId)}/task/${encodeURIComponent(taskId)}` + if ( + url.pathname !== canonicalPath || + (hrefValue !== canonicalPath && hrefValue !== `${origin}${canonicalPath}`) + ) { + throw new SelectorOptionsUnavailableError() + } + return taskId +} + +function normalizeAsyncTasks( + data: unknown, + instanceUrl: string, + jobId: string +): NetSuiteSelectorObject[] { + const origin = normalizeSuiteTalkUrl(instanceUrl) + const objects = new Map() + for (const item of requireItems(data)) { + if (!Array.isArray(item.links) || item.links.length === 0 || !item.links.every(isPlainRecord)) { + throw new SelectorOptionsUnavailableError() + } + const selfLinks = item.links.filter((link) => link.rel === 'self') + if (selfLinks.length === 0) throw new SelectorOptionsUnavailableError() + for (const link of selfLinks) { + const id = taskIdFromHref(link.href, origin, jobId) + if (objects.has(id)) continue + if (objects.size >= MAX_ASYNC_TASKS) throw new SelectorOptionsUnavailableError() + objects.set(id, { id, label: id, detail: null }) + } + } + return dedupeAndSort([...objects.values()]) +} + +function requireJobId(args: ExecuteServerSelectorArgs): string { + const jobId = args.context.jobId?.trim() + if (!jobId || jobId.length > MAX_ID_LENGTH) throw new SelectorContextUnavailableError() + return jobId +} + +async function requireNetSuiteServiceAccount(args: ExecuteServerSelectorArgs): Promise { + const credential = args.credential + const access = credential?.access + if (!credential || !access?.resolvedCredentialId || access.credentialType !== 'service_account') { + throw new SelectorConnectionUnavailableError() + } + const resolved = await resolveOAuthAccountId(access.resolvedCredentialId) + if ( + resolved?.credentialType !== 'service_account' || + resolved.providerId !== NETSUITE_SERVICE_ACCOUNT_PROVIDER_ID + ) { + throw new SelectorConnectionUnavailableError() + } + return access.resolvedCredentialId +} + +async function executeDiscoveryTool( + kind: NetSuiteSelectorKind, + args: ExecuteServerSelectorArgs, + auth: NetSuiteAuthParams, + jobId?: string +): Promise { + if (args.signal?.aborted) throw args.signal.reason + if (kind === 'record_types') { + const execute = netsuiteListRecordTypesTool.directExecution + if (!execute) throw new SelectorOptionsUnavailableError() + return execute(auth, args.signal) + } + const execute = netsuiteGetAsyncStatusTool.directExecution + if (!execute || !jobId) throw new SelectorOptionsUnavailableError() + return execute({ ...auth, jobId, view: 'tasks' }, args.signal) +} + +function toOptions(objects: NetSuiteSelectorObject[]): SafeSelectorOption[] { + return objects.map((object) => ({ + id: object.id, + label: object.label, + ...(object.detail ? { meta: { detail: object.detail } } : {}), + })) +} + +async function executeNetSuite(args: ExecuteServerSelectorArgs) { + const kind = NETSUITE_SELECTOR_KIND[args.selectorKey as NetSuiteSelectorKey] + if (!kind) throw new SelectorOptionsUnavailableError() + const resolvedCredentialId = await requireNetSuiteServiceAccount(args) + if (!args.credential) throw new SelectorConnectionUnavailableError() + const token = await resolveSelectorCredentialBundle({ + credential: args.credential, + protectedValues: args.protectedValues, + }) + if (!token.instanceUrl) throw new SelectorConnectionUnavailableError() + const jobId = kind === 'async_tasks' ? requireJobId(args) : undefined + const result = await executeDiscoveryTool( + kind, + args, + { + oauthCredential: resolvedCredentialId, + accessToken: token.accessToken, + instanceUrl: token.instanceUrl, + }, + jobId + ) + if (!result.success) throw new SelectorOptionsUnavailableError() + const objects = + kind === 'record_types' + ? normalizeRecordTypes(result.output.data) + : normalizeAsyncTasks(result.output.data, token.instanceUrl, jobId as string) + return flatSelectorResult(args.request, toOptions(objects), true) +} + +const credential = { + kind: 'stored', + field: 'oauthCredential', + serviceIds: ['netsuite'], +} as const + +export const netsuiteSelectorAttachments = { + 'netsuite.recordTypes': { + credential, + destination: 'credential-bound', + execute: executeNetSuite, + }, + 'netsuite.asyncTasks': { + credential, + destination: 'credential-bound', + execute: executeNetSuite, + }, +} satisfies ServerSelectorAttachmentMap diff --git a/apps/sim/lib/selectors/server/providers/notion.ts b/apps/sim/lib/selectors/server/providers/notion.ts new file mode 100644 index 00000000000..ca0972af15a --- /dev/null +++ b/apps/sim/lib/selectors/server/providers/notion.ts @@ -0,0 +1,85 @@ +import type { ServerSelectorKey } from '@/lib/selectors/manifest' +import { resolveSelectorOAuthAccessToken } from '@/lib/selectors/server/credentials' +import { SelectorOptionsUnavailableError } from '@/lib/selectors/server/errors' +import { flatSelectorResult } from '@/lib/selectors/server/providers/flat-results' +import { fetchProviderJson } from '@/lib/selectors/server/providers/provider-http' +import type { + ExecuteServerSelectorArgs, + ServerSelectorAttachmentMap, +} from '@/lib/selectors/server/types' +import type { SafeSelectorOption } from '@/lib/selectors/types' +import { extractTitleFromItem } from '@/tools/notion/utils' + +type NotionSelectorKey = Extract + +const credential = { + kind: 'stored', + field: 'oauthCredential', + serviceIds: ['notion'], +} as const + +const PAGE_SIZE = 100 +const MAX_PAGES = 20 + +interface NotionSearchPage { + results?: unknown[] + has_more?: boolean + next_cursor?: string | null +} + +async function listNotionObjects( + args: ExecuteServerSelectorArgs, + object: 'database' | 'page' +): Promise { + if (!args.credential) throw new SelectorOptionsUnavailableError() + const token = await resolveSelectorOAuthAccessToken({ + credential: args.credential, + serviceId: 'notion', + protectedValues: args.protectedValues, + }) + const results: unknown[] = [] + let cursor: string | undefined + + for (let page = 0; page < MAX_PAGES; page++) { + const data = await fetchProviderJson('https://api.notion.com/v1/search', { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + 'Notion-Version': '2022-06-28', + }, + body: JSON.stringify({ + filter: { value: object, property: 'object' }, + page_size: PAGE_SIZE, + ...(cursor ? { start_cursor: cursor } : {}), + }), + signal: args.signal, + redirect: 'error', + }) + if (Array.isArray(data.results)) results.push(...data.results) + if (!data.has_more || !data.next_cursor) break + cursor = data.next_cursor + } + + return results.flatMap((value) => { + if (!value || typeof value !== 'object' || typeof (value as { id?: unknown }).id !== 'string') { + return [] + } + return [{ id: (value as { id: string }).id, label: extractTitleFromItem(value) }] + }) +} + +export const notionSelectorAttachments = { + 'notion.databases': { + credential, + destination: 'fixed', + execute: async (args) => + flatSelectorResult(args.request, await listNotionObjects(args, 'database'), true), + }, + 'notion.pages': { + credential, + destination: 'fixed', + execute: async (args) => + flatSelectorResult(args.request, await listNotionObjects(args, 'page'), true), + }, +} satisfies ServerSelectorAttachmentMap diff --git a/apps/sim/lib/selectors/server/providers/pipedrive.ts b/apps/sim/lib/selectors/server/providers/pipedrive.ts new file mode 100644 index 00000000000..d9bcdb4211d --- /dev/null +++ b/apps/sim/lib/selectors/server/providers/pipedrive.ts @@ -0,0 +1,59 @@ +import type { ServerSelectorKey } from '@/lib/selectors/manifest' +import { SelectorConnectionUnavailableError } from '@/lib/selectors/server/errors' +import { resolveSelectorCredentialBundle } from '@/lib/selectors/server/providers/credential-bundle' +import { flatSelectorResult } from '@/lib/selectors/server/providers/flat-results' +import { fetchProviderJson } from '@/lib/selectors/server/providers/provider-http' +import type { ServerSelectorAttachmentMap } from '@/lib/selectors/server/types' +import type { SafeSelectorOption } from '@/lib/selectors/types' +import { getPipedriveAuthHeaders } from '@/tools/pipedrive/utils' + +type PipedriveSelectorKey = Extract + +interface PipedrivePage { + data?: Array<{ id: number | string; name: string }> + additional_data?: { + pagination?: { more_items_in_collection?: boolean; next_start?: number } + } +} + +export const pipedriveSelectorAttachments = { + 'pipedrive.pipelines': { + credential: { + kind: 'stored', + field: 'oauthCredential', + serviceIds: ['pipedrive'], + }, + destination: 'fixed', + execute: async (args) => { + if (!args.credential) throw new SelectorConnectionUnavailableError() + const token = await resolveSelectorCredentialBundle({ + credential: args.credential, + protectedValues: args.protectedValues, + }) + const items: SafeSelectorOption[] = [] + let start = 0 + for (let page = 0; page < 50; page++) { + const url = new URL('https://api.pipedrive.com/v1/pipelines') + url.searchParams.set('start', String(start)) + url.searchParams.set('limit', '500') + const data = await fetchProviderJson(url, { + headers: getPipedriveAuthHeaders({ + accessToken: token.accessToken, + authStyle: token.authStyle, + }), + signal: args.signal, + redirect: 'error', + }) + for (const pipeline of data.data ?? []) { + items.push({ id: String(pipeline.id), label: pipeline.name }) + } + const pagination = data.additional_data?.pagination + if (!pagination?.more_items_in_collection || typeof pagination.next_start !== 'number') { + break + } + start = pagination.next_start + } + return flatSelectorResult(args.request, items, true) + }, + }, +} satisfies ServerSelectorAttachmentMap diff --git a/apps/sim/lib/selectors/server/providers/provider-http.ts b/apps/sim/lib/selectors/server/providers/provider-http.ts new file mode 100644 index 00000000000..a9f494ce9e7 --- /dev/null +++ b/apps/sim/lib/selectors/server/providers/provider-http.ts @@ -0,0 +1,58 @@ +import { SelectorOptionsUnavailableError } from '@/lib/selectors/server/errors' + +const PROVIDER_TIMEOUT_MS = 30_000 +const MAX_PROVIDER_RESPONSE_BYTES = 16 * 1024 * 1024 + +async function readBoundedBody(response: Response): Promise { + const declaredLength = Number(response.headers.get('content-length')) + if (Number.isFinite(declaredLength) && declaredLength > MAX_PROVIDER_RESPONSE_BYTES) { + throw new SelectorOptionsUnavailableError() + } + if (!response.body) return '' + + const reader = response.body.getReader() + const chunks: Uint8Array[] = [] + let total = 0 + while (true) { + const { done, value } = await reader.read() + if (done) break + total += value.byteLength + if (total > MAX_PROVIDER_RESPONSE_BYTES) { + await reader.cancel() + throw new SelectorOptionsUnavailableError() + } + chunks.push(value) + } + + const body = new Uint8Array(total) + let offset = 0 + for (const chunk of chunks) { + body.set(chunk, offset) + offset += chunk.byteLength + } + return new TextDecoder().decode(body) +} + +export async function fetchProviderJson( + input: RequestInfo | URL, + init?: RequestInit +): Promise { + let response: Response + const timeoutSignal = AbortSignal.timeout(PROVIDER_TIMEOUT_MS) + const signal = init?.signal ? AbortSignal.any([init.signal, timeoutSignal]) : timeoutSignal + try { + response = await fetch(input, { ...init, signal, redirect: init?.redirect ?? 'error' }) + } catch (error) { + if (init?.signal?.aborted) throw error + throw new SelectorOptionsUnavailableError() + } + + if (!response.ok) throw new SelectorOptionsUnavailableError() + try { + return JSON.parse(await readBoundedBody(response)) as T + } catch (error) { + if (init?.signal?.aborted) throw error + if (error instanceof SelectorOptionsUnavailableError) throw error + throw new SelectorOptionsUnavailableError() + } +} diff --git a/apps/sim/lib/selectors/server/providers/sharepoint.ts b/apps/sim/lib/selectors/server/providers/sharepoint.ts new file mode 100644 index 00000000000..0a93b84773a --- /dev/null +++ b/apps/sim/lib/selectors/server/providers/sharepoint.ts @@ -0,0 +1,88 @@ +import { validateSharePointSiteId } from '@/lib/core/security/input-validation' +import type { ServerSelectorKey } from '@/lib/selectors/manifest' +import { resolveSelectorOAuthAccessToken } from '@/lib/selectors/server/credentials' +import { + SelectorConnectionUnavailableError, + SelectorContextUnavailableError, +} from '@/lib/selectors/server/errors' +import { flatSelectorResult } from '@/lib/selectors/server/providers/flat-results' +import { fetchProviderJson } from '@/lib/selectors/server/providers/provider-http' +import type { + ExecuteServerSelectorArgs, + ServerSelectorAttachmentMap, +} from '@/lib/selectors/server/types' +import type { SafeSelectorOption } from '@/lib/selectors/types' +import { assertGraphNextPageUrl, getGraphNextPageUrl } from '@/tools/sharepoint/utils' + +type SharePointSelectorKey = Extract + +const credential = { + kind: 'stored', + field: 'oauthCredential', + serviceIds: ['sharepoint'], +} as const + +async function graphToken(args: ExecuteServerSelectorArgs): Promise { + if (!args.credential) throw new SelectorConnectionUnavailableError() + return resolveSelectorOAuthAccessToken({ + credential: args.credential, + serviceId: 'sharepoint', + protectedValues: args.protectedValues, + }) +} + +async function drainGraph(args: ExecuteServerSelectorArgs, initialUrl: string): Promise { + const token = await graphToken(args) + const values: T[] = [] + let nextUrl: string | undefined = initialUrl + for (let page = 0; page < 10 && nextUrl; page++) { + const data = await fetchProviderJson<{ value?: T[] } & Record>(nextUrl, { + headers: { Authorization: `Bearer ${token}` }, + signal: args.signal, + redirect: 'error', + }) + if (Array.isArray(data.value)) values.push(...data.value) + const nextLink = getGraphNextPageUrl(data) + nextUrl = nextLink ? assertGraphNextPageUrl(nextLink) : undefined + } + return values +} + +async function listLists(args: ExecuteServerSelectorArgs): Promise { + const siteId = args.context.siteId + if (!siteId) throw new SelectorContextUnavailableError() + const validation = validateSharePointSiteId(siteId) + if (!validation.isValid) throw new SelectorContextUnavailableError() + const lists = await drainGraph<{ + id: string + displayName: string + list?: { hidden?: boolean } + }>( + args, + `https://graph.microsoft.com/v1.0/sites/${validation.sanitized}/lists?$select=id,displayName,description,webUrl&$expand=list($select=hidden)&$top=999` + ) + return lists + .filter((list) => list.list?.hidden !== true) + .map((list) => ({ id: list.id, label: list.displayName })) +} + +async function listSites(args: ExecuteServerSelectorArgs): Promise { + const sites = await drainGraph<{ id: string; name: string; displayName?: string }>( + args, + 'https://graph.microsoft.com/v1.0/sites?search=*&$select=id,name,displayName,webUrl,createdDateTime,lastModifiedDateTime&$top=999' + ) + return sites.map((site) => ({ id: site.id, label: site.displayName || site.name })) +} + +export const sharepointSelectorAttachments = { + 'sharepoint.lists': { + credential, + destination: 'fixed', + execute: async (args) => flatSelectorResult(args.request, await listLists(args), true), + }, + 'sharepoint.sites': { + credential, + destination: 'fixed', + execute: async (args) => flatSelectorResult(args.request, await listSites(args), true), + }, +} satisfies ServerSelectorAttachmentMap diff --git a/apps/sim/lib/selectors/server/providers/slack.ts b/apps/sim/lib/selectors/server/providers/slack.ts new file mode 100644 index 00000000000..de34814373f --- /dev/null +++ b/apps/sim/lib/selectors/server/providers/slack.ts @@ -0,0 +1,224 @@ +import { db } from '@sim/db' +import { account } from '@sim/db/schema' +import { eq } from 'drizzle-orm' +import { validateAlphanumericId } from '@/lib/core/security/input-validation' +import type { ServerSelectorKey } from '@/lib/selectors/manifest' +import { resolveSelectorOAuthAccessToken } from '@/lib/selectors/server/credentials' +import { + SelectorConnectionUnavailableError, + SelectorOptionsUnavailableError, +} from '@/lib/selectors/server/errors' +import { flatSelectorResult } from '@/lib/selectors/server/providers/flat-results' +import type { + ExecuteServerSelectorArgs, + ServerSelectorAttachmentMap, +} from '@/lib/selectors/server/types' +import type { SafeSelectorOption } from '@/lib/selectors/types' + +type SlackSelectorKey = Extract +type SlackMethod = 'conversations.list' | 'users.conversations' | 'users.list' + +const SLACK_PAGE_LIMIT = 200 +const SLACK_MAX_PAGES = 10 +const SCOPED_USER_ID_PATTERN = + /-usr_([UW][A-Z0-9]+)-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + +interface SlackApiResponse { + ok?: boolean + error?: string + channels?: SlackChannel[] + members?: SlackUser[] + response_metadata?: { next_cursor?: string } +} + +interface SlackChannel { + id?: string + name?: string + is_private?: boolean + is_archived?: boolean + is_member?: boolean +} + +interface SlackUser { + id?: string + name?: string + real_name?: string + deleted?: boolean + is_bot?: boolean +} + +function parseScopedSlackUserId(accountId: string): string | null { + return SCOPED_USER_ID_PATTERN.exec(accountId)?.[1] ?? null +} + +async function readScopedSlackUserId(args: ExecuteServerSelectorArgs): Promise { + const access = args.credential?.access + if (access?.credentialType !== 'oauth' || !access.resolvedCredentialId) return null + const [row] = await db + .select({ accountId: account.accountId }) + .from(account) + .where(eq(account.id, access.resolvedCredentialId)) + .limit(1) + return row ? parseScopedSlackUserId(row.accountId) : null +} + +async function fetchSlackApi( + args: ExecuteServerSelectorArgs, + method: SlackMethod, + accessToken: string, + params: Record +): Promise { + const url = new URL(`https://slack.com/api/${method}`) + for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value) + + let response: Response + try { + response = await fetch(url, { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + signal: args.signal, + redirect: 'error', + }) + } catch (error) { + if (args.signal?.aborted) throw error + throw new SelectorOptionsUnavailableError() + } + if (!response.ok) throw new SelectorOptionsUnavailableError() + let data: SlackApiResponse + try { + data = (await response.json()) as SlackApiResponse + } catch { + throw new SelectorOptionsUnavailableError() + } + if (!data.ok) throw new SelectorOptionsUnavailableError() + return data +} + +async function fetchAllConversations( + args: ExecuteServerSelectorArgs, + method: 'conversations.list' | 'users.conversations', + accessToken: string, + params: Record +): Promise { + const channels: SlackChannel[] = [] + let cursor: string | undefined + for (let page = 0; page < SLACK_MAX_PAGES; page++) { + const data = await fetchSlackApi(args, method, accessToken, { + ...params, + limit: String(SLACK_PAGE_LIMIT), + ...(cursor ? { cursor } : {}), + }) + if (Array.isArray(data.channels)) channels.push(...data.channels) + cursor = data.response_metadata?.next_cursor?.trim() || undefined + if (!cursor) break + } + return channels +} + +async function fetchChannels( + args: ExecuteServerSelectorArgs, + accessToken: string, + includePrivate: boolean +): Promise { + return fetchAllConversations(args, 'conversations.list', accessToken, { + types: includePrivate ? 'public_channel,private_channel' : 'public_channel', + exclude_archived: 'true', + }) +} + +async function listSlackChannels(args: ExecuteServerSelectorArgs): Promise { + if (!args.credential) throw new SelectorConnectionUnavailableError() + const accessToken = await resolveSelectorOAuthAccessToken({ + credential: args.credential, + serviceId: 'slack', + protectedValues: args.protectedValues, + }) + const isBotCredential = + Boolean(args.credential.fixedToken) || args.credential.access?.credentialType !== 'oauth' + const scopedUserId = await readScopedSlackUserId(args) + + let channels: SlackChannel[] + try { + channels = await fetchChannels(args, accessToken, true) + } catch (error) { + if (!isBotCredential) throw error + channels = await fetchChannels(args, accessToken, false) + } + + let allowedPrivateChannelIds: Set | null = null + if (scopedUserId) { + try { + const scopedChannels = await fetchAllConversations(args, 'users.conversations', accessToken, { + user: scopedUserId, + types: 'private_channel', + exclude_archived: 'true', + }) + allowedPrivateChannelIds = new Set( + scopedChannels.flatMap((channel) => (channel.id ? [channel.id] : [])) + ) + } catch { + // If user membership cannot be verified, fail closed for private channels. + allowedPrivateChannelIds = new Set() + } + } + + return channels.flatMap((channel) => { + if (!channel.id || !channel.name || channel.is_archived) return [] + if ( + channel.is_private && + (allowedPrivateChannelIds ? !allowedPrivateChannelIds.has(channel.id) : !channel.is_member) + ) { + return [] + } + const validation = validateAlphanumericId(channel.id, 'channelId', 50) + if (!validation.isValid || !/^[CDG][A-Z0-9]+$/i.test(channel.id)) return [] + return [{ id: channel.id, label: `#${channel.name}` }] + }) +} + +async function listSlackUsers(args: ExecuteServerSelectorArgs): Promise { + if (!args.credential) throw new SelectorConnectionUnavailableError() + const accessToken = await resolveSelectorOAuthAccessToken({ + credential: args.credential, + serviceId: 'slack', + protectedValues: args.protectedValues, + }) + const members: SlackUser[] = [] + let cursor: string | undefined + for (let page = 0; page < SLACK_MAX_PAGES; page++) { + const data = await fetchSlackApi(args, 'users.list', accessToken, { + limit: String(SLACK_PAGE_LIMIT), + ...(cursor ? { cursor } : {}), + }) + if (Array.isArray(data.members)) members.push(...data.members) + cursor = data.response_metadata?.next_cursor?.trim() || undefined + if (!cursor) break + } + return members.flatMap((user) => { + if (!user.id || !user.name || user.deleted || user.is_bot) return [] + return [{ id: user.id, label: user.real_name || user.name }] + }) +} + +const credential = { + kind: 'stored-or-fixed-token', + field: 'oauthCredential', + serviceIds: ['slack'], + tokenPrefixes: ['xoxb-'], +} as const + +export const slackSelectorAttachments = { + 'slack.channels': { + credential, + destination: 'fixed', + execute: async (args) => flatSelectorResult(args.request, await listSlackChannels(args)), + }, + 'slack.users': { + credential, + destination: 'fixed', + execute: async (args) => flatSelectorResult(args.request, await listSlackUsers(args)), + }, +} satisfies ServerSelectorAttachmentMap diff --git a/apps/sim/lib/selectors/server/providers/snowflake.ts b/apps/sim/lib/selectors/server/providers/snowflake.ts new file mode 100644 index 00000000000..5a766c032a8 --- /dev/null +++ b/apps/sim/lib/selectors/server/providers/snowflake.ts @@ -0,0 +1,178 @@ +import type { ServerSelectorKey } from '@/lib/selectors/manifest' +import { + SelectorConnectionUnavailableError, + SelectorOptionsUnavailableError, +} from '@/lib/selectors/server/errors' +import { resolveSelectorCredentialBundle } from '@/lib/selectors/server/providers/credential-bundle' +import { flatSelectorResult } from '@/lib/selectors/server/providers/flat-results' +import type { + ExecuteServerSelectorArgs, + ServerSelectorAttachmentMap, +} from '@/lib/selectors/server/types' +import type { SafeSelectorOption } from '@/lib/selectors/types' +import type { SnowflakeSelectorKind } from '@/tools/snowflake/selector-kinds' +import { buildSelectorStatement } from '@/tools/snowflake/sql' +import { + buildSnowflakeAuthHeaders, + normalizeSnowflakeHost, + readSnowflakeResult, +} from '@/tools/snowflake/utils' + +type SnowflakeSelectorKey = Extract +type SnowflakeScopeLevel = 'account' | 'database' | 'schema' + +interface SnowflakeSelectorSpec { + kind: SnowflakeSelectorKind + scope: SnowflakeScopeLevel +} + +const SNOWFLAKE_SELECTOR_SPECS = { + 'snowflake.databases': { kind: 'databases', scope: 'account' }, + 'snowflake.warehouses': { kind: 'warehouses', scope: 'account' }, + 'snowflake.roles': { kind: 'roles', scope: 'account' }, + 'snowflake.schemas': { kind: 'schemas', scope: 'database' }, + 'snowflake.tables': { kind: 'tables', scope: 'schema' }, + 'snowflake.fileFormats': { kind: 'file_formats', scope: 'schema' }, + 'snowflake.procedures': { kind: 'procedures', scope: 'schema' }, +} as const satisfies Record + +const SELECTOR_ROW_LIMIT = 1_000 +const SELECTOR_TIMEOUT_SECONDS = 20 +const SELECTOR_FETCH_TIMEOUT_MS = (SELECTOR_TIMEOUT_SECONDS + 10) * 1_000 + +interface SnowflakeObject { + name: string + detail: string | null +} + +function parseAvailableRoles(cellValue: string | null | undefined): SnowflakeObject[] { + if (!cellValue) return [] + let parsed: unknown + try { + parsed = JSON.parse(cellValue) + } catch { + return [] + } + if (!Array.isArray(parsed)) return [] + return parsed + .filter((role): role is string => typeof role === 'string' && role.length > 0) + .sort((left, right) => left.localeCompare(right)) + .map((role) => ({ name: role, detail: null })) +} + +function toOption(object: SnowflakeObject): SafeSelectorOption { + return { + id: object.name, + label: object.detail ? `${object.name} — ${object.detail}` : object.name, + meta: { name: object.name, ...(object.detail ? { detail: object.detail } : {}) }, + } +} + +async function executeSnowflake(args: ExecuteServerSelectorArgs) { + const spec = SNOWFLAKE_SELECTOR_SPECS[args.selectorKey as SnowflakeSelectorKey] + if (!spec) throw new SelectorOptionsUnavailableError() + if (!args.credential) throw new SelectorConnectionUnavailableError() + + const token = await resolveSelectorCredentialBundle({ + credential: args.credential, + protectedValues: args.protectedValues, + }) + if (!token.domain) throw new SelectorConnectionUnavailableError() + + let baseUrl: string + let statement: string + try { + baseUrl = normalizeSnowflakeHost(token.domain) + statement = buildSelectorStatement( + spec.kind, + { + ...(spec.scope !== 'account' ? { database: args.context.database } : {}), + ...(spec.scope === 'schema' ? { schema: args.context.schema } : {}), + }, + SELECTOR_ROW_LIMIT + ).statement + } catch { + throw new SelectorOptionsUnavailableError() + } + + const timeoutSignal = AbortSignal.timeout(SELECTOR_FETCH_TIMEOUT_MS) + const signal = args.signal ? AbortSignal.any([args.signal, timeoutSignal]) : timeoutSignal + let response: Response + try { + response = await fetch(`${baseUrl}/api/v2/statements`, { + method: 'POST', + headers: buildSnowflakeAuthHeaders(token.accessToken), + body: JSON.stringify({ + statement, + timeout: SELECTOR_TIMEOUT_SECONDS, + parameters: { rows_per_resultset: SELECTOR_ROW_LIMIT }, + }), + signal, + redirect: 'error', + }) + } catch (error) { + if (args.signal?.aborted) throw error + throw new SelectorOptionsUnavailableError() + } + + try { + const result = await readSnowflakeResult(response) + if (!result.result) throw new SelectorOptionsUnavailableError() + const objects: SnowflakeObject[] = + spec.kind === 'roles' + ? parseAvailableRoles(result.result.rows[0]?.[0]) + : result.result.rows.flatMap((row) => { + const name = row[0] + if (typeof name !== 'string' || !name) return [] + return [{ name, detail: typeof row[1] === 'string' ? row[1] : null }] + }) + return flatSelectorResult(args.request, objects.map(toOption), true) + } catch (error) { + if (error instanceof SelectorOptionsUnavailableError) throw error + throw new SelectorOptionsUnavailableError() + } +} + +const credential = { + kind: 'stored', + field: 'oauthCredential', + serviceIds: ['snowflake'], +} as const + +export const snowflakeSelectorAttachments = { + 'snowflake.databases': { + credential, + destination: 'credential-bound', + execute: executeSnowflake, + }, + 'snowflake.schemas': { + credential, + destination: 'credential-bound', + execute: executeSnowflake, + }, + 'snowflake.tables': { + credential, + destination: 'credential-bound', + execute: executeSnowflake, + }, + 'snowflake.warehouses': { + credential, + destination: 'credential-bound', + execute: executeSnowflake, + }, + 'snowflake.roles': { + credential, + destination: 'credential-bound', + execute: executeSnowflake, + }, + 'snowflake.fileFormats': { + credential, + destination: 'credential-bound', + execute: executeSnowflake, + }, + 'snowflake.procedures': { + credential, + destination: 'credential-bound', + execute: executeSnowflake, + }, +} satisfies ServerSelectorAttachmentMap diff --git a/apps/sim/lib/selectors/server/providers/trello.ts b/apps/sim/lib/selectors/server/providers/trello.ts new file mode 100644 index 00000000000..ebca7959044 --- /dev/null +++ b/apps/sim/lib/selectors/server/providers/trello.ts @@ -0,0 +1,60 @@ +import type { ServerSelectorKey } from '@/lib/selectors/manifest' +import { resolveSelectorOAuthAccessToken } from '@/lib/selectors/server/credentials' +import { + SelectorConnectionUnavailableError, + SelectorOptionsUnavailableError, +} from '@/lib/selectors/server/errors' +import { fetchProviderJson } from '@/lib/selectors/server/providers/provider-http' +import { + detailSelectorResult, + listSelectorResult, + type ServerSelectorAttachmentMap, +} from '@/lib/selectors/server/types' + +type TrelloSelectorKey = Extract + +export const trelloSelectorAttachments = { + 'trello.boards': { + credential: { + kind: 'stored', + field: 'oauthCredential', + serviceIds: ['trello'], + }, + destination: 'fixed', + execute: async (args) => { + if (!args.credential) throw new SelectorConnectionUnavailableError() + const apiKey = process.env.TRELLO_API_KEY + if (!apiKey) throw new SelectorOptionsUnavailableError() + args.protectedValues.add(apiKey) + const token = await resolveSelectorOAuthAccessToken({ + credential: args.credential, + serviceId: 'trello', + protectedValues: args.protectedValues, + }) + const url = new URL('https://api.trello.com/1/members/me/boards') + url.searchParams.set('key', apiKey) + url.searchParams.set('token', token) + url.searchParams.set('fields', 'id,name,closed') + const data = await fetchProviderJson(url, { + headers: { Accept: 'application/json' }, + signal: args.signal, + redirect: 'error', + }) + if (!Array.isArray(data)) throw new SelectorOptionsUnavailableError() + const boards = data.flatMap((value) => { + if (!value || typeof value !== 'object') return [] + const board = value as { id?: unknown; name?: unknown; closed?: unknown } + if (typeof board.id !== 'string' || typeof board.name !== 'string') return [] + return [{ id: board.id, label: board.name, closed: board.closed === true }] + }) + if (args.request.kind === 'detail') { + const detailId = args.request.id + const board = boards.find((item) => item.id === detailId) + return detailSelectorResult(board ? { id: board.id, label: board.label } : null) + } + return listSelectorResult( + boards.filter((board) => !board.closed).map(({ id, label }) => ({ id, label })) + ) + }, + }, +} satisfies ServerSelectorAttachmentMap diff --git a/apps/sim/lib/selectors/server/providers/wealthbox.ts b/apps/sim/lib/selectors/server/providers/wealthbox.ts new file mode 100644 index 00000000000..5c8392a45ab --- /dev/null +++ b/apps/sim/lib/selectors/server/providers/wealthbox.ts @@ -0,0 +1,76 @@ +import type { ServerSelectorKey } from '@/lib/selectors/manifest' +import { resolveSelectorOAuthAccessToken } from '@/lib/selectors/server/credentials' +import { SelectorConnectionUnavailableError } from '@/lib/selectors/server/errors' +import { flatSelectorResult } from '@/lib/selectors/server/providers/flat-results' +import { fetchProviderJson } from '@/lib/selectors/server/providers/provider-http' +import type { ServerSelectorAttachmentMap } from '@/lib/selectors/server/types' + +type WealthboxSelectorKey = Extract + +const PAGE_SIZE = 50 +const MAX_PAGES = 50 + +interface WealthboxContactsPage { + contacts?: Array> + meta?: { total_pages?: number; current_page?: number } +} + +export const wealthboxSelectorAttachments = { + 'wealthbox.contacts': { + credential: { + kind: 'stored', + field: 'oauthCredential', + serviceIds: ['wealthbox'], + }, + destination: 'fixed', + execute: async (args) => { + if (!args.credential) throw new SelectorConnectionUnavailableError() + const token = await resolveSelectorOAuthAccessToken({ + credential: args.credential, + serviceId: 'wealthbox', + protectedValues: args.protectedValues, + }) + const contacts: Array> = [] + for (let page = 1; page <= MAX_PAGES; page++) { + const url = new URL('https://api.crmworkspace.com/v1/contacts') + url.searchParams.set('per_page', String(PAGE_SIZE)) + url.searchParams.set('page', String(page)) + const data = await fetchProviderJson(url, { + headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, + signal: args.signal, + redirect: 'error', + }) + const pageContacts = Array.isArray(data.contacts) ? data.contacts : [] + contacts.push(...pageContacts) + const totalPages = data.meta?.total_pages + const currentPage = data.meta?.current_page ?? page + if ( + (typeof totalPages === 'number' && totalPages > 0 && currentPage >= totalPages) || + pageContacts.length < PAGE_SIZE + ) { + break + } + } + const search = + args.request.kind === 'list' ? args.request.search?.trim().toLowerCase() : undefined + const items = contacts.flatMap((contact) => { + const id = contact.id === undefined || contact.id === null ? '' : String(contact.id) + if (!id) return [] + const firstName = typeof contact.first_name === 'string' ? contact.first_name : '' + const lastName = typeof contact.last_name === 'string' ? contact.last_name : '' + const label = `${firstName} ${lastName}`.trim() || `Contact ${id}` + const content = + typeof contact.background_information === 'string' ? contact.background_information : '' + if ( + search && + !label.toLowerCase().includes(search) && + !content.toLowerCase().includes(search) + ) { + return [] + } + return [{ id, label }] + }) + return flatSelectorResult(args.request, items) + }, + }, +} satisfies ServerSelectorAttachmentMap diff --git a/apps/sim/lib/selectors/server/providers/webflow.ts b/apps/sim/lib/selectors/server/providers/webflow.ts new file mode 100644 index 00000000000..b7fb27facca --- /dev/null +++ b/apps/sim/lib/selectors/server/providers/webflow.ts @@ -0,0 +1,131 @@ +import { validateAlphanumericId } from '@/lib/core/security/input-validation' +import type { ServerSelectorKey } from '@/lib/selectors/manifest' +import { resolveSelectorOAuthAccessToken } from '@/lib/selectors/server/credentials' +import { + SelectorConnectionUnavailableError, + SelectorContextUnavailableError, +} from '@/lib/selectors/server/errors' +import { flatSelectorResult } from '@/lib/selectors/server/providers/flat-results' +import { fetchProviderJson } from '@/lib/selectors/server/providers/provider-http' +import type { + ExecuteServerSelectorArgs, + ServerSelectorAttachmentMap, +} from '@/lib/selectors/server/types' +import type { SafeSelectorOption } from '@/lib/selectors/types' + +type WebflowSelectorKey = Extract< + ServerSelectorKey, + 'webflow.sites' | 'webflow.collections' | 'webflow.items' +> + +const credential = { + kind: 'stored', + field: 'oauthCredential', + serviceIds: ['webflow'], +} as const + +async function tokenFor(args: ExecuteServerSelectorArgs): Promise { + if (!args.credential) throw new SelectorConnectionUnavailableError() + return resolveSelectorOAuthAccessToken({ + credential: args.credential, + serviceId: 'webflow', + protectedValues: args.protectedValues, + }) +} + +function requireWebflowId(value: string | undefined, name: string): string { + if (!value) throw new SelectorContextUnavailableError() + const validation = validateAlphanumericId(value, name) + if (!validation.isValid) throw new SelectorContextUnavailableError() + return validation.sanitized ?? value +} + +async function listSites(args: ExecuteServerSelectorArgs): Promise { + const token = await tokenFor(args) + const data = await fetchProviderJson<{ + sites?: Array<{ id: string; displayName?: string; shortName?: string }> + }>('https://api.webflow.com/v2/sites', { + headers: { Authorization: `Bearer ${token}`, accept: 'application/json' }, + signal: args.signal, + redirect: 'error', + }) + return (data.sites ?? []).map((site) => ({ + id: site.id, + label: site.displayName || site.shortName || site.id, + })) +} + +async function listCollections(args: ExecuteServerSelectorArgs): Promise { + const siteId = requireWebflowId(args.context.siteId, 'siteId') + const token = await tokenFor(args) + const data = await fetchProviderJson<{ + collections?: Array<{ id: string; displayName?: string; slug?: string }> + }>(`https://api.webflow.com/v2/sites/${encodeURIComponent(siteId)}/collections`, { + headers: { Authorization: `Bearer ${token}`, accept: 'application/json' }, + signal: args.signal, + redirect: 'error', + }) + return (data.collections ?? []).map((collection) => ({ + id: collection.id, + label: collection.displayName || collection.slug || collection.id, + })) +} + +async function listItems(args: ExecuteServerSelectorArgs): Promise { + const collectionId = requireWebflowId(args.context.collectionId, 'collectionId') + const token = await tokenFor(args) + const items: Array<{ + id: string + fieldData?: { name?: string; title?: string; slug?: string } + }> = [] + let offset = 0 + for (let page = 0; page < 50; page++) { + const url = new URL( + `https://api.webflow.com/v2/collections/${encodeURIComponent(collectionId)}/items` + ) + url.searchParams.set('limit', '100') + url.searchParams.set('offset', String(offset)) + const data = await fetchProviderJson<{ + items?: typeof items + pagination?: { total?: number } + }>(url, { + headers: { Authorization: `Bearer ${token}`, accept: 'application/json' }, + signal: args.signal, + redirect: 'error', + }) + const pageItems = data.items ?? [] + items.push(...pageItems) + offset += pageItems.length + if ( + pageItems.length === 0 || + (typeof data.pagination?.total === 'number' && items.length >= data.pagination.total) + ) { + break + } + } + + const search = args.request.kind === 'list' ? args.request.search?.toLowerCase() : undefined + return items.flatMap((item) => { + if (!item.id) return [] + const label = item.fieldData?.name || item.fieldData?.title || item.fieldData?.slug || item.id + return search && !label.toLowerCase().includes(search) ? [] : [{ id: item.id, label }] + }) +} + +export const webflowSelectorAttachments = { + 'webflow.sites': { + credential, + destination: 'fixed', + execute: async (args) => flatSelectorResult(args.request, await listSites(args)), + }, + 'webflow.collections': { + credential, + destination: 'fixed', + execute: async (args) => flatSelectorResult(args.request, await listCollections(args)), + }, + 'webflow.items': { + credential, + destination: 'fixed', + execute: async (args) => flatSelectorResult(args.request, await listItems(args)), + }, +} satisfies ServerSelectorAttachmentMap diff --git a/apps/sim/lib/selectors/server/providers/zoho-desk.ts b/apps/sim/lib/selectors/server/providers/zoho-desk.ts new file mode 100644 index 00000000000..96268bdb425 --- /dev/null +++ b/apps/sim/lib/selectors/server/providers/zoho-desk.ts @@ -0,0 +1,185 @@ +import { db } from '@sim/db' +import { account } from '@sim/db/schema' +import { eq } from 'drizzle-orm' +import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server' +import { resolveOAuthAccountId } from '@/lib/oauth/credential-service' +import type { ServerSelectorKey } from '@/lib/selectors/manifest' +import { + SelectorConnectionUnavailableError, + SelectorContextUnavailableError, + SelectorOptionsUnavailableError, +} from '@/lib/selectors/server/errors' +import { resolveSelectorCredentialBundle } from '@/lib/selectors/server/providers/credential-bundle' +import { flatSelectorResult } from '@/lib/selectors/server/providers/flat-results' +import type { + ExecuteServerSelectorArgs, + ServerSelectorAttachmentMap, +} from '@/lib/selectors/server/types' +import type { SafeSelectorOption } from '@/lib/selectors/types' +import { assertZohoUrl, extractZohoDeskBaseFromScope } from '@/tools/zoho_desk/host-allowlist' +import { buildZohoDeskHeaders, getZohoDeskApiBase } from '@/tools/zoho_desk/utils' + +type ZohoDeskSelectorKey = Extract< + ServerSelectorKey, + 'zoho_desk.organizations' | 'zoho_desk.departments' | 'zoho_desk.agents' +> + +const credential = { + kind: 'stored', + field: 'oauthCredential', + serviceIds: ['zoho-desk'], +} as const + +async function readOAuthApiDomain(credentialId: string): Promise { + try { + const resolved = await resolveOAuthAccountId(credentialId) + if (!resolved?.accountId) return undefined + const [row] = await db + .select({ scope: account.scope }) + .from(account) + .where(eq(account.id, resolved.accountId)) + .limit(1) + return extractZohoDeskBaseFromScope(row?.scope) + } catch { + return undefined + } +} + +async function resolveZohoCredential(args: ExecuteServerSelectorArgs) { + if (!args.credential) throw new SelectorConnectionUnavailableError() + const bundle = await resolveSelectorCredentialBundle({ + credential: args.credential, + protectedValues: args.protectedValues, + }) + const apiDomain = bundle.apiDomain ?? (await readOAuthApiDomain(args.credential.suppliedId)) + args.protectedValues.add(apiDomain) + const apiBase = getZohoDeskApiBase({ apiDomain }) + return { accessToken: bundle.accessToken, apiBase } +} + +async function fetchZoho( + args: ExecuteServerSelectorArgs, + url: URL, + headers: Record +): Promise<{ status: number; data: unknown[] }> { + let response + try { + response = await secureFetchWithValidation(url.toString(), { + method: 'GET', + headers, + timeout: 15_000, + maxResponseBytes: 2 * 1024 * 1024, + stripAuthOnRedirect: true, + signal: args.signal, + }) + } catch { + throw new SelectorOptionsUnavailableError() + } + if (response.status === 204) return { status: 204, data: [] } + const body = await response + .json() + .then((value) => + value && typeof value === 'object' ? (value as { data?: unknown }) : undefined + ) + .catch(() => undefined) + if (!response.ok) throw new SelectorOptionsUnavailableError() + return { status: response.status, data: Array.isArray(body?.data) ? body.data : [] } +} + +async function listOrganizations(args: ExecuteServerSelectorArgs): Promise { + const { accessToken, apiBase } = await resolveZohoCredential(args) + let url: URL + try { + url = assertZohoUrl(`${apiBase}/organizations`) + } catch { + throw new SelectorConnectionUnavailableError() + } + const { data } = await fetchZoho(args, url, { + Authorization: `Zoho-oauthtoken ${accessToken}`, + 'Content-Type': 'application/json', + }) + return data.flatMap((value) => { + if (!value || typeof value !== 'object') return [] + const organization = value as { + id?: string | number + companyName?: string + portalName?: string + } + if (organization.id === undefined || organization.id === null) return [] + const id = String(organization.id) + return [{ id, label: organization.companyName || organization.portalName || id }] + }) +} + +async function listOrgResources( + args: ExecuteServerSelectorArgs, + kind: 'departments' | 'agents' +): Promise { + const orgId = args.context.orgId + if (!orgId) throw new SelectorContextUnavailableError() + const { accessToken, apiBase } = await resolveZohoCredential(args) + const headers = buildZohoDeskHeaders({ accessToken, orgId }) + const items: SafeSelectorOption[] = [] + const seen = new Set() + + for (let page = 0; page < 20; page++) { + let url: URL + try { + url = assertZohoUrl(`${apiBase}/${kind}`) + } catch { + throw new SelectorConnectionUnavailableError() + } + url.searchParams.set('from', String(page * 200)) + url.searchParams.set('limit', '200') + if (kind === 'agents') url.searchParams.set('status', 'ACTIVE') + const result = await fetchZoho(args, url, headers) + if (result.status === 204) break + + for (const value of result.data) { + if (!value || typeof value !== 'object') continue + const record = value as Record + if (record.id === undefined || record.id === null) continue + const id = String(record.id) + if (seen.has(id)) continue + seen.add(id) + let label: string + if (kind === 'departments') { + label = + (typeof record.name === 'string' && record.name) || + (typeof record.nameInCustomerPortal === 'string' && record.nameInCustomerPortal) || + id + } else { + const name = typeof record.name === 'string' ? record.name.trim() : '' + const fullName = [record.firstName, record.lastName] + .filter((part): part is string => typeof part === 'string' && Boolean(part.trim())) + .map((part) => part.trim()) + .join(' ') + label = + name || fullName || (typeof record.emailId === 'string' && record.emailId.trim()) || id + } + items.push({ id, label }) + } + if (result.data.length < 200) break + } + return items +} + +export const zohoDeskSelectorAttachments = { + 'zoho_desk.organizations': { + credential, + destination: 'credential-bound', + execute: async (args) => flatSelectorResult(args.request, await listOrganizations(args)), + }, + 'zoho_desk.departments': { + credential, + destination: 'credential-bound', + execute: async (args) => + flatSelectorResult(args.request, await listOrgResources(args, 'departments')), + }, + 'zoho_desk.agents': { + credential, + destination: 'credential-bound', + execute: async (args) => + flatSelectorResult(args.request, await listOrgResources(args, 'agents')), + }, +} satisfies ServerSelectorAttachmentMap diff --git a/apps/sim/lib/selectors/server/providers/zoom.ts b/apps/sim/lib/selectors/server/providers/zoom.ts new file mode 100644 index 00000000000..890f5a1a992 --- /dev/null +++ b/apps/sim/lib/selectors/server/providers/zoom.ts @@ -0,0 +1,56 @@ +import type { ServerSelectorKey } from '@/lib/selectors/manifest' +import { resolveSelectorOAuthAccessToken } from '@/lib/selectors/server/credentials' +import { SelectorConnectionUnavailableError } from '@/lib/selectors/server/errors' +import { flatSelectorResult } from '@/lib/selectors/server/providers/flat-results' +import { fetchProviderJson } from '@/lib/selectors/server/providers/provider-http' +import type { ServerSelectorAttachmentMap } from '@/lib/selectors/server/types' +import type { SafeSelectorOption } from '@/lib/selectors/types' + +type ZoomSelectorKey = Extract + +const PAGE_SIZE = 300 +const MAX_PAGES = 50 + +interface ZoomMeetingsPage { + meetings?: Array<{ id: number | string; topic?: string }> + next_page_token?: string +} + +export const zoomSelectorAttachments = { + 'zoom.meetings': { + credential: { + kind: 'stored', + field: 'oauthCredential', + serviceIds: ['zoom'], + }, + destination: 'fixed', + execute: async (args) => { + if (!args.credential) throw new SelectorConnectionUnavailableError() + const token = await resolveSelectorOAuthAccessToken({ + credential: args.credential, + serviceId: 'zoom', + protectedValues: args.protectedValues, + }) + const meetings: SafeSelectorOption[] = [] + let nextPageToken = '' + for (let page = 0; page < MAX_PAGES; page++) { + const url = new URL('https://api.zoom.us/v2/users/me/meetings') + url.searchParams.set('page_size', String(PAGE_SIZE)) + url.searchParams.set('type', 'scheduled') + if (nextPageToken) url.searchParams.set('next_page_token', nextPageToken) + const data = await fetchProviderJson(url, { + headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, + signal: args.signal, + redirect: 'error', + }) + for (const meeting of data.meetings ?? []) { + const id = String(meeting.id) + meetings.push({ id, label: meeting.topic || `Meeting ${id}` }) + } + nextPageToken = data.next_page_token?.trim() ?? '' + if (!nextPageToken) break + } + return flatSelectorResult(args.request, meetings, true) + }, + }, +} satisfies ServerSelectorAttachmentMap diff --git a/apps/sim/lib/selectors/server/references.test.ts b/apps/sim/lib/selectors/server/references.test.ts new file mode 100644 index 00000000000..bca9ea7913c --- /dev/null +++ b/apps/sim/lib/selectors/server/references.test.ts @@ -0,0 +1,137 @@ +/** + * @vitest-environment node + */ +import { environmentUtilsMockFns, resetEnvironmentUtilsMock } from '@sim/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { SelectorContextUnavailableError } from '@/lib/selectors/server/errors' +import { createSelectorProtectedValues } from '@/lib/selectors/server/protected-values' +import { resolveSelectorReferences } from '@/lib/selectors/server/references' + +const baseInput = { + selectorKey: 'imap.mailboxes' as const, + requesterUserId: 'user-1', + workspaceId: 'workspace-1', +} + +describe('resolveSelectorReferences', () => { + beforeEach(() => { + resetEnvironmentUtilsMock() + }) + + it('keeps literals local and protects sensitive literal fields without loading environments', async () => { + const protectedValues = createSelectorProtectedValues() + + const result = await resolveSelectorReferences({ + ...baseInput, + context: { + host: 'imap.example.com', + port: '993', + secure: 'true', + username: 'mailbox-user', + password: 'literal-password', + }, + request: { kind: 'list' }, + protectedValues, + }) + + expect(result.context).toEqual({ + host: 'imap.example.com', + port: '993', + secure: 'true', + username: 'mailbox-user', + password: 'literal-password', + }) + expect(result.references.size).toBe(0) + expect(protectedValues.contains('prefix-literal-password-suffix')).toBe(true) + expect(environmentUtilsMockFns.mockGetEffectiveEnvironmentSnapshot).not.toHaveBeenCalled() + }) + + it('resolves personal, visible shared, and hidden use-only references with workspace precedence', async () => { + environmentUtilsMockFns.mockGetEffectiveEnvironmentSnapshot.mockResolvedValue({ + personalEncrypted: {}, + workspaceEncrypted: {}, + personalDecrypted: { + PERSONAL_HOST: 'personal.example.com', + SHARED_USERNAME: 'personal-shadow', + }, + workspaceDecrypted: { + SHARED_USERNAME: 'shared-user', + SHARED_PASSWORD: 'hidden-password', + }, + personalOwners: { PERSONAL_HOST: 'user-1' }, + conflicts: ['SHARED_USERNAME'], + decryptionFailures: [], + workspaceUnredactedKeys: ['SHARED_USERNAME'], + }) + const protectedValues = createSelectorProtectedValues() + + const result = await resolveSelectorReferences({ + ...baseInput, + context: { + host: '{{PERSONAL_HOST}}', + username: '{{SHARED_USERNAME}}', + password: '{{SHARED_PASSWORD}}', + }, + request: { kind: 'list' }, + protectedValues, + }) + + expect(result.context).toEqual({ + host: 'personal.example.com', + username: 'shared-user', + password: 'hidden-password', + }) + expect([...result.references.values()]).toEqual([ + { + field: 'host', + name: 'PERSONAL_HOST', + scope: 'personal', + visible: true, + }, + { + field: 'username', + name: 'SHARED_USERNAME', + scope: 'workspace', + visible: true, + }, + { + field: 'password', + name: 'SHARED_PASSWORD', + scope: 'workspace', + visible: false, + }, + ]) + expect(protectedValues.contains('hidden-password')).toBe(true) + }) + + it('projects missing, inaccessible, embedded, and runtime references to one context error', async () => { + environmentUtilsMockFns.mockGetEffectiveEnvironmentSnapshot.mockResolvedValue({ + personalEncrypted: {}, + workspaceEncrypted: {}, + personalDecrypted: {}, + workspaceDecrypted: {}, + personalOwners: {}, + conflicts: [], + decryptionFailures: ['INACCESSIBLE_SHARED'], + workspaceUnredactedKeys: [], + }) + + const contexts = [ + { host: '{{MISSING}}', username: 'user', password: 'password' }, + { host: '{{INACCESSIBLE_SHARED}}', username: 'user', password: 'password' }, + { host: 'imap.{{HOST}}', username: 'user', password: 'password' }, + { host: '', username: 'user', password: 'password' }, + ] + + for (const context of contexts) { + await expect( + resolveSelectorReferences({ + ...baseInput, + context, + request: { kind: 'list' }, + protectedValues: createSelectorProtectedValues(), + }) + ).rejects.toEqual(new SelectorContextUnavailableError()) + } + }) +}) diff --git a/apps/sim/lib/selectors/server/references.ts b/apps/sim/lib/selectors/server/references.ts new file mode 100644 index 00000000000..b44d5900673 --- /dev/null +++ b/apps/sim/lib/selectors/server/references.ts @@ -0,0 +1,100 @@ +import { getEffectiveEnvironmentSnapshot } from '@/lib/environment/utils' +import { getSelectorManifestEntry, type ServerSelectorKey } from '@/lib/selectors/manifest' +import { SelectorContextUnavailableError } from '@/lib/selectors/server/errors' +import type { + ResolvedSelectorReference, + SelectorProtectedValues, +} from '@/lib/selectors/server/types' +import type { SelectorContext, SelectorRequest } from '@/lib/selectors/types' + +const EXACT_ENVIRONMENT_REFERENCE = /^\{\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*\}\}$/ + +export interface ResolvedSelectorInputs { + context: SelectorContext + request: SelectorRequest + references: ReadonlyMap +} + +function rejectsEmbeddedReference(value: string): boolean { + return (value.includes('{{') || value.includes('}}')) && !EXACT_ENVIRONMENT_REFERENCE.test(value) +} + +function containsRuntimeReference(value: string): boolean { + return /<[^<>]+>/.test(value) +} + +export async function resolveSelectorReferences(input: { + selectorKey: ServerSelectorKey + context: SelectorContext + request: SelectorRequest + requesterUserId: string + workspaceId: string + protectedValues: SelectorProtectedValues +}): Promise { + const contextEntries = Object.entries(input.context).filter( + (entry): entry is [string, string] => entry[1] !== undefined + ) + const resolvableValues = [ + ...contextEntries.map(([, value]) => value), + ...(input.request.kind === 'detail' ? [input.request.id] : []), + ] + if ( + resolvableValues.some( + (value) => rejectsEmbeddedReference(value) || containsRuntimeReference(value) + ) + ) { + throw new SelectorContextUnavailableError() + } + + const manifest = getSelectorManifestEntry(input.selectorKey) + if (!resolvableValues.some((value) => EXACT_ENVIRONMENT_REFERENCE.test(value))) { + const context = Object.fromEntries(contextEntries) as SelectorContext + for (const field of manifest.context.sensitive ?? []) { + input.protectedValues.add(context[field]) + } + return { context, request: input.request, references: new Map() } + } + + const snapshot = await getEffectiveEnvironmentSnapshot(input.requesterUserId, input.workspaceId) + const references = new Map() + const visibleWorkspaceNames = new Set(snapshot.workspaceUnredactedKeys) + + const resolve = (field: string, value: string): string => { + const match = EXACT_ENVIRONMENT_REFERENCE.exec(value) + if (!match) return value + + const name = match[1] + const fromWorkspace = Object.hasOwn(snapshot.workspaceDecrypted, name) + const resolved = fromWorkspace + ? snapshot.workspaceDecrypted[name] + : snapshot.personalDecrypted[name] + if (resolved === undefined) throw new SelectorContextUnavailableError() + + input.protectedValues.add(resolved) + references.set(field, { + field, + name, + scope: fromWorkspace ? 'workspace' : 'personal', + visible: fromWorkspace + ? visibleWorkspaceNames.has(name) + : snapshot.personalOwners[name] === input.requesterUserId, + }) + return resolved + } + + const context: SelectorContext = {} + for (const [field, value] of contextEntries) { + context[field as keyof SelectorContext] = resolve(field, value) + } + + for (const field of manifest.context.sensitive ?? []) { + input.protectedValues.add(context[field]) + } + + const request = + input.request.kind === 'detail' + ? { ...input.request, id: resolve('request.id', input.request.id) } + : input.request + + return { context, request, references } +} diff --git a/apps/sim/lib/selectors/server/registry.ts b/apps/sim/lib/selectors/server/registry.ts new file mode 100644 index 00000000000..ef9a815f1af --- /dev/null +++ b/apps/sim/lib/selectors/server/registry.ts @@ -0,0 +1,71 @@ +import type { ServerSelectorKey } from '@/lib/selectors/manifest' +import { internalSelectorAttachments } from '@/lib/selectors/server/internal' +import { airtableSelectorAttachments } from '@/lib/selectors/server/providers/airtable' +import { asanaSelectorAttachments } from '@/lib/selectors/server/providers/asana' +import { attioSelectorAttachments } from '@/lib/selectors/server/providers/attio' +import { bigQuerySelectorAttachments } from '@/lib/selectors/server/providers/bigquery' +import { bitbucketSelectorAttachments } from '@/lib/selectors/server/providers/bitbucket' +import { calcomSelectorAttachments } from '@/lib/selectors/server/providers/calcom' +import { clickupSelectorAttachments } from '@/lib/selectors/server/providers/clickup' +import { cloudWatchSelectorAttachments } from '@/lib/selectors/server/providers/cloudwatch' +import { confluenceSelectorAttachments } from '@/lib/selectors/server/providers/confluence' +import { googleSelectorAttachments } from '@/lib/selectors/server/providers/google' +import { harmonicSelectorAttachments } from '@/lib/selectors/server/providers/harmonic' +import { hubspotSelectorAttachments } from '@/lib/selectors/server/providers/hubspot' +import { imapSelectorAttachments } from '@/lib/selectors/server/providers/imap' +import { jiraSelectorAttachments } from '@/lib/selectors/server/providers/jira' +import { jsmSelectorAttachments } from '@/lib/selectors/server/providers/jsm' +import { linearSelectorAttachments } from '@/lib/selectors/server/providers/linear' +import { managedAgentSelectorAttachments } from '@/lib/selectors/server/providers/managed-agent' +import { microsoftSelectorAttachments } from '@/lib/selectors/server/providers/microsoft' +import { mondaySelectorAttachments } from '@/lib/selectors/server/providers/monday' +import { netsuiteSelectorAttachments } from '@/lib/selectors/server/providers/netsuite' +import { notionSelectorAttachments } from '@/lib/selectors/server/providers/notion' +import { pipedriveSelectorAttachments } from '@/lib/selectors/server/providers/pipedrive' +import { sharepointSelectorAttachments } from '@/lib/selectors/server/providers/sharepoint' +import { slackSelectorAttachments } from '@/lib/selectors/server/providers/slack' +import { snowflakeSelectorAttachments } from '@/lib/selectors/server/providers/snowflake' +import { trelloSelectorAttachments } from '@/lib/selectors/server/providers/trello' +import { wealthboxSelectorAttachments } from '@/lib/selectors/server/providers/wealthbox' +import { webflowSelectorAttachments } from '@/lib/selectors/server/providers/webflow' +import { zohoDeskSelectorAttachments } from '@/lib/selectors/server/providers/zoho-desk' +import { zoomSelectorAttachments } from '@/lib/selectors/server/providers/zoom' +import type { ServerSelectorAttachment } from '@/lib/selectors/server/types' + +export const serverSelectorRegistry = { + ...internalSelectorAttachments, + ...airtableSelectorAttachments, + ...asanaSelectorAttachments, + ...attioSelectorAttachments, + ...bigQuerySelectorAttachments, + ...bitbucketSelectorAttachments, + ...calcomSelectorAttachments, + ...clickupSelectorAttachments, + ...cloudWatchSelectorAttachments, + ...confluenceSelectorAttachments, + ...googleSelectorAttachments, + ...harmonicSelectorAttachments, + ...hubspotSelectorAttachments, + ...imapSelectorAttachments, + ...jiraSelectorAttachments, + ...jsmSelectorAttachments, + ...linearSelectorAttachments, + ...managedAgentSelectorAttachments, + ...microsoftSelectorAttachments, + ...mondaySelectorAttachments, + ...netsuiteSelectorAttachments, + ...notionSelectorAttachments, + ...pipedriveSelectorAttachments, + ...sharepointSelectorAttachments, + ...slackSelectorAttachments, + ...snowflakeSelectorAttachments, + ...trelloSelectorAttachments, + ...wealthboxSelectorAttachments, + ...webflowSelectorAttachments, + ...zohoDeskSelectorAttachments, + ...zoomSelectorAttachments, +} satisfies Record + +export function getServerSelectorAttachment(key: ServerSelectorKey): ServerSelectorAttachment { + return serverSelectorRegistry[key] +} diff --git a/apps/sim/lib/selectors/server/sanitize.test.ts b/apps/sim/lib/selectors/server/sanitize.test.ts new file mode 100644 index 00000000000..3cc9e305777 --- /dev/null +++ b/apps/sim/lib/selectors/server/sanitize.test.ts @@ -0,0 +1,69 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { SelectorOptionsUnavailableError } from '@/lib/selectors/server/errors' +import { createSelectorProtectedValues } from '@/lib/selectors/server/protected-values' +import { sanitizeSelectorResult } from '@/lib/selectors/server/sanitize' +import type { SelectorExecutionResult } from '@/lib/selectors/types' + +describe('sanitizeSelectorResult', () => { + it('fails closed when protected plaintext appears in any response field', () => { + const protectedValues = createSelectorProtectedValues() + protectedValues.add('selector-secret-canary') + const results: SelectorExecutionResult[] = [ + { + kind: 'list', + items: [{ id: 'selector-secret-canary', label: 'Safe label' }], + }, + { + kind: 'list', + items: [{ id: 'safe-id', label: 'prefix-selector-secret-canary-suffix' }], + }, + { + kind: 'detail', + item: { id: 'safe-id', label: 'Safe label', meta: { value: 'selector-secret-canary' } }, + }, + { + kind: 'list', + items: [{ id: 'safe-id', label: 'Safe label' }], + nextCursor: 'cursor-selector-secret-canary', + }, + ] + + for (const result of results) { + expect(() => sanitizeSelectorResult(result, protectedValues)).toThrow( + SelectorOptionsUnavailableError + ) + } + }) + + it('returns only the normalized selector option envelope', () => { + const result = sanitizeSelectorResult( + { + kind: 'list', + items: [ + { + id: 'resource-1', + label: 'Resource one', + meta: { count: 3, active: true, parentId: null }, + }, + ], + nextCursor: 'next-page', + }, + createSelectorProtectedValues() + ) + + expect(result).toEqual({ + kind: 'list', + items: [ + { + id: 'resource-1', + label: 'Resource one', + meta: { count: 3, active: true, parentId: null }, + }, + ], + nextCursor: 'next-page', + }) + }) +}) diff --git a/apps/sim/lib/selectors/server/sanitize.ts b/apps/sim/lib/selectors/server/sanitize.ts new file mode 100644 index 00000000000..73786e1f7ed --- /dev/null +++ b/apps/sim/lib/selectors/server/sanitize.ts @@ -0,0 +1,91 @@ +import { SelectorOptionsUnavailableError } from '@/lib/selectors/server/errors' +import type { SelectorProtectedValues } from '@/lib/selectors/server/types' +import type { + SafeOptionMeta, + SafeOptionMetaValue, + SafeSelectorOption, + SelectorExecutionResult, +} from '@/lib/selectors/types' + +const MAX_OPTIONS = 10_000 +const MAX_OPTION_TEXT = 16 * 1024 +const MAX_META_FIELDS = 32 + +function requireSafeString(value: unknown, protectedValues: SelectorProtectedValues): string { + if (typeof value !== 'string' || value.length === 0 || value.length > MAX_OPTION_TEXT) { + throw new SelectorOptionsUnavailableError() + } + if (protectedValues.contains(value)) throw new SelectorOptionsUnavailableError() + return value +} + +function sanitizeMeta( + value: unknown, + protectedValues: SelectorProtectedValues +): SafeOptionMeta | undefined { + if (value === undefined) return undefined + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new SelectorOptionsUnavailableError() + } + + const entries = Object.entries(value) + if (entries.length > MAX_META_FIELDS) throw new SelectorOptionsUnavailableError() + const meta: SafeOptionMeta = {} + for (const [key, entry] of entries) { + if (!key || key.length > 128) throw new SelectorOptionsUnavailableError() + if ( + entry !== null && + typeof entry !== 'string' && + typeof entry !== 'number' && + typeof entry !== 'boolean' + ) { + throw new SelectorOptionsUnavailableError() + } + if (typeof entry === 'number' && !Number.isFinite(entry)) { + throw new SelectorOptionsUnavailableError() + } + if (typeof entry === 'string' && protectedValues.contains(entry)) { + throw new SelectorOptionsUnavailableError() + } + meta[key] = entry as SafeOptionMetaValue + } + return meta +} + +function sanitizeOption( + value: unknown, + protectedValues: SelectorProtectedValues +): SafeSelectorOption { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new SelectorOptionsUnavailableError() + } + const option = value as { id?: unknown; label?: unknown; meta?: unknown } + const meta = sanitizeMeta(option.meta, protectedValues) + return { + id: requireSafeString(option.id, protectedValues), + label: requireSafeString(option.label, protectedValues), + ...(meta ? { meta } : {}), + } +} + +export function sanitizeSelectorResult( + result: SelectorExecutionResult, + protectedValues: SelectorProtectedValues +): SelectorExecutionResult { + if (result.kind === 'detail') { + return { + kind: 'detail', + item: result.item ? sanitizeOption(result.item, protectedValues) : null, + } + } + + if (result.items.length > MAX_OPTIONS) throw new SelectorOptionsUnavailableError() + if (result.nextCursor !== undefined) { + requireSafeString(result.nextCursor, protectedValues) + } + return { + kind: 'list', + items: result.items.map((item) => sanitizeOption(item, protectedValues)), + ...(result.nextCursor ? { nextCursor: result.nextCursor } : {}), + } +} diff --git a/apps/sim/lib/selectors/server/types.ts b/apps/sim/lib/selectors/server/types.ts new file mode 100644 index 00000000000..5e195a31b0e --- /dev/null +++ b/apps/sim/lib/selectors/server/types.ts @@ -0,0 +1,98 @@ +import type { SessionPrincipal } from '@sim/auth/principal' +import type { CredentialAccessResult } from '@/lib/auth/credential-access' +import type { SelectorKey, ServerSelectorKey } from '@/lib/selectors/manifest' +import type { + SafeSelectorOption, + SelectorContext, + SelectorExecutionResult, + SelectorRequest, + SelectorScope, +} from '@/lib/selectors/types' + +export type SelectorDestinationPolicy = 'fixed' | 'credential-bound' | 'user-controlled' + +export type SelectorCredentialPolicy = + | { + kind: 'stored' + field: 'oauthCredential' + serviceIds: readonly string[] + } + | { + kind: 'stored-or-fixed-token' + field: 'oauthCredential' + serviceIds: readonly string[] + tokenPrefixes: readonly string[] + } + +export interface AuthorizedSelectorCredential { + suppliedId: string + access?: CredentialAccessResult + fixedToken?: string +} + +export interface SelectorProtectedValues { + add(value: string | null | undefined): void + contains(value: string): boolean +} + +export interface ResolvedSelectorReference { + field: string + name: string + scope: 'personal' | 'workspace' + visible: boolean +} + +export interface ExecuteServerSelectorArgs { + selectorKey: ServerSelectorKey + context: SelectorContext + request: SelectorRequest + scope: SelectorScope + workspaceId: string + principal: SessionPrincipal + requesterUserId: string + credential?: AuthorizedSelectorCredential + references: ReadonlyMap + signal?: AbortSignal + protectedValues: SelectorProtectedValues +} + +export interface ServerSelectorAttachment { + credential?: SelectorCredentialPolicy + destination: SelectorDestinationPolicy + execute(args: ExecuteServerSelectorArgs): Promise +} + +export type ServerSelectorAttachmentMap = { + [P in K]: ServerSelectorAttachment +} + +export function listSelectorResult( + items: SafeSelectorOption[], + nextCursor?: string +): SelectorExecutionResult { + return { kind: 'list', items, ...(nextCursor ? { nextCursor } : {}) } +} + +export function detailSelectorResult(item: SafeSelectorOption | null): SelectorExecutionResult { + return { kind: 'detail', item } +} + +export function requireListRequest( + selectorKey: SelectorKey, + request: SelectorRequest +): Extract { + if (request.kind !== 'list') { + throw new Error(`Selector ${selectorKey} received an unsupported detail request`) + } + return request +} + +export function requireDetailRequest( + selectorKey: SelectorKey, + request: SelectorRequest +): Extract { + if (request.kind !== 'detail') { + throw new Error(`Selector ${selectorKey} received an unsupported list request`) + } + return request +} diff --git a/apps/sim/lib/selectors/types.ts b/apps/sim/lib/selectors/types.ts new file mode 100644 index 00000000000..2980dd052fe --- /dev/null +++ b/apps/sim/lib/selectors/types.ts @@ -0,0 +1,126 @@ +import type { ComponentType } from 'react' + +export const selectorContextKeys = [ + 'oauthCredential', + 'domain', + 'teamId', + 'projectId', + 'knowledgeBaseId', + 'planId', + 'mimeType', + 'fileId', + 'siteId', + 'collectionId', + 'spreadsheetId', + 'driveId', + 'excludeWorkflowId', + 'baseId', + 'datasetId', + 'serviceDeskId', + 'impersonateUserEmail', + 'boardId', + 'spaceId', + 'listSpaceId', + 'folderId', + 'awsAccessKeyId', + 'awsSecretAccessKey', + 'awsRegion', + 'logGroupName', + 'tableId', + 'jobId', + 'database', + 'schema', + 'orgId', + 'workspaceSlug', + 'objectType', + 'customObjectTypeId', + 'pipelineId', + 'environmentType', + 'credentialGroupId', + 'language', + 'host', + 'port', + 'secure', + 'username', + 'password', +] as const + +export type SelectorContextKey = (typeof selectorContextKeys)[number] +export type SelectorContext = Partial> + +export type SelectorClassification = 'local' | 'internal-server' | 'provider-server' +export type SelectorScopeKind = 'workflow' | 'workspace' +export type SelectorListMode = 'flat' | 'paginated' + +export interface SelectorReadiness { + all?: readonly SelectorContextKey[] + any?: readonly SelectorContextKey[] +} + +export interface SelectorManifestEntry { + classification: SelectorClassification + context: { + allowed: readonly SelectorContextKey[] + readiness?: SelectorReadiness + sensitive?: readonly SelectorContextKey[] + /** Active input aliases that project into a canonical wire-context field. */ + sourceFields?: Partial> + } + scopeKinds: readonly SelectorScopeKind[] + listMode: SelectorListMode + supportsSearch: boolean + supportsDetail: boolean + resolvesUnknownIds: boolean + staleTime: number +} + +export type SafeOptionMetaValue = string | number | boolean | null +export type SafeOptionMeta = Record + +export interface SafeSelectorOption { + id: string + label: string + meta?: SafeOptionMeta +} + +export interface SelectorOption extends SafeSelectorOption { + icon?: ComponentType<{ className?: string }> +} + +export interface SelectorPage { + items: SelectorOption[] + nextCursor?: string +} + +export type SelectorScope = + | { + kind: 'workflow' + workflowId: string + workspaceId?: string + } + | { + kind: 'workspace' + workspaceId: string + } + +export type SelectorRequest = + | { + kind: 'list' + search?: string + cursor?: string + } + | { + kind: 'detail' + id: string + } + +export type SelectorExecutionResult = + | { + kind: 'list' + items: SafeSelectorOption[] + nextCursor?: string + } + | { + kind: 'detail' + item: SafeSelectorOption | null + } diff --git a/apps/sim/lib/webhooks/deploy.ts b/apps/sim/lib/webhooks/deploy.ts index 2425135c91c..953ccd8bb15 100644 --- a/apps/sim/lib/webhooks/deploy.ts +++ b/apps/sim/lib/webhooks/deploy.ts @@ -660,14 +660,15 @@ export async function resolveWebhookConfigForBlock(input: { async function configurePollingIfNeeded( provider: string, savedWebhook: Record, - requestId: string + requestId: string, + actor: { userId: string; workspaceId?: string | null; deploymentVersionId?: string | null } ): Promise { const handler = getProviderHandler(provider) if (!handler.configurePolling) { return null } - const success = await handler.configurePolling({ webhook: savedWebhook, requestId }) + const success = await handler.configurePolling({ webhook: savedWebhook, requestId, ...actor }) if (!success) { await db.delete(webhook).where(eq(webhook.id, savedWebhook.id as string)) return { @@ -1058,7 +1059,12 @@ export async function saveTriggerWebhooksForDeploy({ const pollingError = await configurePollingIfNeeded( sub.provider, { id: sub.webhookId, path: sub.triggerPath, providerConfig: sub.updatedProviderConfig }, - requestId + requestId, + { + userId, + workspaceId: typeof workflow.workspaceId === 'string' ? workflow.workspaceId : null, + deploymentVersionId, + } ) if (pollingError) { logger.error( diff --git a/apps/sim/lib/webhooks/polling/imap.test.ts b/apps/sim/lib/webhooks/polling/imap.test.ts new file mode 100644 index 00000000000..d22ff7f9b7a --- /dev/null +++ b/apps/sim/lib/webhooks/polling/imap.test.ts @@ -0,0 +1,83 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCreateSecureImapClient, + mockDbSelect, + mockHasImapEnvironmentReferences, + mockLogger, + mockMarkWebhookFailed, + mockResolveImapConnectionForActor, +} = vi.hoisted(() => ({ + mockCreateSecureImapClient: vi.fn(), + mockDbSelect: vi.fn(), + mockHasImapEnvironmentReferences: vi.fn(), + mockLogger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + mockMarkWebhookFailed: vi.fn(), + mockResolveImapConnectionForActor: vi.fn(), +})) + +vi.mock('@sim/db', () => ({ + db: { select: mockDbSelect }, +})) + +vi.mock('@/lib/core/idempotency/service', () => ({ + pollingIdempotency: { executeWithIdempotency: vi.fn() }, +})) + +vi.mock('@/lib/imap/connection.server', () => ({ + createSecureImapClient: mockCreateSecureImapClient, + hasImapEnvironmentReferences: mockHasImapEnvironmentReferences, + normalizeLiteralImapConnection: vi.fn(), + resolveImapConnectionForActor: mockResolveImapConnectionForActor, +})) + +vi.mock('@/lib/webhooks/polling/utils', () => ({ + markWebhookFailed: mockMarkWebhookFailed, + markWebhookSuccess: vi.fn(), + updateWebhookProviderConfig: vi.fn(), +})) + +vi.mock('@/lib/webhooks/processor', () => ({ + processPolledWebhookEvent: vi.fn(), +})) + +import { imapPollingHandler } from '@/lib/webhooks/polling/imap' + +describe('IMAP runtime polling policy', () => { + beforeEach(() => { + vi.clearAllMocks() + mockHasImapEnvironmentReferences.mockReturnValue(true) + mockMarkWebhookFailed.mockResolvedValue(undefined) + }) + + it('fails closed before resolution, DNS, or ImapFlow when referenced auth has no deployment actor', async () => { + const result = await imapPollingHandler.pollWebhook({ + webhookData: { + id: 'webhook-1', + deploymentVersionId: null, + providerConfig: { + host: '{{IMAP_HOST}}', + username: 'literal-user', + password: 'literal-password', + }, + } as never, + workflowData: { id: 'workflow-1', workspaceId: 'workspace-1' } as never, + requestId: 'request-1', + logger: mockLogger as never, + }) + + expect(result).toBe('failure') + expect(mockMarkWebhookFailed).toHaveBeenCalledWith('webhook-1', mockLogger) + expect(mockDbSelect).not.toHaveBeenCalled() + expect(mockResolveImapConnectionForActor).not.toHaveBeenCalled() + expect(mockCreateSecureImapClient).not.toHaveBeenCalled() + + const logged = JSON.stringify(mockLogger.error.mock.calls) + expect(logged).not.toContain('literal-user') + expect(logged).not.toContain('literal-password') + expect(logged).not.toContain('Referenced IMAP authentication requires redeployment') + }) +}) diff --git a/apps/sim/lib/webhooks/polling/imap.ts b/apps/sim/lib/webhooks/polling/imap.ts index 596f81f3a59..55f7f1b3b1d 100644 --- a/apps/sim/lib/webhooks/polling/imap.ts +++ b/apps/sim/lib/webhooks/polling/imap.ts @@ -1,9 +1,15 @@ +import { db } from '@sim/db' +import { workflowDeploymentVersion } from '@sim/db/schema' import type { Logger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import type { FetchMessageObject, MailboxLockObject } from 'imapflow' -import { ImapFlow } from 'imapflow' +import { and, eq } from 'drizzle-orm' +import type { FetchMessageObject, ImapFlow, MailboxLockObject } from 'imapflow' import { pollingIdempotency } from '@/lib/core/idempotency/service' -import { validateDatabaseHost } from '@/lib/core/security/input-validation.server' +import { + createSecureImapClient, + hasImapEnvironmentReferences, + normalizeLiteralImapConnection, + resolveImapConnectionForActor, +} from '@/lib/imap/connection.server' import { getProviderConfig, type PollingProviderHandler, @@ -71,6 +77,38 @@ interface ImapWebhookPayload { timestamp: string } +async function resolvePollingConnection( + webhookData: PollWebhookContext['webhookData'], + workflowData: PollWebhookContext['workflowData'], + config: ImapWebhookConfig +) { + if (!hasImapEnvironmentReferences(config)) { + return normalizeLiteralImapConnection(config) + } + + if (!webhookData.deploymentVersionId) { + throw new Error('Referenced IMAP authentication requires redeployment') + } + const [deployment] = await db + .select({ createdBy: workflowDeploymentVersion.createdBy }) + .from(workflowDeploymentVersion) + .where( + and( + eq(workflowDeploymentVersion.id, webhookData.deploymentVersionId), + eq(workflowDeploymentVersion.workflowId, workflowData.id) + ) + ) + .limit(1) + if (!deployment?.createdBy) { + throw new Error('Referenced IMAP authentication requires redeployment') + } + return resolveImapConnectionForActor({ + connection: config, + actorUserId: deployment.createdBy, + workspaceId: workflowData.workspaceId, + }) +} + export const imapPollingHandler: PollingProviderHandler = { provider: 'imap', label: 'IMAP', @@ -88,27 +126,8 @@ export const imapPollingHandler: PollingProviderHandler = { return 'failure' } - const hostValidation = await validateDatabaseHost(config.host, 'host') - if (!hostValidation.isValid) { - logger.error( - `[${requestId}] IMAP host validation failed for webhook ${webhookId}: ${hostValidation.error}` - ) - await markWebhookFailed(webhookId, logger) - return 'failure' - } - - const client = new ImapFlow({ - host: hostValidation.resolvedIP!, - servername: config.host, - port: config.port || 993, - secure: config.secure ?? true, - auth: { - user: config.username, - pass: config.password, - }, - tls: { rejectUnauthorized: true }, - logger: false, - }) + const resolvedConnection = await resolvePollingConnection(webhookData, workflowData, config) + const client = await createSecureImapClient(resolvedConnection) let emails: Awaited>['emails'] = [] let latestUidByMailbox: Record = {} @@ -182,7 +201,7 @@ export const imapPollingHandler: PollingProviderHandler = { throw innerError } } catch (error) { - logger.error(`[${requestId}] Error processing IMAP webhook ${webhookId}:`, error) + logger.error(`[${requestId}] Error processing IMAP webhook ${webhookId}`) await markWebhookFailed(webhookId, logger) return 'failure' } @@ -333,8 +352,8 @@ async function fetchNewEmails( } totalEmailsCollected++ } - } catch (mailboxError) { - logger.warn(`[${requestId}] Error processing mailbox ${mailboxPath}:`, mailboxError) + } catch { + logger.warn(`[${requestId}] Error processing mailbox ${mailboxPath}`) } } @@ -574,11 +593,8 @@ async function processEmails( currentOpenMailbox = email.mailboxPath } await client.messageFlagsAdd(email.uid, ['\\Seen'], { uid: true }) - } catch (flagError) { - logger.warn( - `[${requestId}] Failed to mark message ${email.uid} as read:`, - flagError - ) + } catch { + logger.warn(`[${requestId}] Failed to mark message ${email.uid} as read`) } } @@ -590,9 +606,8 @@ async function processEmails( `[${requestId}] Successfully processed email ${email.uid} from ${email.mailboxPath} for webhook ${webhookData.id}` ) processedCount++ - } catch (error) { - const errorMessage = getErrorMessage(error, 'Unknown error') - logger.error(`[${requestId}] Error processing email ${email.uid}:`, errorMessage) + } catch { + logger.error(`[${requestId}] Error processing email ${email.uid}`) failedCount++ } } diff --git a/apps/sim/lib/webhooks/providers/imap.test.ts b/apps/sim/lib/webhooks/providers/imap.test.ts new file mode 100644 index 00000000000..424ace3dccc --- /dev/null +++ b/apps/sim/lib/webhooks/providers/imap.test.ts @@ -0,0 +1,134 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockClose, + mockCreateSecureImapClient, + mockDbUpdate, + mockHasImapEnvironmentReferences, + mockLogger, + mockNormalizeLiteralImapConnection, + mockResolveImapConnectionForActor, +} = vi.hoisted(() => ({ + mockClose: vi.fn(), + mockCreateSecureImapClient: vi.fn(), + mockDbUpdate: vi.fn(), + mockHasImapEnvironmentReferences: vi.fn(), + mockLogger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + mockNormalizeLiteralImapConnection: vi.fn(), + mockResolveImapConnectionForActor: vi.fn(), +})) + +vi.mock('@sim/db', () => ({ + db: { update: mockDbUpdate }, +})) + +vi.mock('@sim/logger', () => ({ + createLogger: () => mockLogger, +})) + +vi.mock('@/lib/imap/connection.server', () => ({ + createSecureImapClient: mockCreateSecureImapClient, + hasImapEnvironmentReferences: mockHasImapEnvironmentReferences, + normalizeLiteralImapConnection: mockNormalizeLiteralImapConnection, + resolveImapConnectionForActor: mockResolveImapConnectionForActor, +})) + +import { imapHandler } from '@/lib/webhooks/providers/imap' + +const referenceConfig = { + host: '{{IMAP_HOST}}', + port: '{{IMAP_PORT}}', + secure: '{{IMAP_SECURE}}', + username: '{{IMAP_USERNAME}}', + password: '{{IMAP_PASSWORD}}', + mailbox: 'INBOX', +} + +describe('IMAP polling deployment policy', () => { + beforeEach(() => { + vi.clearAllMocks() + mockHasImapEnvironmentReferences.mockImplementation((connection: object) => + Object.values(connection).some( + (value) => typeof value === 'string' && /^\{\{[^{}]+\}\}$/.test(value) + ) + ) + mockNormalizeLiteralImapConnection.mockImplementation((connection) => ({ + ...connection, + port: 993, + secure: true, + })) + mockResolveImapConnectionForActor.mockResolvedValue({ + host: 'imap.example.com', + port: 143, + secure: false, + username: 'resolved-user', + password: 'resolved-password', + }) + mockCreateSecureImapClient.mockResolvedValue({ close: mockClose }) + }) + + it('validates references JIT but persists the unresolved reference expressions', async () => { + const persistProviderConfig = vi.fn().mockResolvedValue(true) + + await expect( + imapHandler.configurePolling!({ + webhook: { id: 'webhook-1', providerConfig: referenceConfig }, + requestId: 'request-1', + userId: 'actor-1', + workspaceId: 'workspace-1', + deploymentVersionId: 'deployment-1', + persistProviderConfig, + }) + ).resolves.toBe(true) + + expect(mockResolveImapConnectionForActor).toHaveBeenCalledWith({ + connection: referenceConfig, + actorUserId: 'actor-1', + workspaceId: 'workspace-1', + }) + expect(mockCreateSecureImapClient).toHaveBeenCalledWith( + expect.objectContaining({ username: 'resolved-user', password: 'resolved-password' }) + ) + expect(mockClose).toHaveBeenCalledOnce() + + const persisted = persistProviderConfig.mock.calls[0]?.[0] + expect(persisted).toMatchObject(referenceConfig) + expect(persisted.secure).toBe('{{IMAP_SECURE}}') + expect(persisted.port).toBe('{{IMAP_PORT}}') + expect(JSON.stringify(persisted)).not.toContain('resolved-password') + expect(mockDbUpdate).not.toHaveBeenCalled() + }) + + it('fails closed without logging raw connection errors or authentication values', async () => { + mockCreateSecureImapClient.mockRejectedValue( + new Error('provider echoed literal-user and literal-password') + ) + + await expect( + imapHandler.configurePolling!({ + webhook: { + id: 'webhook-1', + providerConfig: { + host: 'imap.example.com', + username: 'literal-user', + password: 'literal-password', + }, + }, + requestId: 'request-1', + userId: 'actor-1', + workspaceId: 'workspace-1', + }) + ).resolves.toBe(false) + + const logged = JSON.stringify(mockLogger.error.mock.calls) + expect(logged).not.toContain('literal-user') + expect(logged).not.toContain('literal-password') + expect(logged).not.toContain('provider echoed') + expect(mockNormalizeLiteralImapConnection).toHaveBeenCalledOnce() + expect(mockCreateSecureImapClient).toHaveBeenCalledOnce() + expect(mockDbUpdate).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/webhooks/providers/imap.ts b/apps/sim/lib/webhooks/providers/imap.ts index 201af195116..72716cef8c3 100644 --- a/apps/sim/lib/webhooks/providers/imap.ts +++ b/apps/sim/lib/webhooks/providers/imap.ts @@ -2,6 +2,12 @@ import { db } from '@sim/db' import { webhook } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { eq } from 'drizzle-orm' +import { + createSecureImapClient, + hasImapEnvironmentReferences, + normalizeLiteralImapConnection, + resolveImapConnectionForActor, +} from '@/lib/imap/connection.server' import type { FormatInputContext, FormatInputResult, @@ -39,6 +45,8 @@ export const imapHandler: WebhookProviderHandler = { async configurePolling({ webhook: webhookData, requestId, + userId, + workspaceId, persistProviderConfig, }: PollingConfigContext) { logger.info(`[${requestId}] Setting up IMAP polling for webhook ${webhookData.id}`) @@ -54,10 +62,35 @@ export const imapHandler: WebhookProviderHandler = { return false } + const connection = providerConfig as { + host: string + username: string + password: string + port?: string | number + secure?: boolean + } + const hasReferences = hasImapEnvironmentReferences(connection) + if (hasReferences && !userId) return false + const resolved = hasReferences + ? await resolveImapConnectionForActor({ + connection, + actorUserId: userId!, + workspaceId, + }) + : normalizeLiteralImapConnection(connection) + const client = await createSecureImapClient(resolved) + client.close() + const configuredProviderConfig = { ...providerConfig, - port: providerConfig.port || '993', - secure: providerConfig.secure !== false, + port: + providerConfig.port === undefined || providerConfig.port === '' + ? '993' + : providerConfig.port, + secure: + providerConfig.secure === undefined || providerConfig.secure === '' + ? true + : providerConfig.secure, mailbox: providerConfig.mailbox || 'INBOX', searchCriteria: providerConfig.searchCriteria || 'UNSEEN', markAsRead: providerConfig.markAsRead || false, @@ -78,11 +111,9 @@ export const imapHandler: WebhookProviderHandler = { `[${requestId}] Successfully configured IMAP polling for webhook ${webhookData.id}` ) return true - } catch (error: unknown) { - const err = error as Error + } catch { logger.error(`[${requestId}] Failed to configure IMAP polling`, { webhookId: webhookData.id, - error: err.message, }) return false } diff --git a/apps/sim/lib/webhooks/providers/types.ts b/apps/sim/lib/webhooks/providers/types.ts index 64da7506f3c..29e291d890f 100644 --- a/apps/sim/lib/webhooks/providers/types.ts +++ b/apps/sim/lib/webhooks/providers/types.ts @@ -85,6 +85,10 @@ export interface DeleteSubscriptionContext { export interface PollingConfigContext { webhook: Record requestId: string + /** Deployment actor used to validate reference-backed polling configuration. */ + userId?: string + workspaceId?: string | null + deploymentVersionId?: string | null /** * Stable registration preparation supplies a generation-fenced persistence callback. * Legacy callers omit it and retain the existing provider-owned write behavior. diff --git a/apps/sim/lib/webhooks/registration-service.ts b/apps/sim/lib/webhooks/registration-service.ts index 4a650143049..a32ab8932dd 100644 --- a/apps/sim/lib/webhooks/registration-service.ts +++ b/apps/sim/lib/webhooks/registration-service.ts @@ -163,6 +163,10 @@ async function createCandidateProviderState( const configured = await handler.configurePolling({ webhook: { ...webhookData, providerConfig }, requestId: input.requestId, + userId: input.userId, + workspaceId: + typeof input.workflow.workspaceId === 'string' ? input.workflow.workspaceId : null, + deploymentVersionId: input.fence.deploymentVersionId, persistProviderConfig: async (configuredProviderConfig) => { persistedProviderConfig = configuredProviderConfig await dependencies.checkpointCandidate({ diff --git a/apps/sim/lib/workflows/comparison/format-description.test.ts b/apps/sim/lib/workflows/comparison/format-description.test.ts index 037b685406e..ea5c1bc19f5 100644 --- a/apps/sim/lib/workflows/comparison/format-description.test.ts +++ b/apps/sim/lib/workflows/comparison/format-description.test.ts @@ -31,20 +31,13 @@ vi.mock('@/blocks/registry', () => ({ registry: {}, })) -vi.mock('@/lib/workflows/subblocks/context', () => ({ - buildSelectorContextFromBlock: vi.fn(() => ({})), -})) - vi.mock('@/hooks/queries/oauth/oauth-credentials', () => ({ fetchOAuthCredentialDetail: vi.fn(() => []), })) -vi.mock('@/hooks/selectors/registry', () => ({ - getSelectorDefinition: vi.fn(() => ({ fetchList: vi.fn(() => []) })), -})) - -vi.mock('@/hooks/selectors/resolution', () => ({ - resolveSelectorForSubBlock: vi.fn(), +vi.mock('@/lib/selectors/client/execute-selector', () => ({ + executeSelectorRequest: vi.fn(() => ({ kind: 'detail', item: null })), + loadAllSelectorOptions: vi.fn(() => []), })) import { WorkflowBuilder } from '@sim/testing' diff --git a/apps/sim/lib/workflows/comparison/resolve-values.ts b/apps/sim/lib/workflows/comparison/resolve-values.ts index f2b25b2b15b..5bcc6d3a769 100644 --- a/apps/sim/lib/workflows/comparison/resolve-values.ts +++ b/apps/sim/lib/workflows/comparison/resolve-values.ts @@ -1,13 +1,17 @@ import { createLogger } from '@sim/logger' import { truncate } from '@sim/utils/string' -import { buildSelectorContextFromBlock } from '@/lib/workflows/subblocks/context' +import { + executeSelectorRequest, + loadAllSelectorOptions, +} from '@/lib/selectors/client/execute-selector' +import { buildSelectorRawContext, projectSelectorContext } from '@/lib/selectors/context' +import { getSelectorManifestEntry, type SelectorKey } from '@/lib/selectors/manifest' +import type { SelectorContext, SelectorScope } from '@/lib/selectors/types' +import { getDependsOnFields } from '@/lib/workflows/subblocks/dependencies' import { getBlock } from '@/blocks/registry' import { SELECTOR_TYPES_HYDRATION_REQUIRED, type SubBlockConfig } from '@/blocks/types' import { isUuid } from '@/executor/constants' import { fetchOAuthCredentialDetail } from '@/hooks/queries/oauth/oauth-credentials' -import { getSelectorDefinition, loadAllSelectorOptions } from '@/hooks/selectors/registry' -import { resolveSelectorForSubBlock } from '@/hooks/selectors/resolution' -import type { SelectorContext, SelectorKey } from '@/hooks/selectors/types' import type { WorkflowState } from '@/stores/workflows/workflow/types' import { formatParameterLabel } from '@/tools/params' @@ -55,8 +59,8 @@ async function resolveCredential(credentialId: string, workflowId: string): Prom } return null - } catch (error) { - logger.warn('Failed to resolve credential', { credentialId, error }) + } catch { + logger.warn('Failed to resolve credential display label') return null } } @@ -65,18 +69,15 @@ async function resolveWorkflow(workflowId: string, workspaceId?: string): Promis if (!workspaceId) return null try { - const definition = getSelectorDefinition('sim.workflows') - if (definition.fetchById) { - const result = await definition.fetchById({ - key: 'sim.workflows', - context: { workspaceId }, - detailId: workflowId, - }) - return result?.label ?? null - } - return null - } catch (error) { - logger.warn('Failed to resolve workflow', { workflowId, error }) + const result = await executeSelectorRequest({ + selectorKey: 'sim.workflows', + scope: { kind: 'workspace', workspaceId }, + context: {}, + request: { kind: 'detail', id: workflowId }, + }) + return result.kind === 'detail' ? (result.item?.label ?? null) : null + } catch { + logger.warn('Failed to resolve workflow display label') return null } } @@ -84,30 +85,33 @@ async function resolveWorkflow(workflowId: string, workspaceId?: string): Promis async function resolveSelectorValue( value: string, selectorKey: SelectorKey, - selectorContext: SelectorContext + selectorContext: SelectorContext, + scope: SelectorScope ): Promise { try { - const definition = getSelectorDefinition(selectorKey) + const manifest = getSelectorManifestEntry(selectorKey) - if (definition.fetchById) { - const result = await definition.fetchById({ - key: selectorKey, + if (manifest.supportsDetail) { + const result = await executeSelectorRequest({ + selectorKey, + scope, context: selectorContext, - detailId: value, + request: { kind: 'detail', id: value }, }) - if (result?.label) { - return result.label + if (result.kind === 'detail' && result.item?.label) { + return result.item.label } } - const options = await loadAllSelectorOptions(definition, { - key: selectorKey, + const options = await loadAllSelectorOptions({ + selectorKey, + scope, context: selectorContext, }) const match = options.find((opt) => opt.id === value) return match?.label ?? null - } catch (error) { - logger.warn('Failed to resolve selector value', { value, selectorKey, error }) + } catch { + logger.warn('Failed to resolve selector display label', { selectorKey }) return null } } @@ -170,16 +174,19 @@ export function formatValueForDisplay(value: unknown): string { function extractSelectorContext( blockId: string, currentState: WorkflowState, - workflowId: string, - workspaceId?: string + selectorKey: SelectorKey, + subBlockConfig: SubBlockConfig ): SelectorContext { const block = currentState.blocks?.[blockId] - if (!block?.subBlocks) return { workflowId, workspaceId } - return buildSelectorContextFromBlock(block.type, block.subBlocks, { - workflowId, - workspaceId, + if (!block?.subBlocks) return {} + return buildSelectorRawContext({ + selectorKey, + blockType: block.type, + subBlocks: block.subBlocks, + dependsOn: getDependsOnFields(subBlockConfig.dependsOn), canonicalModes: block.data?.canonicalModes, triggerMode: block.triggerMode, + staticContext: { mimeType: subBlockConfig.mimeType }, }) } @@ -210,15 +217,6 @@ export async function resolveValueForDisplay( } const semanticFallback = getSemanticFallback(subBlockConfig) - const selectorCtx = context.blockId - ? extractSelectorContext( - context.blockId, - context.currentState, - context.workflowId, - context.workspaceId - ) - : { workflowId: context.workflowId, workspaceId: context.workspaceId } - const isCredentialField = subBlockConfig.type === 'oauth-input' || context.subBlockId === 'credential' @@ -231,7 +229,7 @@ export async function resolveValueForDisplay( } if (subBlockConfig.type === 'workflow-selector' && isUuid(value)) { - const label = await resolveWorkflow(value, selectorCtx.workspaceId) + const label = await resolveWorkflow(value, context.workspaceId) if (label) { return { original: value, displayLabel: label, resolved: true } } @@ -249,20 +247,27 @@ export async function resolveValueForDisplay( if (label) { return { original: value, displayLabel: label, resolved: true } } - } catch (error) { - logger.warn('Failed to resolve dropdown label', { - value, - subBlockId: context.subBlockId, - error, - }) + } catch { + logger.warn('Failed to resolve dropdown display label') } } if (SELECTOR_TYPES_HYDRATION_REQUIRED.includes(subBlockConfig.type)) { - const resolution = resolveSelectorForSubBlock(subBlockConfig, selectorCtx) - - if (resolution?.key) { - const label = await resolveSelectorValue(value, resolution.key, selectorCtx) + const selectorKey = subBlockConfig.selectorKey + const scope: SelectorScope | undefined = context.workflowId + ? { + kind: 'workflow', + workflowId: context.workflowId, + ...(context.workspaceId ? { workspaceId: context.workspaceId } : {}), + } + : context.workspaceId + ? { kind: 'workspace', workspaceId: context.workspaceId } + : undefined + if (selectorKey && scope) { + const selectorContext = context.blockId + ? extractSelectorContext(context.blockId, context.currentState, selectorKey, subBlockConfig) + : projectSelectorContext(selectorKey, { mimeType: subBlockConfig.mimeType }) + const label = await resolveSelectorValue(value, selectorKey, selectorContext, scope) if (label) { return { original: value, displayLabel: label, resolved: true } } diff --git a/apps/sim/lib/workflows/search-replace/indexer.ts b/apps/sim/lib/workflows/search-replace/indexer.ts index 8eb9e60b37e..192e5f5295d 100644 --- a/apps/sim/lib/workflows/search-replace/indexer.ts +++ b/apps/sim/lib/workflows/search-replace/indexer.ts @@ -3,6 +3,8 @@ import { forEachSearchOccurrence, projectEscapedMarkdownForSearch } from '@sim/u import { DEFAULT_SUBBLOCK_TYPE } from '@sim/workflow-persistence/subblocks' import type { SubBlockType } from '@sim/workflow-types/blocks' import { isWorkflowBlockProtected } from '@sim/workflow-types/workflow' +import { buildSelectorContextFromValues } from '@/lib/selectors/context' +import type { SelectorKey } from '@/lib/selectors/manifest' import { COMPARISON_OPERATORS, LOGICAL_OPERATORS } from '@/lib/table/query-builder/constants' import { getSearchableJsonStringLeaves, @@ -22,10 +24,10 @@ import type { WorkflowSearchBlockState, WorkflowSearchIndexerOptions, WorkflowSearchMatch, + WorkflowSearchSelectorContext, WorkflowSearchValuePath, } from '@/lib/workflows/search-replace/types' import { pathToKey, walkStringValues } from '@/lib/workflows/search-replace/value-walker' -import { SELECTOR_CONTEXT_FIELDS } from '@/lib/workflows/subblocks/context' import { getTransitiveSubBlockDependents } from '@/lib/workflows/subblocks/dependencies' import { resolveStoredToolName } from '@/lib/workflows/subblocks/display' import { @@ -48,7 +50,6 @@ import { type ParsedStoredTool, parseStoredToolInputValue } from '@/lib/workflow import { getBlock } from '@/blocks/registry' import type { SubBlockConfig } from '@/blocks/types' import { isReference } from '@/executor/constants' -import type { SelectorContext } from '@/hooks/selectors/types' import { formatParameterLabel, getSubBlocksForToolInput, @@ -690,7 +691,7 @@ export interface ResolvedToolInputParamConfig { value: unknown /** False when the codec had no registered tool definition and inferred only a generic shape. */ authoritative: boolean - selectorContext?: SelectorContext + selectorContext?: WorkflowSearchSelectorContext dependentValuePaths?: WorkflowSearchValuePath[] } @@ -782,6 +783,7 @@ export function getToolInputParamConfigs({ ? buildSelectorContext({ subBlockConfig: config, subBlockValues: values, + contextConfigs: [config], canonicalIndex: fallbackCanonicalIndex, canonicalModes: scopedCanonicalModes, }) @@ -837,6 +839,7 @@ export function getToolInputParamConfigs({ ? buildSelectorContext({ subBlockConfig: config, subBlockValues: values, + contextConfigs: allToolSubBlocks, canonicalIndex: toolCanonicalIndex, canonicalModes: scopedCanonicalModes, }) @@ -856,6 +859,7 @@ export function getToolInputParamConfigs({ ? buildSelectorContext({ subBlockConfig: config, subBlockValues: values, + contextConfigs: allToolSubBlocks, canonicalIndex: toolCanonicalIndex, canonicalModes: scopedCanonicalModes, }) @@ -869,6 +873,7 @@ export function getToolInputParamConfigs({ function buildSelectorContext({ subBlockConfig, subBlockValues, + contextConfigs, canonicalIndex, canonicalModes, workspaceId, @@ -876,22 +881,41 @@ function buildSelectorContext({ }: { subBlockConfig?: WorkflowSearchSubBlockConfig subBlockValues: Record + contextConfigs: SubBlockConfig[] canonicalIndex: ReturnType canonicalModes?: CanonicalModeOverrides workspaceId?: string workflowId?: string -}): SelectorContext { - const context: SelectorContext = {} +}): WorkflowSearchSelectorContext { + const context: WorkflowSearchSelectorContext = {} if (workspaceId) context.workspaceId = workspaceId if (workflowId) { context.workflowId = workflowId context.excludeWorkflowId = workflowId } - if (subBlockConfig?.mimeType) context.mimeType = subBlockConfig.mimeType - const { allDependsOnFields } = parseDependsOn(subBlockConfig?.dependsOn) + if (subBlockConfig?.selectorKey) { + const projected = buildSelectorContextFromValues({ + selectorKey: subBlockConfig.selectorKey as SelectorKey, + contextConfigs, + values: subBlockValues, + dependsOn: allDependsOnFields, + canonicalIndex, + canonicalModes, + staticContext: { mimeType: subBlockConfig.mimeType }, + }) + return { + ...projected, + ...(context.excludeWorkflowId ? { excludeWorkflowId: context.excludeWorkflowId } : {}), + ...(context.workflowId ? { workflowId: context.workflowId } : {}), + ...(context.workspaceId ? { workspaceId: context.workspaceId } : {}), + } + } + + if (subBlockConfig?.mimeType) context.mimeType = subBlockConfig.mimeType + for (const subBlockId of allDependsOnFields) { const value = normalizeDependencyValue( resolveDependencyValue(subBlockId, subBlockValues, canonicalIndex, canonicalModes) @@ -906,11 +930,8 @@ function buildSelectorContext({ context.mcpServerId = stringValue continue } - if (SELECTOR_CONTEXT_FIELDS.has(canonicalKey as keyof SelectorContext)) { - context[canonicalKey as keyof SelectorContext] = stringValue - } + context[canonicalKey as keyof WorkflowSearchSelectorContext] = stringValue } - return context } @@ -918,6 +939,7 @@ function buildSearchSelectorContext({ block, subBlockConfig, subBlockValues, + contextConfigs, canonicalIndex, workspaceId, workflowId, @@ -925,13 +947,15 @@ function buildSearchSelectorContext({ block: WorkflowSearchBlockState subBlockConfig?: WorkflowSearchSubBlockConfig subBlockValues: Record + contextConfigs: SubBlockConfig[] canonicalIndex: ReturnType workspaceId?: string workflowId?: string -}): SelectorContext { +}): WorkflowSearchSelectorContext { return buildSelectorContext({ subBlockConfig, subBlockValues, + contextConfigs, canonicalIndex, canonicalModes: getSearchCanonicalModes(block), workspaceId, @@ -1556,6 +1580,7 @@ export function indexWorkflowSearchMatches( block, subBlockConfig, subBlockValues, + contextConfigs: subBlockConfigs, canonicalIndex, workspaceId, workflowId, diff --git a/apps/sim/lib/workflows/search-replace/resources/references.ts b/apps/sim/lib/workflows/search-replace/resources/references.ts index cfd718c833d..e96bc6169ff 100644 --- a/apps/sim/lib/workflows/search-replace/resources/references.ts +++ b/apps/sim/lib/workflows/search-replace/resources/references.ts @@ -7,11 +7,11 @@ import { import type { WorkflowSearchRange, WorkflowSearchResourceMeta, + WorkflowSearchSelectorContext, } from '@/lib/workflows/search-replace/types' import type { SubBlockConfig } from '@/blocks/types' import { normalizeName, REFERENCE } from '@/executor/constants' import { createEnvVarPattern, createReferencePattern } from '@/executor/utils/reference-validation' -import type { SelectorContext } from '@/hooks/selectors/types' export interface ParsedInlineReference { kind: 'environment' | 'workflow-reference' @@ -136,7 +136,7 @@ export function resolveInlineReferenceSearchText( export function parseStructuredResourceReferences( value: unknown, subBlockConfig?: Pick, - selectorContext?: SelectorContext + selectorContext?: WorkflowSearchSelectorContext ): StructuredResourceReference[] { return parseWorkflowSearchSubBlockResources(value, subBlockConfig, selectorContext) } diff --git a/apps/sim/lib/workflows/search-replace/resources/registry.ts b/apps/sim/lib/workflows/search-replace/resources/registry.ts index 37f19619ea4..bf61b54838f 100644 --- a/apps/sim/lib/workflows/search-replace/resources/registry.ts +++ b/apps/sim/lib/workflows/search-replace/resources/registry.ts @@ -4,10 +4,10 @@ import type { WorkflowSearchMatch, WorkflowSearchMatchKind, WorkflowSearchResourceMeta, + WorkflowSearchSelectorContext, WorkflowSearchValuePath, } from '@/lib/workflows/search-replace/types' import type { SubBlockConfig } from '@/blocks/types' -import type { SelectorContext } from '@/hooks/selectors/types' export type StructuredWorkflowSearchResourceKind = Exclude< WorkflowSearchMatchKind, @@ -21,7 +21,7 @@ interface ResourceCodecParseParams { SubBlockConfig, 'type' | 'serviceId' | 'selectorKey' | 'requiredScopes' | 'multiSelect' | 'multiple' > - selectorContext?: SelectorContext + selectorContext?: WorkflowSearchSelectorContext } export interface StructuredResourceReference { @@ -69,7 +69,7 @@ function createResourceMeta({ kind: StructuredWorkflowSearchResourceKind rawValue: string subBlockConfig: Pick - selectorContext?: SelectorContext + selectorContext?: WorkflowSearchSelectorContext }): WorkflowSearchResourceMeta { const resource: WorkflowSearchResourceMeta = { kind, @@ -409,7 +409,7 @@ export function parseWorkflowSearchSubBlockResources( SubBlockConfig, 'type' | 'serviceId' | 'selectorKey' | 'requiredScopes' | 'multiSelect' | 'multiple' >, - selectorContext?: SelectorContext + selectorContext?: WorkflowSearchSelectorContext ): StructuredResourceReference[] { const definition = getWorkflowSearchSubBlockResourceDefinition(subBlockConfig) if (!definition || !subBlockConfig) return [] diff --git a/apps/sim/lib/workflows/search-replace/types.ts b/apps/sim/lib/workflows/search-replace/types.ts index 4eefe464a80..4c3ff0ca36d 100644 --- a/apps/sim/lib/workflows/search-replace/types.ts +++ b/apps/sim/lib/workflows/search-replace/types.ts @@ -1,11 +1,11 @@ import type { SubBlockType } from '@sim/workflow-types/blocks' +import type { SelectorContext } from '@/lib/selectors/types' import type { WorkflowSearchSubflowEditableValue, WorkflowSearchSubflowFieldId, } from '@/lib/workflows/search-replace/subflow-fields' import type { StoredCustomToolRecord } from '@/lib/workflows/subblocks/display' import type { SubBlockConfig } from '@/blocks/types' -import type { SelectorContext } from '@/hooks/selectors/types' import type { BlockState, SubBlockState } from '@/stores/workflows/workflow/types' export type WorkflowSearchMode = 'text' | 'resource' | 'all' @@ -26,6 +26,14 @@ export type WorkflowSearchMatchKind = export type WorkflowSearchValuePath = Array +/** Raw selector inputs plus the resource scope needed by the client transport. */ +export interface WorkflowSearchSelectorContext extends SelectorContext { + workflowId?: string + workspaceId?: string + /** Search metadata for MCP tools; never forwarded to selectors.execute. */ + mcpServerId?: string +} + export interface WorkflowSearchRange { start: number end: number @@ -36,7 +44,7 @@ export interface WorkflowSearchResourceMeta { providerId?: string serviceId?: string selectorKey?: string - selectorContext?: SelectorContext + selectorContext?: WorkflowSearchSelectorContext resourceGroupKey?: string requiredScopes?: string[] token?: string @@ -119,7 +127,7 @@ export interface WorkflowSearchReplacementOption { providerId?: string serviceId?: string selectorKey?: string - selectorContext?: SelectorContext + selectorContext?: WorkflowSearchSelectorContext resourceGroupKey?: string } diff --git a/apps/sim/lib/workflows/subblocks/context.test.ts b/apps/sim/lib/workflows/subblocks/context.test.ts index 554605d084d..34856bcd7b5 100644 --- a/apps/sim/lib/workflows/subblocks/context.test.ts +++ b/apps/sim/lib/workflows/subblocks/context.test.ts @@ -5,9 +5,9 @@ import { afterAll, describe, expect, it, vi } from 'vitest' vi.unmock('@/blocks/registry') +import { isSelectorReady } from '@/lib/selectors/manifest' import * as blocksBarrel from '@/blocks' import { getAllBlocks, getBlock as getRealBlock } from '@/blocks/registry' -import { bitbucketSelectors } from '@/hooks/selectors/providers/bitbucket/selectors' import { buildSelectorContextFromBlock, getSelectorContextSubBlocks, @@ -190,6 +190,91 @@ describe('buildSelectorContextFromBlock', () => { ).toBeUndefined() }) + it('preserves exact environment references through the strict selector context path', () => { + const subBlocks = subBlocksFromValues({ + credential: '{{GMAIL_BASIC_CREDENTIAL}}', + manualCredential: '{{GMAIL_SHARED_CREDENTIAL_ID}}', + }) + + expect( + buildSelectorContextFromBlock('gmail', subBlocks, { + selectorKey: 'gmail.labels', + dependsOn: ['credential', 'manualCredential'], + }).oauthCredential + ).toBe('{{GMAIL_BASIC_CREDENTIAL}}') + expect( + buildSelectorContextFromBlock('gmail', subBlocks, { + selectorKey: 'gmail.labels', + dependsOn: ['credential', 'manualCredential'], + canonicalModes: { oauthCredential: 'advanced' }, + }).oauthCredential + ).toBe('{{GMAIL_SHARED_CREDENTIAL_ID}}') + }) + + it('includes Google impersonation as an explicit active selector hint', () => { + const context = buildSelectorContextFromBlock( + 'gmail', + subBlocksFromValues({ + credential: '{{GMAIL_CREDENTIAL_ID}}', + impersonateUserEmail: '{{GMAIL_IMPERSONATE_EMAIL}}', + }), + { + selectorKey: 'gmail.labels', + dependsOn: ['credential'], + } + ) + + expect(context).toEqual({ + oauthCredential: '{{GMAIL_CREDENTIAL_ID}}', + impersonateUserEmail: '{{GMAIL_IMPERSONATE_EMAIL}}', + }) + }) + + it('projects Slack raw-token and trigger credential sources into the canonical field', () => { + const action = buildSelectorContextFromBlock( + 'slack', + subBlocksFromValues({ + authMethod: 'bot_token', + credential: 'dormant-oauth', + botToken: '{{SLACK_BOT_TOKEN}}', + }), + { + selectorKey: 'slack.channels', + dependsOn: ['authMethod', 'credential', 'botToken'], + } + ) + expect(action.oauthCredential).toBe('{{SLACK_BOT_TOKEN}}') + + const trigger = buildSelectorContextFromBlock( + 'slack', + subBlocksFromValues({ + eventType: 'message.channels', + customBotCredential: '{{SLACK_TRIGGER_CREDENTIAL}}', + }), + { + selectorKey: 'slack.channels', + dependsOn: ['customBotCredential'], + triggerMode: true, + } + ) + expect(trigger.oauthCredential).toBe('{{SLACK_TRIGGER_CREDENTIAL}}') + }) + + it('projects the optional Microsoft Excel drive cascade input', () => { + const excel = buildSelectorContextFromBlock( + 'microsoft_excel', + subBlocksFromValues({ + credential: 'excel-credential', + driveId: '{{SHAREPOINT_DRIVE_ID}}', + }), + { + selectorKey: 'microsoft.excel', + dependsOn: ['credential', 'driveId'], + } + ) + expect(excel.driveId).toBe('{{SHAREPOINT_DRIVE_ID}}') + }) + it('uses trigger credentials with and without canonical metadata after action conversion', () => { const clickupValues = { selectedTriggerId: 'clickup_task_created', @@ -221,6 +306,25 @@ describe('buildSelectorContextFromBlock', () => { ) }) + it('uses only active trigger dependencies in the strict selector context path', () => { + const context = buildSelectorContextFromBlock( + 'clickup', + subBlocksFromValues({ + selectedTriggerId: 'clickup_task_created', + credential: 'dormant-action', + triggerCredentials: '{{CLICKUP_SHARED_CREDENTIAL}}', + teamId: '', + }), + { + selectorKey: 'clickup.spaces', + dependsOn: ['triggerCredentials', 'teamId'], + triggerMode: true, + } + ) + + expect(context).toEqual({ oauthCredential: '{{CLICKUP_SHARED_CREDENTIAL}}' }) + }) + it('does not leak a dormant action credential when an unmapped trigger credential is blank', () => { const ctx = buildSelectorContextFromBlock( 'airtable', @@ -258,7 +362,7 @@ describe('buildSelectorContextFromBlock', () => { oauthCredential: 'credential-1', workspaceSlug: 'acme-platform', }) - expect(bitbucketSelectors['bitbucket.repositories'].enabled({ context } as never)).toBe(true) + expect(isSelectorReady('bitbucket.repositories', context)).toBe(true) }) it('should ignore subblock keys not in SELECTOR_CONTEXT_FIELDS', () => { @@ -274,6 +378,7 @@ describe('buildSelectorContextFromBlock', () => { describe('SELECTOR_CONTEXT_FIELDS validation', () => { it('every entry must be a canonicalParamId (if a canonical pair exists) or a direct subblock ID', () => { + const explicitSurfaceFields = new Set(['excludeWorkflowId']) const allCanonicalParamIds = new Set() const allSubBlockIds = new Set() const idsInCanonicalPairs = new Set() @@ -299,6 +404,7 @@ describe('SELECTOR_CONTEXT_FIELDS validation', () => { for (const field of SELECTOR_CONTEXT_FIELDS) { const f = field as string + if (explicitSurfaceFields.has(f)) continue if (allCanonicalParamIds.has(f)) continue if (idsInCanonicalPairs.has(f)) { diff --git a/apps/sim/lib/workflows/subblocks/context.ts b/apps/sim/lib/workflows/subblocks/context.ts index fe7ff8eeec4..ca14a0fb749 100644 --- a/apps/sim/lib/workflows/subblocks/context.ts +++ b/apps/sim/lib/workflows/subblocks/context.ts @@ -1,62 +1,22 @@ -import { getBlock } from '@/blocks' -import type { SubBlockConfig } from '@/blocks/types' -import { isReference } from '@/executor/constants' -import type { SelectorContext } from '@/hooks/selectors/types' -import type { SubBlockState } from '@/stores/workflows/workflow/types' +import { + buildSelectorRawContext, + getSelectorContextSubBlocks as getSharedSelectorContextSubBlocks, + SELECTOR_CONTEXT_FIELDS, +} from '@/lib/selectors/context' +import type { SelectorKey } from '@/lib/selectors/manifest' +import type { SelectorContext } from '@/lib/selectors/types' import { buildCanonicalIndex, buildSubBlockValues, type CanonicalModeOverrides, - evaluateSubBlockCondition, resolveActiveCanonicalValue, -} from './visibility' +} from '@/lib/workflows/subblocks/visibility' +import { getBlock } from '@/blocks' +import type { SubBlockConfig } from '@/blocks/types' +import { isReference } from '@/executor/constants' +import type { SubBlockState } from '@/stores/workflows/workflow/types' -/** - * Canonical param IDs (or raw subblock IDs) that correspond to SelectorContext fields. - * A subblock's resolved canonical key is set on the context only if it appears here. - */ -export const SELECTOR_CONTEXT_FIELDS = new Set([ - 'oauthCredential', - 'domain', - 'teamId', - 'projectId', - 'knowledgeBaseId', - 'planId', - 'siteId', - 'collectionId', - 'spreadsheetId', - 'driveId', - 'fileId', - 'baseId', - 'datasetId', - 'serviceDeskId', - 'impersonateUserEmail', - 'boardId', - 'spaceId', - 'listSpaceId', - 'folderId', - 'awsAccessKeyId', - 'awsSecretAccessKey', - 'awsRegion', - 'logGroupName', - 'tableId', - 'jobId', - 'orgId', - 'database', - 'schema', - 'workspaceSlug', - 'objectType', - 'customObjectTypeId', - 'pipelineId', - 'environmentType', - 'credentialGroupId', - 'language', - 'host', - 'port', - 'secure', - 'username', - 'password', -]) +export { SELECTOR_CONTEXT_FIELDS } /** * Selects the block fields allowed to contribute to selector context for the active mode. @@ -66,12 +26,7 @@ export function getSelectorContextSubBlocks( values: Record, triggerMode?: boolean ): SubBlockConfig[] { - if (!triggerMode) return subBlocks - return subBlocks.filter( - (subBlock) => - (subBlock.mode === 'trigger' || subBlock.mode === 'trigger-advanced') && - evaluateSubBlockCondition(subBlock.condition, values) - ) + return getSharedSelectorContextSubBlocks(subBlocks, values, triggerMode) } /** @@ -90,74 +45,65 @@ export function buildSelectorContextFromBlock( workspaceId?: string canonicalModes?: CanonicalModeOverrides triggerMode?: boolean + selectorKey?: SelectorKey + dependsOn?: readonly string[] + staticContext?: Readonly> } ): SelectorContext { - const context: SelectorContext = {} - if (opts?.workflowId) context.workflowId = opts.workflowId - if (opts?.workspaceId) context.workspaceId = opts.workspaceId + if (!opts?.selectorKey) { + const context: SelectorContext & { workflowId?: string; workspaceId?: string } = {} + if (opts?.workflowId) context.workflowId = opts.workflowId + if (opts?.workspaceId) context.workspaceId = opts.workspaceId - const blockConfig = getBlock(blockType) - if (!blockConfig) return context + const blockConfig = getBlock(blockType) + if (!blockConfig) return context + const values = buildSubBlockValues(subBlocks) + const configs = getSelectorContextSubBlocks(blockConfig.subBlocks, values, opts?.triggerMode) + const configById = new Map(configs.map((config) => [config.id, config])) + const canonicalIndex = buildCanonicalIndex(configs) + const resolvedGroups = new Set() - const values = buildSubBlockValues(subBlocks) - const contextConfigs = getSelectorContextSubBlocks( - blockConfig.subBlocks, - values, - opts?.triggerMode - ) - const canonicalIndex = buildCanonicalIndex(contextConfigs) - const contextSubBlockIds = opts?.triggerMode - ? new Set(contextConfigs.map((subBlock) => subBlock.id)) - : undefined - const resolvedGroups = new Set() - - const setField = (key: string, value: unknown) => { - if (value === null || value === undefined) return - const strValue = typeof value === 'string' ? value : String(value) - if (!strValue) return - // A `` reference only resolves at run time; handing the literal text to a - // selector would issue a request for a resource that cannot exist (mirrors useSelectorSetup). - if (isReference(strValue)) return - if (SELECTOR_CONTEXT_FIELDS.has(key as keyof SelectorContext)) { - context[key as keyof SelectorContext] = strValue + const setField = (field: string, value: unknown) => { + if (!SELECTOR_CONTEXT_FIELDS.has(field as keyof SelectorContext)) return + if (value === null || value === undefined) return + const normalized = typeof value === 'string' ? value : String(value) + if (!normalized || isReference(normalized)) return + context[field as keyof SelectorContext] = normalized } - } - for (const [subBlockId, subBlock] of Object.entries(subBlocks)) { - if (contextSubBlockIds && !contextSubBlockIds.has(subBlockId)) continue - const canonicalId = canonicalIndex.canonicalIdBySubBlockId[subBlockId] - if (canonicalId) { - // A canonical group resolves to its ACTIVE member only (no last-write-wins between a - // basic/advanced pair when both hold values), honoring an explicit mode override. + for (const [subBlockId, subBlock] of Object.entries(subBlocks)) { + if (!configById.has(subBlockId)) continue + const canonicalId = canonicalIndex.canonicalIdBySubBlockId[subBlockId] + if (!canonicalId) { + setField(subBlockId, subBlock.value) + continue + } if (resolvedGroups.has(canonicalId)) continue resolvedGroups.add(canonicalId) - const group = canonicalIndex.groupsById[canonicalId] - setField(canonicalId, resolveActiveCanonicalValue(group, values, opts?.canonicalModes)) - continue + setField( + canonicalId, + resolveActiveCanonicalValue( + canonicalIndex.groupsById[canonicalId], + values, + opts?.canonicalModes + ) + ) } - setField(subBlockId, subBlock?.value) - } - // A credential field IS the oauth credential, whatever the block calls its subblock. Most - // blocks say so with `canonicalParamId: 'oauthCredential'`, but that id is also the block's - // serialized param name — so requiring it would mean renaming a shipped block's param just - // to make its pickers resolvable, which is not a rename any picker should be able to force. - // Reading it off the subblock TYPE keeps the two decisions independent. - // - // Only fills a gap: a block that does declare the canonical id has already set it above, - // including the basic/advanced active-member resolution this loop cannot express. - if (!context.oauthCredential && !resolvedGroups.has('oauthCredential')) { - for (const [subBlockId, subBlock] of Object.entries(subBlocks)) { - if (contextConfigs.find((cfg) => cfg.id === subBlockId)?.type !== 'oauth-input') { - continue - } - const value = subBlock?.value - if (typeof value === 'string' && value) { - context.oauthCredential = value - break - } + if (!context.oauthCredential && !resolvedGroups.has('oauthCredential')) { + const credential = configs.find((config) => config.type === 'oauth-input') + if (credential) setField('oauthCredential', subBlocks[credential.id]?.value) } + return context } - return context + return buildSelectorRawContext({ + selectorKey: opts.selectorKey, + blockType, + subBlocks, + dependsOn: opts.dependsOn, + canonicalModes: opts.canonicalModes, + triggerMode: opts.triggerMode, + staticContext: opts.staticContext, + }) } diff --git a/apps/sim/tools/cloudwatch/listing.ts b/apps/sim/tools/cloudwatch/listing.ts new file mode 100644 index 00000000000..097a2bfd0b9 --- /dev/null +++ b/apps/sim/tools/cloudwatch/listing.ts @@ -0,0 +1,122 @@ +import { + CloudWatchLogsClient, + DescribeLogGroupsCommand, + DescribeLogStreamsCommand, +} from '@aws-sdk/client-cloudwatch-logs' + +const PAGE_SIZE = 50 +const MAX_PAGES = 20 + +export interface CloudWatchListingCredentials { + region: string + accessKeyId: string + secretAccessKey: string +} + +export interface DescribedLogGroup { + logGroupName: string + arn: string + storedBytes: number + retentionInDays: number | undefined + creationTime: number | undefined +} + +export interface DescribedLogStream { + logStreamName: string + lastEventTimestamp: number | undefined + firstEventTimestamp: number | undefined + creationTime: number | undefined + storedBytes: number +} + +function createClient(credentials: CloudWatchListingCredentials): CloudWatchLogsClient { + return new CloudWatchLogsClient({ + region: credentials.region, + credentials: { + accessKeyId: credentials.accessKeyId, + secretAccessKey: credentials.secretAccessKey, + }, + }) +} + +export async function listCloudWatchLogGroups(input: { + credentials: CloudWatchListingCredentials + prefix?: string + limit?: number + signal?: AbortSignal +}): Promise { + const client = createClient(input.credentials) + try { + const groups: DescribedLogGroup[] = [] + let nextToken: string | undefined + for (let page = 0; page < MAX_PAGES; page += 1) { + const remaining = input.limit === undefined ? PAGE_SIZE : input.limit - groups.length + if (remaining <= 0) break + const response = await client.send( + new DescribeLogGroupsCommand({ + ...(input.prefix ? { logGroupNamePrefix: input.prefix } : {}), + limit: Math.min(PAGE_SIZE, remaining), + ...(nextToken ? { nextToken } : {}), + }), + input.signal ? { abortSignal: input.signal } : undefined + ) + groups.push( + ...(response.logGroups ?? []).map((group) => ({ + logGroupName: group.logGroupName ?? '', + arn: group.arn ?? '', + storedBytes: group.storedBytes ?? 0, + retentionInDays: group.retentionInDays, + creationTime: group.creationTime, + })) + ) + nextToken = response.nextToken + if (!nextToken) break + } + return input.limit === undefined ? groups : groups.slice(0, input.limit) + } finally { + client.destroy() + } +} + +export async function listCloudWatchLogStreams(input: { + credentials: CloudWatchListingCredentials + logGroupName: string + prefix?: string + limit?: number + signal?: AbortSignal +}): Promise { + const client = createClient(input.credentials) + try { + const streams: DescribedLogStream[] = [] + let nextToken: string | undefined + for (let page = 0; page < MAX_PAGES; page += 1) { + const remaining = input.limit === undefined ? PAGE_SIZE : input.limit - streams.length + if (remaining <= 0) break + const response = await client.send( + new DescribeLogStreamsCommand({ + logGroupName: input.logGroupName, + ...(input.prefix + ? { orderBy: 'LogStreamName', logStreamNamePrefix: input.prefix } + : { orderBy: 'LastEventTime', descending: true }), + limit: Math.min(PAGE_SIZE, remaining), + ...(nextToken ? { nextToken } : {}), + }), + input.signal ? { abortSignal: input.signal } : undefined + ) + streams.push( + ...(response.logStreams ?? []).map((stream) => ({ + logStreamName: stream.logStreamName ?? '', + lastEventTimestamp: stream.lastEventTimestamp, + firstEventTimestamp: stream.firstEventTimestamp, + creationTime: stream.creationTime, + storedBytes: stream.storedBytes ?? 0, + })) + ) + nextToken = response.nextToken + if (!nextToken) break + } + return input.limit === undefined ? streams : streams.slice(0, input.limit) + } finally { + client.destroy() + } +} diff --git a/apps/sim/tools/confluence/client.ts b/apps/sim/tools/confluence/client.ts new file mode 100644 index 00000000000..bc896e01f16 --- /dev/null +++ b/apps/sim/tools/confluence/client.ts @@ -0,0 +1,17 @@ +export function fetchConfluencePage(input: { + cloudId: string + pageId: string + accessToken: string + signal?: AbortSignal +}): Promise { + const url = `https://api.atlassian.com/ex/confluence/${input.cloudId}/wiki/api/v2/pages/${input.pageId}?body-format=storage` + return fetch(url, { + method: 'GET', + headers: { + Accept: 'application/json', + Authorization: `Bearer ${input.accessToken}`, + }, + redirect: 'error', + signal: input.signal, + }) +} diff --git a/scripts/check-fork-dependent-coverage.ts b/scripts/check-fork-dependent-coverage.ts index 0e6bafe898b..a7dc198b8cd 100644 --- a/scripts/check-fork-dependent-coverage.ts +++ b/scripts/check-fork-dependent-coverage.ts @@ -88,7 +88,7 @@ function main(): void { [ '', 'Each needs one of:', - " - selectorKey: '' register the list in hooks/selectors/ (the usual answer for a picker)", + " - selectorKey: '' register the key in the selector manifest and server attachment (the usual answer for a picker)", ' - a canonical pair whose basic member is a selector (the manual member is verbatim)', ' - type short-input / long-input, which the sync modal renders as a text field', ' - a preserved name-based type in PRESERVED_NAME_BASED_DEPENDENT_TYPES', diff --git a/scripts/check-tool-registry-boundary.baseline.json b/scripts/check-tool-registry-boundary.baseline.json index 8bf98124ed6..7fa90f1ccca 100644 --- a/scripts/check-tool-registry-boundary.baseline.json +++ b/scripts/check-tool-registry-boundary.baseline.json @@ -6,68 +6,68 @@ }, "entries": { "app/api/v2/blocks/[blockId]/route.ts": { - "modules": 1652, + "modules": 1572, "gateways": { - "apps/sim/blocks/registry.ts": 532, "apps/sim/triggers/index.ts": 474, "apps/sim/triggers/registry.ts": 472, - "apps/sim/lib/api/server/routes/index.ts": 364, - "apps/sim/lib/api/server/routes/internal-json-route.ts": 322, - "apps/sim/lib/auth/index.ts": 311, - "apps/sim/blocks/blocks/credential-group.ts": 189, - "apps/sim/stores/workflows/registry/store.ts": 169 + "apps/sim/blocks/registry.ts": 444, + "apps/sim/lib/api/server/routes/index.ts": 370, + "apps/sim/lib/api/server/routes/internal-json-route.ts": 329, + "apps/sim/lib/auth/index.ts": 317, + "apps/sim/lib/webhooks/providers/index.ts": 110, + "apps/sim/lib/webhooks/providers/registry.ts": 108 } }, "app/api/v2/blocks/route.ts": { - "modules": 1651, + "modules": 1571, "gateways": { - "apps/sim/blocks/registry.ts": 532, "apps/sim/triggers/index.ts": 474, "apps/sim/triggers/registry.ts": 472, - "apps/sim/lib/api/server/routes/index.ts": 357, - "apps/sim/lib/api/server/routes/internal-json-route.ts": 324, - "apps/sim/lib/auth/index.ts": 313, - "apps/sim/blocks/blocks/credential-group.ts": 189, - "apps/sim/stores/workflows/registry/store.ts": 169 + "apps/sim/blocks/registry.ts": 444, + "apps/sim/lib/api/server/routes/index.ts": 363, + "apps/sim/lib/api/server/routes/internal-json-route.ts": 331, + "apps/sim/lib/auth/index.ts": 319, + "apps/sim/lib/webhooks/providers/index.ts": 110, + "apps/sim/lib/webhooks/providers/registry.ts": 108 } }, "app/api/v2/connector-types/route.ts": { - "modules": 1714, + "modules": 1634, "gateways": { - "apps/sim/blocks/registry.ts": 532, "apps/sim/triggers/index.ts": 474, "apps/sim/triggers/registry.ts": 472, - "apps/sim/lib/api/server/routes/index.ts": 366, - "apps/sim/lib/api/server/routes/internal-json-route.ts": 324, - "apps/sim/lib/auth/index.ts": 313, - "apps/sim/blocks/blocks/credential-group.ts": 189, - "apps/sim/stores/workflows/registry/store.ts": 169 + "apps/sim/blocks/registry.ts": 444, + "apps/sim/lib/api/server/routes/index.ts": 372, + "apps/sim/lib/api/server/routes/internal-json-route.ts": 331, + "apps/sim/lib/auth/index.ts": 319, + "apps/sim/lib/webhooks/providers/index.ts": 110, + "apps/sim/lib/webhooks/providers/registry.ts": 108 } }, "app/api/v2/tools/[toolId]/route.ts": { - "modules": 1649, + "modules": 1569, "gateways": { - "apps/sim/blocks/registry.ts": 532, "apps/sim/triggers/index.ts": 474, "apps/sim/triggers/registry.ts": 472, - "apps/sim/lib/api/server/routes/index.ts": 364, - "apps/sim/lib/api/server/routes/internal-json-route.ts": 322, - "apps/sim/lib/auth/index.ts": 311, - "apps/sim/blocks/blocks/credential-group.ts": 189, - "apps/sim/stores/workflows/registry/store.ts": 169 + "apps/sim/blocks/registry.ts": 444, + "apps/sim/lib/api/server/routes/index.ts": 370, + "apps/sim/lib/api/server/routes/internal-json-route.ts": 329, + "apps/sim/lib/auth/index.ts": 317, + "apps/sim/lib/webhooks/providers/index.ts": 110, + "apps/sim/lib/webhooks/providers/registry.ts": 108 } }, "app/api/v2/tools/route.ts": { - "modules": 1650, + "modules": 1570, "gateways": { - "apps/sim/blocks/registry.ts": 532, "apps/sim/triggers/index.ts": 474, "apps/sim/triggers/registry.ts": 472, - "apps/sim/lib/api/server/routes/index.ts": 355, - "apps/sim/lib/api/server/routes/internal-json-route.ts": 322, - "apps/sim/lib/auth/index.ts": 311, - "apps/sim/blocks/blocks/credential-group.ts": 189, - "apps/sim/stores/workflows/registry/store.ts": 169 + "apps/sim/blocks/registry.ts": 444, + "apps/sim/lib/api/server/routes/index.ts": 361, + "apps/sim/lib/api/server/routes/internal-json-route.ts": 329, + "apps/sim/lib/auth/index.ts": 317, + "apps/sim/lib/webhooks/providers/index.ts": 110, + "apps/sim/lib/webhooks/providers/registry.ts": 108 } }, "app/workspace/[workspaceId]/chat/[chatId]/error.tsx": { @@ -83,16 +83,16 @@ "gateways": {} }, "app/workspace/[workspaceId]/chat/[chatId]/page.tsx": { - "modules": 3000, + "modules": 2806, "gateways": { - "apps/sim/app/workspace/[workspaceId]/home/home.tsx": 1391, - "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx": 1036, - "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/index.ts": 895, - "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/index.ts": 892, + "apps/sim/app/workspace/[workspaceId]/home/home.tsx": 1272, + "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx": 901, + "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/index.ts": 757, + "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/index.ts": 754, "apps/sim/triggers/registry.ts": 472, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx": 342, - "apps/sim/blocks/registry.ts": 315, - "apps/sim/lib/auth/index.ts": 231 + "apps/sim/blocks/registry.ts": 322, + "apps/sim/lib/auth/index.ts": 237, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx": 198 } }, "app/workspace/[workspaceId]/error.tsx": { @@ -112,16 +112,16 @@ } }, "app/workspace/[workspaceId]/files/[fileId]/page.tsx": { - "modules": 1952, + "modules": 1899, "gateways": { "apps/sim/triggers/registry.ts": 472, - "apps/sim/blocks/registry.ts": 341, - "apps/sim/app/workspace/[workspaceId]/files/files.tsx": 276, - "apps/sim/lib/auth/index.ts": 245, - "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/index.ts": 154, - "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx": 137, - "apps/sim/lib/webhooks/providers/index.ts": 109, - "apps/sim/lib/webhooks/providers/registry.ts": 107 + "apps/sim/blocks/registry.ts": 348, + "apps/sim/app/workspace/[workspaceId]/files/files.tsx": 297, + "apps/sim/lib/auth/index.ts": 251, + "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/index.ts": 171, + "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx": 141, + "apps/sim/lib/webhooks/providers/index.ts": 110, + "apps/sim/lib/webhooks/providers/registry.ts": 108 } }, "app/workspace/[workspaceId]/files/[fileId]/view/page.tsx": { @@ -148,16 +148,16 @@ } }, "app/workspace/[workspaceId]/files/page.tsx": { - "modules": 1952, + "modules": 1899, "gateways": { "apps/sim/triggers/registry.ts": 472, - "apps/sim/blocks/registry.ts": 341, - "apps/sim/app/workspace/[workspaceId]/files/files.tsx": 278, - "apps/sim/lib/auth/index.ts": 245, - "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/index.ts": 154, - "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx": 137, - "apps/sim/lib/webhooks/providers/index.ts": 109, - "apps/sim/lib/webhooks/providers/registry.ts": 107 + "apps/sim/blocks/registry.ts": 348, + "apps/sim/app/workspace/[workspaceId]/files/files.tsx": 299, + "apps/sim/lib/auth/index.ts": 251, + "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/index.ts": 171, + "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx": 141, + "apps/sim/lib/webhooks/providers/index.ts": 110, + "apps/sim/lib/webhooks/providers/registry.ts": 108 } }, "app/workspace/[workspaceId]/home/error.tsx": { @@ -173,42 +173,42 @@ "gateways": {} }, "app/workspace/[workspaceId]/home/page.tsx": { - "modules": 3000, + "modules": 2806, "gateways": { - "apps/sim/app/workspace/[workspaceId]/home/home.tsx": 1391, - "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx": 1036, - "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/index.ts": 895, - "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/index.ts": 892, + "apps/sim/app/workspace/[workspaceId]/home/home.tsx": 1272, + "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx": 901, + "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/index.ts": 757, + "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/index.ts": 754, "apps/sim/triggers/registry.ts": 472, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx": 342, - "apps/sim/blocks/registry.ts": 315, - "apps/sim/lib/auth/index.ts": 231 + "apps/sim/blocks/registry.ts": 322, + "apps/sim/lib/auth/index.ts": 237, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx": 198 } }, "app/workspace/[workspaceId]/integrations/[block]/page.tsx": { - "modules": 1247, + "modules": 1154, "gateways": { - "apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx": 1221, + "apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx": 1126, "apps/sim/triggers/index.ts": 510, "apps/sim/triggers/registry.ts": 508, - "apps/sim/blocks/registry.ts": 491, - "apps/sim/blocks/blocks/credential-group.ts": 145, - "apps/sim/stores/workflows/registry/store.ts": 128, - "apps/sim/hooks/queries/deployments.ts": 121, - "apps/sim/lib/workflows/comparison/describe.ts": 111 + "apps/sim/blocks/registry.ts": 394, + "apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-skills-section.tsx": 59, + "apps/sim/app/workspace/[workspaceId]/components/index.ts": 56, + "apps/sim/blocks/blocks/credential-group.ts": 45, + "apps/sim/lib/api/contracts/index.ts": 36 } }, "app/workspace/[workspaceId]/integrations/connected/[credentialId]/page.tsx": { - "modules": 1225, + "modules": 1132, "gateways": { - "apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/connected-credential-detail.tsx": 1224, + "apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/connected-credential-detail.tsx": 1131, "apps/sim/triggers/registry.ts": 508, - "apps/sim/blocks/registry.ts": 349, - "apps/sim/stores/workflows/registry/store.ts": 124, - "apps/sim/hooks/queries/deployments.ts": 121, - "apps/sim/lib/workflows/comparison/describe.ts": 111, - "apps/sim/hooks/selectors/registry.ts": 106, - "apps/sim/app/workspace/[workspaceId]/components/credential-detail/index.ts": 54 + "apps/sim/blocks/registry.ts": 361, + "apps/sim/app/workspace/[workspaceId]/components/credential-detail/index.ts": 54, + "apps/sim/lib/api/contracts/index.ts": 42, + "apps/sim/components/permissions/index.ts": 41, + "apps/sim/components/permissions/add-people-modal.tsx": 35, + "apps/sim/app/workspace/[workspaceId]/providers/workspace-permissions-provider.tsx": 33 } }, "app/workspace/[workspaceId]/integrations/error.tsx": { @@ -220,16 +220,16 @@ } }, "app/workspace/[workspaceId]/integrations/page.tsx": { - "modules": 1232, + "modules": 1139, "gateways": { - "apps/sim/app/workspace/[workspaceId]/integrations/integrations.tsx": 1090, - "apps/sim/blocks/registry.ts": 1005, + "apps/sim/app/workspace/[workspaceId]/integrations/integrations.tsx": 997, + "apps/sim/blocks/registry.ts": 908, "apps/sim/triggers/index.ts": 510, "apps/sim/triggers/registry.ts": 508, - "apps/sim/blocks/blocks/credential-group.ts": 146, - "apps/sim/stores/workflows/registry/store.ts": 128, - "apps/sim/hooks/queries/deployments.ts": 121, - "apps/sim/lib/workflows/comparison/describe.ts": 111 + "apps/sim/app/workspace/[workspaceId]/components/index.ts": 57, + "apps/sim/blocks/blocks/credential-group.ts": 46, + "apps/sim/lib/api/contracts/index.ts": 38, + "apps/sim/triggers/clickup/index.ts": 32 } }, "app/workspace/[workspaceId]/knowledge/[id]/[documentId]/loading.tsx": { @@ -241,16 +241,16 @@ } }, "app/workspace/[workspaceId]/knowledge/[id]/[documentId]/page.tsx": { - "modules": 1432, + "modules": 1351, "gateways": { - "apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx": 1287, + "apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx": 1206, "apps/sim/triggers/registry.ts": 508, - "apps/sim/blocks/registry.ts": 344, - "apps/sim/blocks/registry-maps.ts": 341, - "apps/sim/hooks/selectors/registry.ts": 91, + "apps/sim/blocks/registry.ts": 355, + "apps/sim/blocks/registry-maps.ts": 352, "apps/sim/connectors/registry.ts": 65, - "apps/sim/app/workspace/[workspaceId]/components/index.ts": 60, - "apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/index.ts": 51 + "apps/sim/app/workspace/[workspaceId]/components/index.ts": 58, + "apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/index.ts": 51, + "apps/sim/lib/api/contracts/index.ts": 40 } }, "app/workspace/[workspaceId]/knowledge/[id]/error.tsx": { @@ -270,16 +270,16 @@ } }, "app/workspace/[workspaceId]/knowledge/[id]/page.tsx": { - "modules": 1435, + "modules": 1354, "gateways": { - "apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx": 1289, + "apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx": 1208, "apps/sim/triggers/registry.ts": 508, - "apps/sim/blocks/registry.ts": 349, - "apps/sim/blocks/registry-maps.ts": 346, - "apps/sim/hooks/selectors/registry.ts": 91, + "apps/sim/blocks/registry.ts": 361, + "apps/sim/blocks/registry-maps.ts": 358, "apps/sim/connectors/registry.ts": 65, - "apps/sim/app/workspace/[workspaceId]/components/index.ts": 60, - "apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/index.ts": 44 + "apps/sim/app/workspace/[workspaceId]/components/index.ts": 58, + "apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/index.ts": 44, + "apps/sim/lib/api/contracts/index.ts": 40 } }, "app/workspace/[workspaceId]/knowledge/error.tsx": { @@ -299,29 +299,29 @@ } }, "app/workspace/[workspaceId]/knowledge/page.tsx": { - "modules": 2173, + "modules": 2110, "gateways": { "apps/sim/triggers/registry.ts": 472, - "apps/sim/blocks/registry.ts": 339, - "apps/sim/app/workspace/[workspaceId]/knowledge/prefetch.ts": 290, - "apps/sim/lib/knowledge/application/knowledge-bases.ts": 234, - "apps/sim/lib/auth/index.ts": 193, - "apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx": 154, - "apps/sim/lib/knowledge/orchestration/index.ts": 145, - "apps/sim/lib/knowledge/orchestration/connectors.ts": 141 + "apps/sim/blocks/registry.ts": 348, + "apps/sim/app/workspace/[workspaceId]/knowledge/prefetch.ts": 298, + "apps/sim/lib/knowledge/application/knowledge-bases.ts": 243, + "apps/sim/lib/auth/index.ts": 196, + "apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx": 159, + "apps/sim/lib/knowledge/orchestration/index.ts": 146, + "apps/sim/lib/knowledge/orchestration/connectors.ts": 142 } }, "app/workspace/[workspaceId]/layout.tsx": { - "modules": 2033, + "modules": 1981, "gateways": { "apps/sim/triggers/registry.ts": 472, - "apps/sim/blocks/registry.ts": 340, - "apps/sim/lib/auth/index.ts": 317, - "apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/index.ts": 309, - "apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx": 304, - "apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/index.ts": 209, - "apps/sim/lib/webhooks/providers/index.ts": 109, - "apps/sim/lib/webhooks/providers/registry.ts": 107 + "apps/sim/blocks/registry.ts": 347, + "apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/index.ts": 329, + "apps/sim/lib/auth/index.ts": 326, + "apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx": 324, + "apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/index.ts": 220, + "apps/sim/lib/webhooks/providers/index.ts": 110, + "apps/sim/lib/webhooks/providers/registry.ts": 108 } }, "app/workspace/[workspaceId]/logs/error.tsx": { @@ -341,16 +341,16 @@ } }, "app/workspace/[workspaceId]/logs/page.tsx": { - "modules": 1684, + "modules": 1624, "gateways": { - "apps/sim/app/workspace/[workspaceId]/logs/logs.tsx": 1541, + "apps/sim/app/workspace/[workspaceId]/logs/logs.tsx": 1481, "apps/sim/triggers/registry.ts": 508, - "apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/execution-snapshot/execution-snapshot.tsx": 362, - "apps/sim/blocks/registry.ts": 335, - "apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/index.ts": 316, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/index.ts": 310, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx": 272, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/index.ts": 261 + "apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/execution-snapshot/execution-snapshot.tsx": 391, + "apps/sim/blocks/registry.ts": 342, + "apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/index.ts": 341, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/index.ts": 335, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx": 298, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/index.ts": 287 } }, "app/workspace/[workspaceId]/not-found.tsx": { @@ -382,16 +382,16 @@ "gateways": {} }, "app/workspace/[workspaceId]/settings/[section]/page.tsx": { - "modules": 2083, + "modules": 2047, "gateways": { + "apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx": 476, "apps/sim/triggers/registry.ts": 472, - "apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx": 451, - "apps/sim/blocks/registry.ts": 339, - "apps/sim/lib/auth/index.ts": 305, - "apps/sim/lib/webhooks/providers/index.ts": 109, - "apps/sim/lib/webhooks/providers/registry.ts": 107, - "apps/sim/hooks/selectors/registry.ts": 71, - "apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx": 45 + "apps/sim/blocks/registry.ts": 342, + "apps/sim/lib/auth/index.ts": 300, + "apps/sim/lib/webhooks/providers/index.ts": 110, + "apps/sim/lib/webhooks/providers/registry.ts": 108, + "apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx": 56, + "apps/sim/app/workspace/[workspaceId]/settings/[section]/prefetch.ts": 51 } }, "app/workspace/[workspaceId]/settings/billing/credit-usage/layout.tsx": { @@ -403,16 +403,16 @@ "gateways": {} }, "app/workspace/[workspaceId]/settings/billing/credit-usage/page.tsx": { - "modules": 1604, + "modules": 1526, "gateways": { - "apps/sim/lib/auth/index.ts": 1467, - "apps/sim/blocks/registry.ts": 530, - "apps/sim/blocks/registry-maps.ts": 527, + "apps/sim/lib/auth/index.ts": 1389, "apps/sim/triggers/index.ts": 474, "apps/sim/triggers/registry.ts": 472, - "apps/sim/blocks/blocks/credential-group.ts": 185, - "apps/sim/stores/workflows/registry/store.ts": 168, - "apps/sim/hooks/queries/deployments.ts": 160 + "apps/sim/blocks/registry.ts": 441, + "apps/sim/blocks/registry-maps.ts": 438, + "apps/sim/lib/webhooks/providers/index.ts": 110, + "apps/sim/lib/webhooks/providers/registry.ts": 108, + "apps/sim/blocks/blocks/credential-group.ts": 93 } }, "app/workspace/[workspaceId]/settings/error.tsx": { @@ -432,42 +432,42 @@ "gateways": {} }, "app/workspace/[workspaceId]/settings/secrets/[credentialId]/loading.tsx": { - "modules": 1194, + "modules": 1101, "gateways": { - "apps/sim/app/workspace/[workspaceId]/components/credential-detail/index.ts": 1191, - "apps/sim/components/permissions/index.ts": 1074, - "apps/sim/components/permissions/add-people-modal.tsx": 1065, - "apps/sim/app/workspace/[workspaceId]/providers/workspace-permissions-provider.tsx": 1063, + "apps/sim/app/workspace/[workspaceId]/components/credential-detail/index.ts": 1098, + "apps/sim/components/permissions/index.ts": 978, + "apps/sim/components/permissions/add-people-modal.tsx": 969, + "apps/sim/app/workspace/[workspaceId]/providers/workspace-permissions-provider.tsx": 967, "apps/sim/triggers/registry.ts": 508, - "apps/sim/blocks/registry.ts": 351, - "apps/sim/blocks/registry-maps.ts": 348, - "apps/sim/stores/workflows/registry/store.ts": 124 + "apps/sim/blocks/registry.ts": 363, + "apps/sim/blocks/registry-maps.ts": 360, + "apps/sim/lib/api/contracts/index.ts": 47 } }, "app/workspace/[workspaceId]/settings/secrets/[credentialId]/page.tsx": { - "modules": 1272, + "modules": 1177, "gateways": { "apps/sim/triggers/registry.ts": 508, - "apps/sim/blocks/registry.ts": 351, - "apps/sim/blocks/registry-maps.ts": 348, - "apps/sim/stores/workflows/registry/store.ts": 123, - "apps/sim/hooks/queries/deployments.ts": 120, - "apps/sim/lib/workflows/comparison/describe.ts": 110, - "apps/sim/hooks/selectors/registry.ts": 105, - "apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/secret-detail.tsx": 77 + "apps/sim/blocks/registry.ts": 363, + "apps/sim/blocks/registry-maps.ts": 360, + "apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/secret-detail.tsx": 75, + "apps/sim/app/workspace/[workspaceId]/components/index.ts": 55, + "apps/sim/app/workspace/[workspaceId]/components/credential-detail/index.ts": 51, + "apps/sim/lib/api/contracts/index.ts": 43, + "apps/sim/components/permissions/index.ts": 40 } }, "app/workspace/[workspaceId]/skills/[skillId]/page.tsx": { - "modules": 1354, + "modules": 1288, "gateways": { - "apps/sim/app/workspace/[workspaceId]/skills/[skillId]/skill-detail.tsx": 1353, + "apps/sim/app/workspace/[workspaceId]/skills/[skillId]/skill-detail.tsx": 1287, "apps/sim/triggers/registry.ts": 508, - "apps/sim/blocks/registry.ts": 349, - "apps/sim/blocks/registry-maps.ts": 347, - "apps/sim/app/workspace/[workspaceId]/skills/components/skill-fields/index.ts": 95, - "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx": 92, - "apps/sim/hooks/queries/deployments.ts": 90, - "apps/sim/lib/workflows/comparison/describe.ts": 83 + "apps/sim/blocks/registry.ts": 357, + "apps/sim/blocks/registry-maps.ts": 355, + "apps/sim/app/workspace/[workspaceId]/skills/components/skill-fields/index.ts": 124, + "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx": 121, + "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/index.ts": 61, + "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/use-markdown-mentions.ts": 59 } }, "app/workspace/[workspaceId]/skills/error.tsx": { @@ -479,29 +479,29 @@ } }, "app/workspace/[workspaceId]/skills/new/page.tsx": { - "modules": 1352, + "modules": 1286, "gateways": { - "apps/sim/app/workspace/[workspaceId]/skills/new/skill-create.tsx": 1351, + "apps/sim/app/workspace/[workspaceId]/skills/new/skill-create.tsx": 1285, "apps/sim/triggers/registry.ts": 508, - "apps/sim/blocks/registry.ts": 349, - "apps/sim/blocks/registry-maps.ts": 347, - "apps/sim/app/workspace/[workspaceId]/skills/components/skill-fields/index.ts": 95, - "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx": 92, - "apps/sim/hooks/queries/deployments.ts": 90, - "apps/sim/lib/workflows/comparison/describe.ts": 83 + "apps/sim/blocks/registry.ts": 357, + "apps/sim/blocks/registry-maps.ts": 355, + "apps/sim/app/workspace/[workspaceId]/skills/components/skill-fields/index.ts": 124, + "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx": 121, + "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/index.ts": 61, + "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/use-markdown-mentions.ts": 59 } }, "app/workspace/[workspaceId]/skills/page.tsx": { - "modules": 1215, + "modules": 1120, "gateways": { - "apps/sim/app/workspace/[workspaceId]/skills/skills.tsx": 1073, - "apps/sim/app/workspace/[workspaceId]/integrations/components/showcase-with-explore/index.ts": 1061, - "apps/sim/blocks/registry.ts": 1049, - "apps/sim/blocks/registry-maps.ts": 1047, + "apps/sim/app/workspace/[workspaceId]/skills/skills.tsx": 978, + "apps/sim/app/workspace/[workspaceId]/integrations/components/showcase-with-explore/index.ts": 966, + "apps/sim/blocks/registry.ts": 954, + "apps/sim/blocks/registry-maps.ts": 952, "apps/sim/triggers/index.ts": 510, "apps/sim/triggers/registry.ts": 508, - "apps/sim/blocks/blocks/credential-group.ts": 160, - "apps/sim/stores/workflows/registry/store.ts": 140 + "apps/sim/blocks/blocks/credential-group.ts": 61, + "apps/sim/app/workspace/[workspaceId]/components/index.ts": 57 } }, "app/workspace/[workspaceId]/tables/[tableId]/error.tsx": { @@ -521,16 +521,16 @@ } }, "app/workspace/[workspaceId]/tables/[tableId]/page.tsx": { - "modules": 2225, + "modules": 1728, "gateways": { - "apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx": 574, - "apps/sim/triggers/registry.ts": 472, - "apps/sim/lib/auth/index.ts": 344, - "apps/sim/app/workspace/[workspaceId]/w/components/preview/index.ts": 322, - "apps/sim/blocks/registry.ts": 315, - "apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/index.ts": 277, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/index.ts": 273, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx": 241 + "apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx": 1585, + "apps/sim/triggers/registry.ts": 508, + "apps/sim/app/workspace/[workspaceId]/w/components/preview/index.ts": 330, + "apps/sim/blocks/registry.ts": 323, + "apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/index.ts": 284, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/index.ts": 280, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx": 248, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/index.ts": 237 } }, "app/workspace/[workspaceId]/tables/error.tsx": { @@ -550,16 +550,16 @@ } }, "app/workspace/[workspaceId]/tables/page.tsx": { - "modules": 1792, + "modules": 1736, "gateways": { "apps/sim/triggers/registry.ts": 472, - "apps/sim/blocks/registry.ts": 341, - "apps/sim/lib/auth/index.ts": 339, - "apps/sim/lib/webhooks/providers/index.ts": 109, - "apps/sim/lib/webhooks/providers/registry.ts": 107, - "apps/sim/app/workspace/[workspaceId]/tables/tables.tsx": 106, - "apps/sim/stores/workflows/registry/store.ts": 98, - "apps/sim/lib/workflows/comparison/describe.ts": 88 + "apps/sim/blocks/registry.ts": 348, + "apps/sim/lib/auth/index.ts": 348, + "apps/sim/app/workspace/[workspaceId]/tables/tables.tsx": 124, + "apps/sim/lib/webhooks/providers/index.ts": 110, + "apps/sim/lib/webhooks/providers/registry.ts": 108, + "apps/sim/app/workspace/[workspaceId]/components/index.ts": 58, + "apps/sim/lib/uploads/utils/file-utils.server.ts": 50 } }, "app/workspace/[workspaceId]/upgrade/page.tsx": { @@ -574,94 +574,90 @@ } }, "app/workspace/[workspaceId]/w/[workflowId]/layout.tsx": { - "modules": 2195, + "modules": 144, "gateways": { - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/error/index.tsx": 2194, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/index.ts": 556, - "apps/sim/triggers/registry.ts": 508, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/index.ts": 472, - "apps/sim/blocks/registry.ts": 335, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/index.ts": 295, - "apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx": 187, - "apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/index.ts": 156 + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/error/index.tsx": 143, + "apps/sim/app/workspace/[workspaceId]/components/index.ts": 141, + "apps/sim/app/workspace/[workspaceId]/components/message-actions/index.ts": 107, + "apps/sim/hooks/queries/copilot-feedback.ts": 70 } }, "app/workspace/[workspaceId]/w/[workflowId]/page.tsx": { - "modules": 2226, + "modules": 1982, "gateways": { - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx": 2225, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx": 1981, "apps/sim/triggers/registry.ts": 508, - "apps/sim/blocks/registry.ts": 335, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/index.ts": 312, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/index.ts": 274, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/index.ts": 231, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/error/index.tsx": 181, - "apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx": 179 + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/index.ts": 344, + "apps/sim/blocks/registry.ts": 342, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/index.ts": 307, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/index.ts": 245, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/index.ts": 151, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx": 144 } }, "app/workspace/[workspaceId]/w/page.tsx": { - "modules": 2195, + "modules": 1964, "gateways": { - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/index.ts": 981, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/index.ts": 828, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/index.ts": 539, "apps/sim/triggers/registry.ts": 508, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/index.ts": 472, - "apps/sim/blocks/registry.ts": 335, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/index.ts": 295, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/error/index.tsx": 168, - "apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/index.ts": 156, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/index.ts": 146 + "apps/sim/blocks/registry.ts": 342, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/index.ts": 306, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/index.ts": 153, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx": 146, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/index.ts": 140 } }, "app/workspace/layout.tsx": { - "modules": 1160, + "modules": 1067, "gateways": { - "apps/sim/app/workspace/providers/socket-provider.tsx": 1150, + "apps/sim/app/workspace/providers/socket-provider.tsx": 1057, "apps/sim/triggers/registry.ts": 508, - "apps/sim/blocks/registry.ts": 351, - "apps/sim/blocks/registry-maps.ts": 348, - "apps/sim/stores/workflows/registry/store.ts": 180, - "apps/sim/hooks/queries/deployments.ts": 177, - "apps/sim/lib/workflows/comparison/describe.ts": 166, - "apps/sim/hooks/selectors/registry.ts": 106 + "apps/sim/blocks/registry.ts": 363, + "apps/sim/blocks/registry-maps.ts": 360, + "apps/sim/stores/workflows/registry/store.ts": 80, + "apps/sim/hooks/queries/deployments.ts": 77, + "apps/sim/lib/workflows/comparison/describe.ts": 66, + "apps/sim/lib/api/contracts/index.ts": 47 } }, "app/workspace/page.tsx": { - "modules": 1157, + "modules": 1066, "gateways": { - "apps/sim/lib/auth/stale-session-recovery.ts": 1068, + "apps/sim/lib/auth/stale-session-recovery.ts": 977, "apps/sim/triggers/index.ts": 510, "apps/sim/triggers/registry.ts": 508, - "apps/sim/blocks/registry.ts": 351, - "apps/sim/blocks/registry-maps.ts": 348, - "apps/sim/stores/workflows/registry/store.ts": 130, - "apps/sim/hooks/queries/deployments.ts": 123, - "apps/sim/lib/workflows/comparison/describe.ts": 113 + "apps/sim/blocks/registry.ts": 363, + "apps/sim/blocks/registry-maps.ts": 360, + "apps/sim/lib/api/contracts/index.ts": 41, + "apps/sim/stores/workflows/registry/store.ts": 33, + "apps/sim/triggers/clickup/index.ts": 32 } }, "lib/catalog/projection/block-detail.ts": { - "modules": 1138, + "modules": 1045, "gateways": { - "apps/sim/lib/catalog/projection/block-summary.ts": 597, - "apps/sim/blocks/registry-maps.ts": 593, "apps/sim/triggers/index.ts": 510, "apps/sim/triggers/registry.ts": 508, - "apps/sim/blocks/blocks/credential-group.ts": 219, - "apps/sim/stores/workflows/registry/store.ts": 190, - "apps/sim/hooks/queries/deployments.ts": 181, - "apps/sim/lib/workflows/comparison/describe.ts": 170 + "apps/sim/lib/catalog/projection/block-summary.ts": 502, + "apps/sim/blocks/registry-maps.ts": 498, + "apps/sim/blocks/blocks/credential-group.ts": 120, + "apps/sim/stores/workflows/registry/store.ts": 90, + "apps/sim/hooks/queries/deployments.ts": 81, + "apps/sim/lib/workflows/comparison/describe.ts": 70 } }, "lib/catalog/projection/block-summary.ts": { - "modules": 1134, + "modules": 1041, "gateways": { - "apps/sim/blocks/registry.ts": 1126, - "apps/sim/blocks/registry-maps.ts": 1123, + "apps/sim/blocks/registry.ts": 1033, + "apps/sim/blocks/registry-maps.ts": 1030, "apps/sim/triggers/index.ts": 510, "apps/sim/triggers/registry.ts": 508, - "apps/sim/blocks/blocks/credential-group.ts": 220, - "apps/sim/stores/workflows/registry/store.ts": 190, - "apps/sim/hooks/queries/deployments.ts": 181, - "apps/sim/lib/workflows/comparison/describe.ts": 170 + "apps/sim/blocks/blocks/credential-group.ts": 121, + "apps/sim/stores/workflows/registry/store.ts": 90, + "apps/sim/hooks/queries/deployments.ts": 81, + "apps/sim/lib/workflows/comparison/describe.ts": 70 } }, "lib/catalog/projection/connector-type.ts": { @@ -677,16 +673,16 @@ "gateways": {} }, "lib/copilot/tools/server/blocks/get-blocks-metadata-tool.ts": { - "modules": 1257, + "modules": 1167, "gateways": { - "apps/sim/blocks/registry.ts": 553, - "apps/sim/blocks/registry-maps.ts": 551, "apps/sim/triggers/index.ts": 510, "apps/sim/triggers/registry.ts": 508, - "apps/sim/blocks/blocks/credential-group.ts": 205, - "apps/sim/stores/workflows/registry/store.ts": 181, - "apps/sim/hooks/queries/deployments.ts": 172, - "apps/sim/lib/workflows/comparison/describe.ts": 161 + "apps/sim/blocks/registry.ts": 459, + "apps/sim/blocks/registry-maps.ts": 457, + "apps/sim/ee/access-control/utils/permission-check.ts": 114, + "apps/sim/lib/billing/index.ts": 109, + "apps/sim/blocks/blocks/credential-group.ts": 108, + "apps/sim/stores/workflows/registry/store.ts": 83 } } } From a343f97c2bc5e9bfcd991f290c34595b85a76e9f Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Thu, 27 Aug 2026 17:48:55 -0700 Subject: [PATCH 02/13] fix(selectors): expose route verb to contract audit --- .../app/api/selectors/execute/route.test.ts | 4 +- apps/sim/app/api/selectors/execute/route.ts | 11 +-- .../server/routes/internal-json-route.test.ts | 69 +++++++++++++++++++ .../api/server/routes/internal-json-route.ts | 12 +++- 4 files changed, 84 insertions(+), 12 deletions(-) diff --git a/apps/sim/app/api/selectors/execute/route.test.ts b/apps/sim/app/api/selectors/execute/route.test.ts index fc2045b8464..4b0e49dd513 100644 --- a/apps/sim/app/api/selectors/execute/route.test.ts +++ b/apps/sim/app/api/selectors/execute/route.test.ts @@ -8,10 +8,10 @@ const mocks = vi.hoisted(() => ({ status: 200 })) vi.mock('@/lib/api/server/routes', () => ({ defineInternalJsonRoute: vi.fn( - () => async () => + (options: { staticResponseHeaders?: HeadersInit }) => async () => new Response(JSON.stringify({ ok: mocks.status < 400 }), { status: mocks.status, - headers: { 'Content-Type': 'application/json' }, + headers: options.staticResponseHeaders, }) ), extendInternalErrorPolicy: vi.fn(() => ({})), diff --git a/apps/sim/app/api/selectors/execute/route.ts b/apps/sim/app/api/selectors/execute/route.ts index b521e8a16d8..7e4281db897 100644 --- a/apps/sim/app/api/selectors/execute/route.ts +++ b/apps/sim/app/api/selectors/execute/route.ts @@ -30,7 +30,7 @@ const selectorErrorPolicy = extendInternalErrorPolicy(internalOrchestrationError return null }) -const executeSelectorRoute = defineInternalJsonRoute({ +export const POST = defineInternalJsonRoute({ contract: executeSelectorContract, auth: internalSessionAuth, operation: selectorOperations.execute, @@ -41,12 +41,5 @@ const executeSelectorRoute = defineInternalJsonRoute({ parseOptions: { maxBodyBytes: 256 * 1024 }, mapInput: ({ body }, { request }) => ({ ...body, signal: request.signal }), useCase: executeSelector, - responseHeaders: () => PRIVATE_NO_STORE, + staticResponseHeaders: PRIVATE_NO_STORE, }) - -/** Applies the privacy header to authentication, parse, and unhandled failures too. */ -export async function POST(...args: Parameters): Promise { - const response = await executeSelectorRoute(...args) - response.headers.set('Cache-Control', PRIVATE_NO_STORE['Cache-Control']) - return response -} diff --git a/apps/sim/lib/api/server/routes/internal-json-route.test.ts b/apps/sim/lib/api/server/routes/internal-json-route.test.ts index 7f10e36b703..33aca897966 100644 --- a/apps/sim/lib/api/server/routes/internal-json-route.test.ts +++ b/apps/sim/lib/api/server/routes/internal-json-route.test.ts @@ -215,6 +215,75 @@ describe('defineInternalJsonRoute', () => { expect(body.requestId).toBe('req-parse') }) + it('applies static response headers to success and every failure stage', async () => { + const staticHeaderContract = defineRouteContract({ + method: 'POST', + path: '/api/test/internal-json-route', + body: z.object({ outcome: z.enum(['success', 'failure']) }), + response: { mode: 'json', schema: z.object({ value: z.string() }) }, + }) + const handler = defineInternalJsonRoute({ + contract: staticHeaderContract, + auth: { + async authenticate(request) { + if (request.headers.get('x-reject-auth') === 'true') { + throw new InternalUnauthenticatedError('Unauthorized') + } + return { kind: 'session' as const, userId: 'user-1', sessionId: 'session-1' } + }, + }, + operation, + rateLimit: internalRateLimits.none({ reason: 'Unit test' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ body }) => body.outcome, + useCase: { + operation, + async execute({ input }) { + if (input === 'failure') throw new Error('Unhandled') + return { value: 'ok' } + }, + }, + staticResponseHeaders: { 'Cache-Control': 'private, no-store' }, + }) + + const cases: Array<[NextRequest, number]> = [ + [ + new NextRequest('http://localhost/api/test/internal-json-route', { + method: 'POST', + body: JSON.stringify({ outcome: 'success' }), + }), + 200, + ], + [ + new NextRequest('http://localhost/api/test/internal-json-route', { + method: 'POST', + headers: { 'x-reject-auth': 'true' }, + }), + 401, + ], + [ + new NextRequest('http://localhost/api/test/internal-json-route', { + method: 'POST', + body: '{', + }), + 400, + ], + [ + new NextRequest('http://localhost/api/test/internal-json-route', { + method: 'POST', + body: JSON.stringify({ outcome: 'failure' }), + }), + 500, + ], + ] + + for (const [request, expectedStatus] of cases) { + const response = await handler(request) + expect(response.status).toBe(expectedStatus) + expect(response.headers.get('Cache-Control')).toBe('private, no-store') + } + }) + it('orders auth, rate limiting, parsing, async mapping, and application execution', async () => { const events: string[] = [] const orderedContract = defineRouteContract({ diff --git a/apps/sim/lib/api/server/routes/internal-json-route.ts b/apps/sim/lib/api/server/routes/internal-json-route.ts index 599fb2cbeee..08ae1bb3833 100644 --- a/apps/sim/lib/api/server/routes/internal-json-route.ts +++ b/apps/sim/lib/api/server/routes/internal-json-route.ts @@ -256,6 +256,8 @@ type InternalJsonRouteOptions< }): void | Promise onSuccess?(args: { principal: P; input: NoInfer; result: NoInfer }): void | Promise statusForResult?(result: NoInfer): number + /** Headers applied last to every response path, including authentication and parse failures. */ + staticResponseHeaders?: HeadersInit responseHeaders?(args: { principal: P; input: NoInfer; result: NoInfer }): HeadersInit finalizeResponse?(args: { request: NextRequest @@ -408,5 +410,13 @@ export function defineInternalJsonRoute< } ) - return async (request, context) => wrapped(request, context) + return async (request, context) => { + const response = await wrapped(request, context) + if (options.staticResponseHeaders) { + new Headers(options.staticResponseHeaders).forEach((value, key) => { + response.headers.set(key, value) + }) + } + return response + } } From db0ffc0fd9c9e6c544532416dec70429ccaf6078 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Thu, 27 Aug 2026 17:57:35 -0700 Subject: [PATCH 03/13] test(selectors): align migration expectations --- .../hooks/queries/utils/invalidate-workflow-lists.test.ts | 4 ++-- .../client-credential-accounts/minters/zoho-desk.test.ts | 7 +++---- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/apps/sim/hooks/queries/utils/invalidate-workflow-lists.test.ts b/apps/sim/hooks/queries/utils/invalidate-workflow-lists.test.ts index 6c5963840ae..d49197967d2 100644 --- a/apps/sim/hooks/queries/utils/invalidate-workflow-lists.test.ts +++ b/apps/sim/hooks/queries/utils/invalidate-workflow-lists.test.ts @@ -5,7 +5,7 @@ import { describe, expect, it, vi } from 'vitest' import { invalidateWorkflowLists } from '@/hooks/queries/utils/invalidate-workflow-lists' describe('invalidateWorkflowLists', () => { - it('invalidates scoped workflow lists and workflow selector caches', async () => { + it('invalidates scoped workflow lists and the privacy-safe selector prefix', async () => { const queryClient = { invalidateQueries: vi.fn().mockResolvedValue(undefined), } @@ -19,7 +19,7 @@ describe('invalidateWorkflowLists', () => { queryKey: ['workflows', 'list', 'ws-1', 'archived'], }) expect(queryClient.invalidateQueries).toHaveBeenNthCalledWith(3, { - queryKey: ['selectors', 'sim.workflows', 'ws-1'], + queryKey: ['selectors'], }) }) }) diff --git a/apps/sim/lib/credentials/client-credential-accounts/minters/zoho-desk.test.ts b/apps/sim/lib/credentials/client-credential-accounts/minters/zoho-desk.test.ts index 6bf48fd65c1..bfaa0b47f5c 100644 --- a/apps/sim/lib/credentials/client-credential-accounts/minters/zoho-desk.test.ts +++ b/apps/sim/lib/credentials/client-credential-accounts/minters/zoho-desk.test.ts @@ -274,11 +274,10 @@ describe('mintZohoDeskServiceAccountToken', () => { expect(result.apiDomain).toBe('https://desk.zoho.in') expect(mockLoggerWarn).toHaveBeenCalledWith( 'Zoho api_domain disagrees with the selected data center', - expect.objectContaining({ + { selectedDataCenter: 'eu', - selectedDeskBase: 'https://desk.zoho.eu', - reportedDeskBase: 'https://desk.zoho.in', - }) + usedProviderReportedDomain: true, + } ) }) From 855ce1f82b87c0ef9e64f7c80388ae566dff2d42 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Thu, 27 Aug 2026 19:31:15 -0700 Subject: [PATCH 04/13] fix(selectors): address review findings --- .../app/api/selectors/execute/route.test.ts | 125 +++++++++++++++--- apps/sim/app/api/selectors/execute/route.ts | 48 +++++-- apps/sim/hooks/queries/credential-groups.ts | 16 +-- apps/sim/hooks/queries/credentials.ts | 12 +- apps/sim/hooks/queries/environment.ts | 8 +- .../hooks/queries/utils/selector-keys.test.ts | 32 +++++ apps/sim/hooks/queries/utils/selector-keys.ts | 16 ++- .../hooks/queries/workflow-search-replace.ts | 3 +- apps/sim/lib/environment/utils.test.ts | 118 +++++++++++++++++ apps/sim/lib/environment/utils.ts | 58 +++++++- .../application/execute-selector.test.ts | 76 +++++++++++ .../selectors/application/execute-selector.ts | 70 ++++++++-- apps/sim/lib/selectors/context.ts | 1 + apps/sim/lib/selectors/manifest.test.ts | 15 +++ .../sim/lib/selectors/server/internal.test.ts | 46 +++++++ apps/sim/lib/selectors/server/internal.ts | 16 +-- .../selectors/server/providers/microsoft.ts | 7 +- .../selectors/server/providers/sharepoint.ts | 12 +- .../lib/workflows/subblocks/context.test.ts | 24 +++- .../src/mocks/environment-utils.mock.test.ts | 3 + .../src/mocks/environment-utils.mock.ts | 4 + 21 files changed, 629 insertions(+), 81 deletions(-) create mode 100644 apps/sim/hooks/queries/utils/selector-keys.test.ts create mode 100644 apps/sim/lib/selectors/server/internal.test.ts diff --git a/apps/sim/app/api/selectors/execute/route.test.ts b/apps/sim/app/api/selectors/execute/route.test.ts index 4b0e49dd513..99030d5d551 100644 --- a/apps/sim/app/api/selectors/execute/route.test.ts +++ b/apps/sim/app/api/selectors/execute/route.test.ts @@ -4,25 +4,85 @@ import { createMockRequest } from '@sim/testing' import { describe, expect, it, vi } from 'vitest' -const mocks = vi.hoisted(() => ({ status: 200 })) - -vi.mock('@/lib/api/server/routes', () => ({ - defineInternalJsonRoute: vi.fn( - (options: { staticResponseHeaders?: HeadersInit }) => async () => - new Response(JSON.stringify({ ok: mocks.status < 400 }), { - status: mocks.status, - headers: options.staticResponseHeaders, - }) - ), - extendInternalErrorPolicy: vi.fn(() => ({})), - internalErrorResponse: vi.fn(), - internalOrchestrationErrorPolicy: {}, - internalRateLimits: { none: vi.fn(() => ({ kind: 'none' })) }, - internalSessionAuth: {}, +const mocks = vi.hoisted(() => ({ + status: 200, + errorPolicy: undefined as + | { + project(error: unknown): { body: unknown; status: number; headers?: HeadersInit } | null + unhandled?(): { body: unknown; status: number; headers?: HeadersInit } + } + | undefined, })) +vi.mock('@/lib/api/server/routes', () => { + const internalErrorResponse = vi.fn((status: number, body: unknown, headers?: HeadersInit) => ({ + body, + status, + headers, + })) + const internalOrchestrationErrorPolicy = { + project(error: unknown) { + if (!(error instanceof Error) || !('code' in error)) return null + const code = (error as Error & { code: string }).code + const status = + code === 'validation' + ? 400 + : code === 'unauthorized' + ? 401 + : code === 'forbidden' + ? 403 + : code === 'not_found' + ? 404 + : code === 'conflict' + ? 409 + : 500 + return internalErrorResponse(status, { error: error.message }) + }, + unhandled: () => internalErrorResponse(500, { error: 'Internal server error' }), + } + + return { + defineInternalJsonRoute: vi.fn( + (options: { errorPolicy: typeof mocks.errorPolicy; staticResponseHeaders?: HeadersInit }) => { + mocks.errorPolicy = options.errorPolicy + return async () => + new Response(JSON.stringify({ ok: mocks.status < 400 }), { + status: mocks.status, + headers: options.staticResponseHeaders, + }) + } + ), + extendInternalErrorPolicy: vi.fn( + ( + base: typeof internalOrchestrationErrorPolicy, + project: (error: unknown) => ReturnType | null + ) => ({ + project: (error: unknown) => project(error) ?? base.project(error), + unhandled: base.unhandled, + }) + ), + internalErrorResponse, + internalOrchestrationErrorPolicy, + internalRateLimits: { none: vi.fn(() => ({ kind: 'none' })) }, + internalSessionAuth: {}, + } +}) + +import { NoWorkspaceAccessError } from '@/lib/core/application/workspace-authorization' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { + SelectorConnectionUnavailableError, + SelectorContextUnavailableError, + SelectorOptionsUnavailableError, +} from '@/lib/selectors/server/errors' import { POST } from '@/app/api/selectors/execute/route' +function project(error: unknown) { + const result = mocks.errorPolicy?.project(error) + if (!result) throw new Error('Expected route error policy to project the error') + return result +} + describe('POST /api/selectors/execute', () => { it('marks success, authentication, parse, and unhandled responses private and non-cacheable', async () => { for (const status of [200, 400, 401, 500]) { @@ -33,4 +93,39 @@ describe('POST /api/selectors/execute', () => { expect(response.headers.get('Cache-Control')).toBe('private, no-store') } }) + + it.each([ + ['missing workflow', new OrchestrationError('not_found', 'Workflow not found')], + ['missing workspace', new OrchestrationError('not_found', 'Workspace not found')], + ['asserted workspace mismatch', new OrchestrationError('not_found', 'Workflow not found')], + ['cross-tenant workspace', new NoWorkspaceAccessError()], + ])('conceals %s as the same selector-scope absence', (_case, error) => { + expect(project(error)).toEqual({ + status: 404, + body: { error: 'Selector scope not found' }, + headers: { 'Cache-Control': 'private, no-store' }, + }) + }) + + it.each([ + [new SelectorContextUnavailableError(), 400, 'Context unavailable'], + [new SelectorConnectionUnavailableError(), 403, 'Connection unavailable'], + [new SelectorOptionsUnavailableError(), 502, 'Options unavailable'], + ])('preserves selector error projection for %s', (error, status, message) => { + expect(project(error)).toEqual({ + status, + body: { error: message }, + headers: { 'Cache-Control': 'private, no-store' }, + }) + }) + + it('preserves same-workspace forbidden errors', () => { + expect( + project(new OrchestrationError('forbidden', 'Insufficient workspace permissions')) + ).toEqual({ + status: 403, + body: { error: 'Insufficient workspace permissions' }, + headers: undefined, + }) + }) }) diff --git a/apps/sim/app/api/selectors/execute/route.ts b/apps/sim/app/api/selectors/execute/route.ts index 7e4281db897..b2837ff76ba 100644 --- a/apps/sim/app/api/selectors/execute/route.ts +++ b/apps/sim/app/api/selectors/execute/route.ts @@ -2,11 +2,14 @@ import { executeSelectorContract } from '@/lib/api/contracts/selectors/execute' import { defineInternalJsonRoute, extendInternalErrorPolicy, + type InternalErrorPolicy, internalErrorResponse, internalOrchestrationErrorPolicy, internalRateLimits, internalSessionAuth, } from '@/lib/api/server/routes' +import { createInternalResourceConcealmentPolicy } from '@/lib/api/server/routes/resource-concealment' +import { asOrchestrationError } from '@/lib/core/orchestration/types' import { executeSelector } from '@/lib/selectors/application/execute-selector' import { selectorOperations } from '@/lib/selectors/application/operations' import { @@ -16,18 +19,43 @@ import { } from '@/lib/selectors/server/errors' const PRIVATE_NO_STORE = { 'Cache-Control': 'private, no-store' } as const +const SELECTOR_SCOPE_NOT_FOUND = 'Selector scope not found' -const selectorErrorPolicy = extendInternalErrorPolicy(internalOrchestrationErrorPolicy, (error) => { - if (error instanceof SelectorContextUnavailableError) { - return internalErrorResponse(400, { error: 'Context unavailable' }, PRIVATE_NO_STORE) - } - if (error instanceof SelectorConnectionUnavailableError) { - return internalErrorResponse(403, { error: 'Connection unavailable' }, PRIVATE_NO_STORE) - } - if (error instanceof SelectorOptionsUnavailableError) { - return internalErrorResponse(502, { error: 'Options unavailable' }, PRIVATE_NO_STORE) +const selectorOperationErrorPolicy = extendInternalErrorPolicy( + internalOrchestrationErrorPolicy, + (error) => { + if (error instanceof SelectorContextUnavailableError) { + return internalErrorResponse(400, { error: 'Context unavailable' }, PRIVATE_NO_STORE) + } + if (error instanceof SelectorConnectionUnavailableError) { + return internalErrorResponse(403, { error: 'Connection unavailable' }, PRIVATE_NO_STORE) + } + if (error instanceof SelectorOptionsUnavailableError) { + return internalErrorResponse(502, { error: 'Options unavailable' }, PRIVATE_NO_STORE) + } + return null } - return null +) + +/** + * This route accepts both workflow and workspace scopes, whose canonical loaders + * use different not-found messages. Normalize those ordinary misses together + * with concealed cross-tenant denials so neither status nor body reveals whether + * a caller-supplied scope exists. + */ +const selectorScopeNotFoundPolicy: InternalErrorPolicy = { + project(error) { + if (asOrchestrationError(error)?.code === 'not_found') { + return internalErrorResponse(404, { error: SELECTOR_SCOPE_NOT_FOUND }, PRIVATE_NO_STORE) + } + return selectorOperationErrorPolicy.project(error) + }, + unhandled: selectorOperationErrorPolicy.unhandled, +} + +const selectorErrorPolicy = createInternalResourceConcealmentPolicy({ + base: selectorScopeNotFoundPolicy, + notFoundMessage: SELECTOR_SCOPE_NOT_FOUND, }) export const POST = defineInternalJsonRoute({ diff --git a/apps/sim/hooks/queries/credential-groups.ts b/apps/sim/hooks/queries/credential-groups.ts index 8afb7ceb78b..98b097516e5 100644 --- a/apps/sim/hooks/queries/credential-groups.ts +++ b/apps/sim/hooks/queries/credential-groups.ts @@ -24,7 +24,7 @@ import { credentialGroupKeys, fetchCredentialGroupList, } from '@/hooks/queries/utils/credential-group-queries' -import { selectorKeys } from '@/hooks/queries/utils/selector-keys' +import { invalidateSelectorQueries } from '@/hooks/queries/utils/selector-keys' export function useCredentialGroups(workspaceId?: string) { return useQuery({ @@ -125,7 +125,7 @@ export function useUpdateCredentialGroupAccess() { queryKey: credentialGroupKeys.access(variables.workspaceId, variables.groupId), exact: true, }), - queryClient.invalidateQueries({ queryKey: selectorKeys.all }), + invalidateSelectorQueries(queryClient), ]), }) } @@ -142,7 +142,7 @@ export function useCreateCredentialGroup() { }) => requestJson(createCredentialGroupContract, { params: { id: workspaceId }, body }), onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ queryKey: credentialGroupKeys.list(variables.workspaceId) }) - queryClient.invalidateQueries({ queryKey: selectorKeys.all }) + invalidateSelectorQueries(queryClient) }, }) } @@ -159,7 +159,7 @@ export function useDeleteCredentialGroup() { queryClient.removeQueries({ queryKey: credentialGroupKeys.detail(variables.workspaceId, variables.groupId), }) - queryClient.invalidateQueries({ queryKey: selectorKeys.all }) + invalidateSelectorQueries(queryClient) }, }) } @@ -191,7 +191,7 @@ export function useUpdateCredentialGroup() { queryClient.invalidateQueries({ queryKey: credentialGroupKeys.detail(variables.workspaceId, variables.groupId), }), - queryClient.invalidateQueries({ queryKey: selectorKeys.all }), + invalidateSelectorQueries(queryClient), ]), }) } @@ -234,7 +234,7 @@ export function useInviteCredentialGroupEnrollments() { queryClient.invalidateQueries({ queryKey: credentialGroupKeys.detail(variables.workspaceId, variables.groupId), }) - queryClient.invalidateQueries({ queryKey: selectorKeys.all }) + invalidateSelectorQueries(queryClient) }, }) } @@ -258,7 +258,7 @@ export function useResendCredentialGroupEnrollment() { queryClient.invalidateQueries({ queryKey: credentialGroupKeys.detail(variables.workspaceId, variables.groupId), }) - queryClient.invalidateQueries({ queryKey: selectorKeys.all }) + invalidateSelectorQueries(queryClient) }, }) } @@ -282,7 +282,7 @@ export function useDeleteCredentialGroupEnrollment() { queryClient.invalidateQueries({ queryKey: credentialGroupKeys.detail(variables.workspaceId, variables.groupId), }) - queryClient.invalidateQueries({ queryKey: selectorKeys.all }) + invalidateSelectorQueries(queryClient) }, }) } diff --git a/apps/sim/hooks/queries/credentials.ts b/apps/sim/hooks/queries/credentials.ts index 393d67efe03..1ba031e58ad 100644 --- a/apps/sim/hooks/queries/credentials.ts +++ b/apps/sim/hooks/queries/credentials.ts @@ -30,7 +30,7 @@ import { requireWorkspaceCredentialListResponse, WORKSPACE_CREDENTIAL_LIST_STALE_TIME, } from '@/hooks/queries/utils/fetch-workspace-credentials' -import { selectorKeys } from '@/hooks/queries/utils/selector-keys' +import { invalidateSelectorQueries } from '@/hooks/queries/utils/selector-keys' /** * Key prefix for OAuth credential queries. @@ -133,7 +133,7 @@ export function useCreateWorkspaceCredential() { queryClient.invalidateQueries({ queryKey: OAUTH_CREDENTIALS_KEY, }) - queryClient.invalidateQueries({ queryKey: selectorKeys.all }) + invalidateSelectorQueries(queryClient) }, }) } @@ -224,7 +224,7 @@ export function useUpdateWorkspaceCredential() { queryClient.invalidateQueries({ queryKey: OAUTH_CREDENTIALS_KEY, }) - queryClient.invalidateQueries({ queryKey: selectorKeys.all }) + invalidateSelectorQueries(queryClient) }, }) } @@ -242,7 +242,7 @@ export function useDeleteWorkspaceCredential() { queryClient.invalidateQueries({ queryKey: OAUTH_CREDENTIALS_KEY }) queryClient.invalidateQueries({ queryKey: environmentKeys.all }) queryClient.invalidateQueries({ queryKey: oauthConnectionsKeys.connections() }) - queryClient.invalidateQueries({ queryKey: selectorKeys.all }) + invalidateSelectorQueries(queryClient) }, }) } @@ -287,7 +287,7 @@ export function useUpsertWorkspaceCredentialMember() { queryClient.invalidateQueries({ queryKey: workspaceCredentialKeys.detail(variables.credentialId), }) - queryClient.invalidateQueries({ queryKey: selectorKeys.all }) + invalidateSelectorQueries(queryClient) }, }) } @@ -313,7 +313,7 @@ export function useRemoveWorkspaceCredentialMember() { queryClient.invalidateQueries({ queryKey: workspaceCredentialKeys.detail(variables.credentialId), }) - queryClient.invalidateQueries({ queryKey: selectorKeys.all }) + invalidateSelectorQueries(queryClient) }, }) } diff --git a/apps/sim/hooks/queries/environment.ts b/apps/sim/hooks/queries/environment.ts index 8fbd2942a99..11bb7e6c937 100644 --- a/apps/sim/hooks/queries/environment.ts +++ b/apps/sim/hooks/queries/environment.ts @@ -9,7 +9,7 @@ import { } from '@/lib/api/contracts' import type { WorkspaceEnvironmentData } from '@/lib/environment/api' import { fetchPersonalEnvironment, fetchWorkspaceEnvironment } from '@/lib/environment/api' -import { selectorKeys } from '@/hooks/queries/utils/selector-keys' +import { invalidateSelectorQueries } from '@/hooks/queries/utils/selector-keys' const logger = createLogger('EnvironmentQueries') @@ -80,7 +80,7 @@ export function useSavePersonalEnvironment() { await Promise.all([ queryClient.invalidateQueries({ queryKey: environmentKeys.personal() }), queryClient.invalidateQueries({ queryKey: environmentKeys.workspaces() }), - queryClient.invalidateQueries({ queryKey: selectorKeys.all }), + invalidateSelectorQueries(queryClient), ]) }, }) @@ -110,7 +110,7 @@ export function useUpsertWorkspaceEnvironment() { queryClient.invalidateQueries({ queryKey: environmentKeys.workspace(variables.workspaceId), }), - queryClient.invalidateQueries({ queryKey: selectorKeys.all }), + invalidateSelectorQueries(queryClient), ]), }) } @@ -139,7 +139,7 @@ export function useRemoveWorkspaceEnvironment() { queryClient.invalidateQueries({ queryKey: environmentKeys.workspace(variables.workspaceId), }), - queryClient.invalidateQueries({ queryKey: selectorKeys.all }), + invalidateSelectorQueries(queryClient), ]), }) } diff --git a/apps/sim/hooks/queries/utils/selector-keys.test.ts b/apps/sim/hooks/queries/utils/selector-keys.test.ts new file mode 100644 index 00000000000..3ed047f908f --- /dev/null +++ b/apps/sim/hooks/queries/utils/selector-keys.test.ts @@ -0,0 +1,32 @@ +/** + * @vitest-environment node + */ +import { QueryClient } from '@tanstack/react-query' +import { describe, expect, it } from 'vitest' +import { invalidateSelectorQueries, selectorQueryRoots } from '@/hooks/queries/utils/selector-keys' + +describe('invalidateSelectorQueries', () => { + it('invalidates both privacy-safe selector roots without touching unrelated queries', async () => { + const queryClient = new QueryClient() + const selectorKey = [...selectorQueryRoots.selectors, 'gmail.labels'] as const + const searchReplaceKey = [ + ...selectorQueryRoots.workflowSearchReplace, + 'selector-detail', + ] as const + const unrelatedKey = ['workflows', 'workspace-1'] as const + + queryClient.setQueryData(selectorKey, []) + queryClient.setQueryData(searchReplaceKey, []) + queryClient.setQueryData(unrelatedKey, []) + + await invalidateSelectorQueries(queryClient) + + expect(selectorQueryRoots).toEqual({ + selectors: ['selectors'], + workflowSearchReplace: ['workflow-search-replace'], + }) + expect(queryClient.getQueryState(selectorKey)?.isInvalidated).toBe(true) + expect(queryClient.getQueryState(searchReplaceKey)?.isInvalidated).toBe(true) + expect(queryClient.getQueryState(unrelatedKey)?.isInvalidated).toBe(false) + }) +}) diff --git a/apps/sim/hooks/queries/utils/selector-keys.ts b/apps/sim/hooks/queries/utils/selector-keys.ts index 4cb3d60eb43..7c63d70cb77 100644 --- a/apps/sim/hooks/queries/utils/selector-keys.ts +++ b/apps/sim/hooks/queries/utils/selector-keys.ts @@ -1,8 +1,14 @@ +import type { QueryClient } from '@tanstack/react-query' import type { SelectorKey } from '@/lib/selectors/manifest' import type { SelectorScope } from '@/lib/selectors/types' +export const selectorQueryRoots = { + selectors: ['selectors'], + workflowSearchReplace: ['workflow-search-replace'], +} as const + export const selectorKeys = { - all: ['selectors'] as const, + all: selectorQueryRoots.selectors, scoped: (selectorKey: SelectorKey, scope: SelectorScope | undefined, surfaceId: string) => [ ...selectorKeys.all, @@ -26,3 +32,11 @@ export const selectorKeys = { ...(ordinal === undefined ? [] : [ordinal]), ] as const, } + +/** Invalidates every client cache that executes or hydrates dynamic selectors. */ +export async function invalidateSelectorQueries(queryClient: QueryClient): Promise { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: selectorQueryRoots.selectors }), + queryClient.invalidateQueries({ queryKey: selectorQueryRoots.workflowSearchReplace }), + ]) +} diff --git a/apps/sim/hooks/queries/workflow-search-replace.ts b/apps/sim/hooks/queries/workflow-search-replace.ts index 72930944b19..46feacf004a 100644 --- a/apps/sim/hooks/queries/workflow-search-replace.ts +++ b/apps/sim/hooks/queries/workflow-search-replace.ts @@ -43,6 +43,7 @@ import { fetchOAuthCredentials, } from '@/hooks/queries/oauth/oauth-credentials' import { collectDuplicateNames, disambiguateLabelByFolder } from '@/hooks/queries/utils/folder-tree' +import { selectorQueryRoots } from '@/hooks/queries/utils/selector-keys' import type { WorkflowFolder } from '@/stores/folders/types' /** Stable identity while a folder list loads, so `select` isn't re-keyed on it. */ @@ -58,7 +59,7 @@ export interface WorkflowSearchResolvedResource { } export const workflowSearchReplaceKeys = { - all: ['workflow-search-replace'] as const, + all: selectorQueryRoots.workflowSearchReplace, resourceDetails: () => [...workflowSearchReplaceKeys.all, 'resource-detail'] as const, oauthDetails: (workflowId?: string) => [...workflowSearchReplaceKeys.resourceDetails(), 'oauth', workflowId ?? ''] as const, diff --git a/apps/sim/lib/environment/utils.test.ts b/apps/sim/lib/environment/utils.test.ts index 0d56b57d9b7..28ebece0e7f 100644 --- a/apps/sim/lib/environment/utils.test.ts +++ b/apps/sim/lib/environment/utils.test.ts @@ -50,6 +50,7 @@ vi.mock('@/lib/workspaces/permissions/utils', () => ({ import { getEffectiveDecryptedEnv, getEffectiveEnvironmentSnapshot, + getEffectiveEnvironmentVariableNames, getExecutionEnvironment, getPersonalAndWorkspaceEnv, invalidateEffectiveDecryptedEnvCache, @@ -57,6 +58,123 @@ import { WorkspaceEnvAccessError, } from '@/lib/environment/utils' +describe('getEffectiveEnvironmentVariableNames', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + invalidateEffectiveDecryptedEnvCache({ userId: 'names-user' }) + mockCheckWorkspaceAccess.mockResolvedValue({ + exists: true, + hasAccess: true, + canWrite: true, + canAdmin: false, + }) + mockGetAccessibleEnvCredentials.mockResolvedValue([]) + encryptionMockFns.mockDecryptSecret.mockReset() + }) + + it('lists only stored, accessible names across personal and workspace scopes without decryption', async () => { + mockGetAccessibleEnvCredentials.mockResolvedValue([ + { + type: 'env_workspace', + envKey: 'WORKSPACE_VISIBLE', + envOwnerUserId: null, + updatedAt: new Date('2026-01-01T00:00:00.000Z'), + unredacted: false, + }, + { + type: 'env_workspace', + envKey: 'DUPLICATE', + envOwnerUserId: null, + updatedAt: new Date('2026-01-01T00:00:00.000Z'), + unredacted: false, + }, + { + type: 'env_workspace', + envKey: 'MISSING_WORKSPACE', + envOwnerUserId: null, + updatedAt: new Date('2026-01-01T00:00:00.000Z'), + unredacted: false, + }, + { + type: 'env_personal', + envKey: 'SHARED_PRESENT', + envOwnerUserId: 'owner-2', + updatedAt: new Date('2026-01-02T00:00:00.000Z'), + }, + { + type: 'env_personal', + envKey: 'SHARED_MISSING', + envOwnerUserId: 'owner-3', + updatedAt: new Date('2026-01-01T00:00:00.000Z'), + }, + ]) + queueTableRows(environment, [ + { variables: { OWN_ONLY: 'own-cipher', DUPLICATE: 'duplicate-cipher' } }, + ]) + queueTableRows(workspaceEnvironment, [ + { + variables: { + WORKSPACE_VISIBLE: 'workspace-cipher', + DUPLICATE: 'duplicate-workspace-cipher', + WORKSPACE_HIDDEN: 'hidden-cipher', + }, + }, + ]) + queueTableRows(environment, [ + { userId: 'owner-2', variables: { SHARED_PRESENT: 'shared-cipher' } }, + { userId: 'owner-3', variables: { UNRELATED: 'unrelated-cipher' } }, + ]) + + await expect( + getEffectiveEnvironmentVariableNames('names-user', 'workspace-1') + ).resolves.toEqual(['DUPLICATE', 'OWN_ONLY', 'SHARED_PRESENT', 'WORKSPACE_VISIBLE']) + expect(encryptionMockFns.mockDecryptSecret).not.toHaveBeenCalled() + + // A later snapshot performs a fresh lookup, proving the names read did not warm its LRU. + queueTableRows(environment, [{ variables: { FRESH_PERSONAL: 'fresh-personal-cipher' } }]) + queueTableRows(workspaceEnvironment, [ + { variables: { WORKSPACE_VISIBLE: 'fresh-workspace-cipher' } }, + ]) + queueTableRows(environment, [ + { userId: 'owner-2', variables: { SHARED_PRESENT: 'fresh-shared-cipher' } }, + { userId: 'owner-3', variables: {} }, + ]) + encryptionMockFns.mockDecryptSecret.mockImplementation(async (encryptedValue: string) => ({ + decrypted: `plain:${encryptedValue}`, + })) + + await expect( + getEffectiveEnvironmentSnapshot('names-user', 'workspace-1') + ).resolves.toMatchObject({ + personalEncrypted: { + FRESH_PERSONAL: 'fresh-personal-cipher', + SHARED_PRESENT: 'fresh-shared-cipher', + }, + workspaceEncrypted: { WORKSPACE_VISIBLE: 'fresh-workspace-cipher' }, + }) + expect(encryptionMockFns.mockDecryptSecret).toHaveBeenCalledTimes(3) + }) + + it('includes stored legacy workspace names for a workspace admin', async () => { + mockCheckWorkspaceAccess.mockResolvedValue({ + exists: true, + hasAccess: true, + canWrite: true, + canAdmin: true, + }) + queueTableRows(environment, [{ variables: {} }]) + queueTableRows(workspaceEnvironment, [ + { variables: { LEGACY_KEY: 'legacy-cipher', CURRENT_KEY: 'current-cipher' } }, + ]) + + await expect( + getEffectiveEnvironmentVariableNames('names-user', 'workspace-1') + ).resolves.toEqual(['CURRENT_KEY', 'LEGACY_KEY']) + expect(encryptionMockFns.mockDecryptSecret).not.toHaveBeenCalled() + }) +}) + describe('getPersonalAndWorkspaceEnv access filtering', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/apps/sim/lib/environment/utils.ts b/apps/sim/lib/environment/utils.ts index ed931ef9276..ed2a71d151e 100644 --- a/apps/sim/lib/environment/utils.ts +++ b/apps/sim/lib/environment/utils.ts @@ -142,11 +142,25 @@ export async function getEnvironmentVariableKeys(userId: string): Promise<{ } } -export async function getPersonalAndWorkspaceEnv( +interface AccessibleEncryptedEnvironment { + personalEncrypted: Record + workspaceEncrypted: Record + personalOwners: Record + workspaceUnredactedKeys: string[] +} + +/** + * Loads only the encrypted environment slices the caller may use. + * + * Keeping this before decryption gives name-only consumers the exact same workspace, + * credential, shared-personal precedence, and stored-value checks as runtime resolution + * without exposing plaintext or touching the decrypted snapshot cache. + */ +async function loadAccessibleEncryptedEnvironment( userId: string, workspaceId?: string, options?: { workspaceAccess?: WorkspaceAccess } -): Promise { +): Promise { let workspaceCanAdmin = false if (workspaceId) { const access = options?.workspaceAccess ?? (await checkWorkspaceAccess(workspaceId, userId)) @@ -237,6 +251,42 @@ export async function getPersonalAndWorkspaceEnv( ) } + return { + personalEncrypted, + workspaceEncrypted, + personalOwners, + workspaceUnredactedKeys: accessibleEnvCredentials + .filter((row) => row.type === 'env_workspace' && row.unredacted) + .map((row) => row.envKey), + } +} + +/** + * Lists the effective environment names visible to a caller without decrypting values. + * This deliberately performs a fresh ACL-aware encrypted lookup instead of populating or + * reading the short-lived decrypted environment snapshot cache. + */ +export async function getEffectiveEnvironmentVariableNames( + userId: string, + workspaceId?: string +): Promise { + const { personalEncrypted, workspaceEncrypted } = await loadAccessibleEncryptedEnvironment( + userId, + workspaceId + ) + return [ + ...new Set([...Object.keys(personalEncrypted), ...Object.keys(workspaceEncrypted)]), + ].sort() +} + +export async function getPersonalAndWorkspaceEnv( + userId: string, + workspaceId?: string, + options?: { workspaceAccess?: WorkspaceAccess } +): Promise { + const { personalEncrypted, workspaceEncrypted, personalOwners, workspaceUnredactedKeys } = + await loadAccessibleEncryptedEnvironment(userId, workspaceId, options) + const decryptionFailures: string[] = [] const decryptAll = async (src: Record, source: 'personal' | 'workspace') => { @@ -283,9 +333,7 @@ export async function getPersonalAndWorkspaceEnv( personalOwners, conflicts, decryptionFailures, - workspaceUnredactedKeys: accessibleEnvCredentials - .filter((row) => row.type === 'env_workspace' && row.unredacted) - .map((row) => row.envKey), + workspaceUnredactedKeys, } } diff --git a/apps/sim/lib/selectors/application/execute-selector.test.ts b/apps/sim/lib/selectors/application/execute-selector.test.ts index 006359c595a..b5e0f53fb60 100644 --- a/apps/sim/lib/selectors/application/execute-selector.test.ts +++ b/apps/sim/lib/selectors/application/execute-selector.test.ts @@ -169,4 +169,80 @@ describe('executeSelector', () => { expect(logged).not.toContain('credential-1') expect(logged).not.toContain('context') }) + + it('restores exact detail-id repeats from reference provenance before sanitization', async () => { + const { sanitizeSelectorResult } = await vi.importActual< + typeof import('@/lib/selectors/server/sanitize') + >('@/lib/selectors/server/sanitize') + + mocks.resolveScope.mockImplementationOnce(async () => { + mocks.events.push('canonical-scope') + return { + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + selectorKey: 'google.drive', + selectorManifest: getSelectorManifestEntry('google.drive'), + selectorScope: scope, + } + }) + mocks.resolveReferences.mockImplementationOnce(async ({ protectedValues }) => { + mocks.events.push('reference-resolution') + protectedValues.add('resolved-file-id') + return { + context: { oauthCredential: 'credential-1' }, + request: { kind: 'detail', id: 'resolved-file-id' }, + references: new Map([ + [ + 'request.id', + { + field: 'request.id', + name: 'GOOGLE_FILE_ID', + scope: 'workspace', + visible: false, + }, + ], + ]), + } + }) + mocks.executeAttachment.mockImplementationOnce(async () => { + mocks.events.push('provider-execution') + return { + kind: 'detail', + item: { + id: 'resolved-file-id', + label: 'resolved-file-id', + meta: { resourceId: 'resolved-file-id', mimeType: 'application/pdf' }, + }, + } + }) + mocks.sanitize.mockImplementationOnce((result, protectedValues) => { + mocks.events.push('sanitization') + expect(result).toEqual({ + kind: 'detail', + item: { + id: '{{GOOGLE_FILE_ID}}', + label: '{{GOOGLE_FILE_ID}}', + meta: { resourceId: '{{GOOGLE_FILE_ID}}', mimeType: 'application/pdf' }, + }, + }) + expect(JSON.stringify(result)).not.toContain('resolved-file-id') + expect(protectedValues.contains('resolved-file-id')).toBe(true) + return sanitizeSelectorResult(result, protectedValues) + }) + + await expect( + execute({ + selectorKey: 'google.drive', + request: { kind: 'detail', id: '{{GOOGLE_FILE_ID}}' }, + }) + ).resolves.toEqual({ + kind: 'detail', + item: { + id: '{{GOOGLE_FILE_ID}}', + label: '{{GOOGLE_FILE_ID}}', + meta: { resourceId: '{{GOOGLE_FILE_ID}}', mimeType: 'application/pdf' }, + }, + }) + }) }) diff --git a/apps/sim/lib/selectors/application/execute-selector.ts b/apps/sim/lib/selectors/application/execute-selector.ts index 7f462bd76a7..8330a97947a 100644 --- a/apps/sim/lib/selectors/application/execute-selector.ts +++ b/apps/sim/lib/selectors/application/execute-selector.ts @@ -17,7 +17,8 @@ import { createSelectorProtectedValues } from '@/lib/selectors/server/protected- import { resolveSelectorReferences } from '@/lib/selectors/server/references' import { getServerSelectorAttachment } from '@/lib/selectors/server/registry' import { sanitizeSelectorResult } from '@/lib/selectors/server/sanitize' -import type { SelectorExecutionResult } from '@/lib/selectors/types' +import type { ResolvedSelectorReference } from '@/lib/selectors/server/types' +import type { SelectorExecutionResult, SelectorRequest } from '@/lib/selectors/types' const logger = createLogger('ExecuteSelector') @@ -54,6 +55,45 @@ function validateAuthorizedInput( } } +function restoreReferencedDetailValues(input: { + originalRequest: SelectorRequest + resolvedRequest: SelectorRequest + result: SelectorExecutionResult + references: ReadonlyMap +}): SelectorExecutionResult { + if ( + input.originalRequest.kind !== 'detail' || + input.resolvedRequest.kind !== 'detail' || + input.result.kind !== 'detail' || + !input.result.item || + !input.references.has('request.id') + ) { + return input.result + } + + const item = input.result.item + const resolvedId = input.resolvedRequest.id + const originalId = input.originalRequest.id + const meta = item.meta + ? Object.fromEntries( + Object.entries(item.meta).map(([key, value]) => [ + key, + value === resolvedId ? originalId : value, + ]) + ) + : undefined + + return { + kind: 'detail', + item: { + ...item, + id: originalId, + label: item.label === resolvedId ? originalId : item.label, + ...(meta ? { meta } : {}), + }, + } +} + async function executeAuthorizedSelector(args: { principal: { kind: 'session'; userId: string; sessionId: string } input: ExecuteSelectorInput @@ -87,19 +127,25 @@ async function executeAuthorizedSelector(args: { }) : undefined + const providerResult = await attachment.execute({ + selectorKey: args.input.selectorKey as ServerSelectorKey, + context: resolved.context, + request: resolved.request, + scope: args.input.scope, + workspaceId: args.context.workspaceId, + principal: args.principal, + requesterUserId: args.principal.userId, + credential, + references: resolved.references, + signal: args.input.signal, + protectedValues, + }) const result = sanitizeSelectorResult( - await attachment.execute({ - selectorKey: args.input.selectorKey as ServerSelectorKey, - context: resolved.context, - request: resolved.request, - scope: args.input.scope, - workspaceId: args.context.workspaceId, - principal: args.principal, - requesterUserId: args.principal.userId, - credential, + restoreReferencedDetailValues({ + originalRequest: args.input.request, + resolvedRequest: resolved.request, + result: providerResult, references: resolved.references, - signal: args.input.signal, - protectedValues, }), protectedValues ) diff --git a/apps/sim/lib/selectors/context.ts b/apps/sim/lib/selectors/context.ts index c1e08f6201e..b750899de2c 100644 --- a/apps/sim/lib/selectors/context.ts +++ b/apps/sim/lib/selectors/context.ts @@ -150,6 +150,7 @@ export function buildSelectorContextFromValues( ) continue } + if (!configById.has(dependency)) continue candidate[dependency] = input.values[dependency] } } else { diff --git a/apps/sim/lib/selectors/manifest.test.ts b/apps/sim/lib/selectors/manifest.test.ts index 3d3cc87f908..3765dda0062 100644 --- a/apps/sim/lib/selectors/manifest.test.ts +++ b/apps/sim/lib/selectors/manifest.test.ts @@ -43,4 +43,19 @@ describe('selector manifest', () => { 'imap.mailboxes', ]) }) + + it('keeps shared Microsoft selectors bound only to their intended credential families', () => { + expect(serverSelectorRegistry['onedrive.files'].credential?.serviceIds).toEqual(['onedrive']) + expect(serverSelectorRegistry['onedrive.folders'].credential?.serviceIds).toEqual([ + 'onedrive', + 'microsoft-word', + ]) + expect(serverSelectorRegistry['sharepoint.lists'].credential?.serviceIds).toEqual([ + 'sharepoint', + ]) + expect(serverSelectorRegistry['sharepoint.sites'].credential?.serviceIds).toEqual([ + 'sharepoint', + 'microsoft-excel', + ]) + }) }) diff --git a/apps/sim/lib/selectors/server/internal.test.ts b/apps/sim/lib/selectors/server/internal.test.ts new file mode 100644 index 00000000000..1488d6e54f1 --- /dev/null +++ b/apps/sim/lib/selectors/server/internal.test.ts @@ -0,0 +1,46 @@ +/** + * @vitest-environment node + */ +import { environmentUtilsMockFns, resetEnvironmentUtilsMock } from '@sim/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { internalSelectorAttachments } from '@/lib/selectors/server/internal' +import { createSelectorProtectedValues } from '@/lib/selectors/server/protected-values' + +describe('workspace.secretNames selector', () => { + beforeEach(() => { + resetEnvironmentUtilsMock() + }) + + it('returns the ACL-filtered names without loading the decrypted environment snapshot', async () => { + environmentUtilsMockFns.mockGetEffectiveEnvironmentVariableNames.mockResolvedValue([ + 'PERSONAL_KEY', + 'SHARED_KEY', + ]) + + await expect( + internalSelectorAttachments['workspace.secretNames'].execute({ + selectorKey: 'workspace.secretNames', + context: {}, + request: { kind: 'list' }, + scope: { kind: 'workspace', workspaceId: 'workspace-1' }, + workspaceId: 'workspace-1', + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + requesterUserId: 'user-1', + references: new Map(), + protectedValues: createSelectorProtectedValues(), + }) + ).resolves.toEqual({ + kind: 'list', + items: [ + { id: 'PERSONAL_KEY', label: 'PERSONAL_KEY' }, + { id: 'SHARED_KEY', label: 'SHARED_KEY' }, + ], + }) + + expect(environmentUtilsMockFns.mockGetEffectiveEnvironmentVariableNames).toHaveBeenCalledWith( + 'user-1', + 'workspace-1' + ) + expect(environmentUtilsMockFns.mockGetEffectiveEnvironmentSnapshot).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/selectors/server/internal.ts b/apps/sim/lib/selectors/server/internal.ts index cbe7deaf509..8b22b7aa0ee 100644 --- a/apps/sim/lib/selectors/server/internal.ts +++ b/apps/sim/lib/selectors/server/internal.ts @@ -2,7 +2,7 @@ import { listCredentialGroupSettings } from '@/lib/credential-groups/application import { getCredentialGroupProviderService } from '@/lib/credential-groups/providers' import { listInternalCredentials } from '@/lib/credentials/application/credential-crud' import { fetchOpenRouterEmbeddingModelCatalog } from '@/lib/embeddings/openrouter-model-catalog.server' -import { getEffectiveEnvironmentSnapshot } from '@/lib/environment/utils' +import { getEffectiveEnvironmentVariableNames } from '@/lib/environment/utils' import { listWorkspaceSandboxes } from '@/lib/execution/remote-sandbox/workspace-sandboxes' import { listKnowledgeDocuments, @@ -243,15 +243,11 @@ export const internalSelectorAttachments = { 'workspace.secretNames': { destination: 'fixed', async execute(args: ExecuteServerSelectorArgs) { - const snapshot = await getEffectiveEnvironmentSnapshot(args.requesterUserId, args.workspaceId) - for (const value of Object.values(snapshot.personalDecrypted)) args.protectedValues.add(value) - for (const value of Object.values(snapshot.workspaceDecrypted)) - args.protectedValues.add(value) - const names = new Set([ - ...Object.keys(snapshot.personalDecrypted), - ...Object.keys(snapshot.workspaceDecrypted), - ]) - return listSelectorResult([...names].sort().map((name) => ({ id: name, label: name }))) + const names = await getEffectiveEnvironmentVariableNames( + args.requesterUserId, + args.workspaceId + ) + return listSelectorResult(names.map((name) => ({ id: name, label: name }))) }, }, 'workspace.rawSecretNames': { diff --git a/apps/sim/lib/selectors/server/providers/microsoft.ts b/apps/sim/lib/selectors/server/providers/microsoft.ts index 47694ad2ca4..4b25e7dc288 100644 --- a/apps/sim/lib/selectors/server/providers/microsoft.ts +++ b/apps/sim/lib/selectors/server/providers/microsoft.ts @@ -371,6 +371,11 @@ const plannerCredential = microsoftCredential('microsoft-planner') const outlookCredential = microsoftCredential('outlook') const teamsCredential = microsoftCredential('microsoft-teams') const oneDriveCredential = microsoftCredential('onedrive') +const oneDriveFolderCredential: SelectorCredentialPolicy = { + kind: 'stored', + field: 'oauthCredential', + serviceIds: ['onedrive', 'microsoft-word'], +} const excelCredential = microsoftCredential('microsoft-excel') const wordCredential = microsoftCredential('microsoft-word') @@ -416,7 +421,7 @@ export const microsoftSelectorAttachments = { execute: async (args) => flatSelectorResult(args.request, await listOneDriveFiles(args)), }, 'onedrive.folders': { - credential: oneDriveCredential, + credential: oneDriveFolderCredential, destination: 'fixed', execute: async (args) => flatSelectorResult(args.request, await listOneDriveFolders(args)), }, diff --git a/apps/sim/lib/selectors/server/providers/sharepoint.ts b/apps/sim/lib/selectors/server/providers/sharepoint.ts index 0a93b84773a..6f2e2d4f067 100644 --- a/apps/sim/lib/selectors/server/providers/sharepoint.ts +++ b/apps/sim/lib/selectors/server/providers/sharepoint.ts @@ -16,12 +16,18 @@ import { assertGraphNextPageUrl, getGraphNextPageUrl } from '@/tools/sharepoint/ type SharePointSelectorKey = Extract -const credential = { +const sharepointCredential = { kind: 'stored', field: 'oauthCredential', serviceIds: ['sharepoint'], } as const +const siteCredential = { + kind: 'stored', + field: 'oauthCredential', + serviceIds: ['sharepoint', 'microsoft-excel'], +} as const + async function graphToken(args: ExecuteServerSelectorArgs): Promise { if (!args.credential) throw new SelectorConnectionUnavailableError() return resolveSelectorOAuthAccessToken({ @@ -76,12 +82,12 @@ async function listSites(args: ExecuteServerSelectorArgs): Promise flatSelectorResult(args.request, await listLists(args), true), }, 'sharepoint.sites': { - credential, + credential: siteCredential, destination: 'fixed', execute: async (args) => flatSelectorResult(args.request, await listSites(args), true), }, diff --git a/apps/sim/lib/workflows/subblocks/context.test.ts b/apps/sim/lib/workflows/subblocks/context.test.ts index 34856bcd7b5..ccf5762bb7c 100644 --- a/apps/sim/lib/workflows/subblocks/context.test.ts +++ b/apps/sim/lib/workflows/subblocks/context.test.ts @@ -230,8 +230,22 @@ describe('buildSelectorContextFromBlock', () => { }) }) - it('projects Slack raw-token and trigger credential sources into the canonical field', () => { - const action = buildSelectorContextFromBlock( + it('projects only the active Slack auth source plus trigger credentials', () => { + const oauthAction = buildSelectorContextFromBlock( + 'slack', + subBlocksFromValues({ + authMethod: 'oauth', + credential: 'active-oauth', + botToken: 'xoxb-dormant', + }), + { + selectorKey: 'slack.channels', + dependsOn: ['authMethod', 'credential', 'botToken'], + } + ) + expect(oauthAction.oauthCredential).toBe('active-oauth') + + const botAction = buildSelectorContextFromBlock( 'slack', subBlocksFromValues({ authMethod: 'bot_token', @@ -243,12 +257,12 @@ describe('buildSelectorContextFromBlock', () => { dependsOn: ['authMethod', 'credential', 'botToken'], } ) - expect(action.oauthCredential).toBe('{{SLACK_BOT_TOKEN}}') + expect(botAction.oauthCredential).toBe('{{SLACK_BOT_TOKEN}}') const trigger = buildSelectorContextFromBlock( - 'slack', + 'slack_v2', subBlocksFromValues({ - eventType: 'message.channels', + eventType: 'message', customBotCredential: '{{SLACK_TRIGGER_CREDENTIAL}}', }), { diff --git a/packages/testing/src/mocks/environment-utils.mock.test.ts b/packages/testing/src/mocks/environment-utils.mock.test.ts index c36272aad58..3141dfeda54 100644 --- a/packages/testing/src/mocks/environment-utils.mock.test.ts +++ b/packages/testing/src/mocks/environment-utils.mock.test.ts @@ -32,6 +32,9 @@ describe('environment-utils mock', () => { conflicts: [], decryptionFailures: [], }) + await expect( + environmentUtilsMock.getEffectiveEnvironmentVariableNames('user-1') + ).resolves.toEqual([]) await expect(environmentUtilsMock.upsertPersonalEnvVars('user-1', {})).resolves.toEqual({ added: [], updated: [], diff --git a/packages/testing/src/mocks/environment-utils.mock.ts b/packages/testing/src/mocks/environment-utils.mock.ts index 23501b9734e..b4411d72b66 100644 --- a/packages/testing/src/mocks/environment-utils.mock.ts +++ b/packages/testing/src/mocks/environment-utils.mock.ts @@ -90,6 +90,7 @@ export const environmentUtilsMockFns = { mockGetEffectiveEnvironmentSnapshot: vi .fn() .mockImplementation(async () => emptyPersonalAndWorkspaceEnv()), + mockGetEffectiveEnvironmentVariableNames: vi.fn().mockResolvedValue([]), mockUpsertPersonalEnvVars: vi.fn().mockResolvedValue({ added: [], updated: [] }), mockUpsertWorkspaceEnvVars: vi.fn().mockResolvedValue([]), mockGetEffectiveDecryptedEnv: vi.fn().mockResolvedValue({}), @@ -112,6 +113,7 @@ export function resetEnvironmentUtilsMock(): void { environmentUtilsMockFns.mockGetEffectiveEnvironmentSnapshot .mockReset() .mockImplementation(async () => emptyPersonalAndWorkspaceEnv()) + environmentUtilsMockFns.mockGetEffectiveEnvironmentVariableNames.mockReset().mockResolvedValue([]) environmentUtilsMockFns.mockUpsertPersonalEnvVars .mockReset() .mockResolvedValue({ added: [], updated: [] }) @@ -135,6 +137,8 @@ export const environmentUtilsMock = { getPersonalAndWorkspaceEnv: environmentUtilsMockFns.mockGetPersonalAndWorkspaceEnv, getExecutionEnvironment: environmentUtilsMockFns.mockGetExecutionEnvironment, getEffectiveEnvironmentSnapshot: environmentUtilsMockFns.mockGetEffectiveEnvironmentSnapshot, + getEffectiveEnvironmentVariableNames: + environmentUtilsMockFns.mockGetEffectiveEnvironmentVariableNames, upsertPersonalEnvVars: environmentUtilsMockFns.mockUpsertPersonalEnvVars, upsertWorkspaceEnvVars: environmentUtilsMockFns.mockUpsertWorkspaceEnvVars, getEffectiveDecryptedEnv: environmentUtilsMockFns.mockGetEffectiveDecryptedEnv, From d32809222c6d3a7ba34dcceb59e9b057af7a689b Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Thu, 27 Aug 2026 20:43:48 -0700 Subject: [PATCH 05/13] fix(selectors): harden exact reference handling --- apps/sim/lib/environment/utils.test.ts | 167 ++++++++++++++++++ apps/sim/lib/environment/utils.ts | 61 +++++++ .../application/execute-selector.test.ts | 48 +++-- .../selectors/application/execute-selector.ts | 40 ++++- .../lib/selectors/server/references.test.ts | 80 ++++++--- apps/sim/lib/selectors/server/references.ts | 34 ++-- .../sim/lib/selectors/server/sanitize.test.ts | 74 ++++++++ apps/sim/lib/selectors/server/sanitize.ts | 39 ++-- .../src/mocks/environment-utils.mock.test.ts | 3 + .../src/mocks/environment-utils.mock.ts | 4 + 10 files changed, 476 insertions(+), 74 deletions(-) diff --git a/apps/sim/lib/environment/utils.test.ts b/apps/sim/lib/environment/utils.test.ts index 28ebece0e7f..a0e7cfb582e 100644 --- a/apps/sim/lib/environment/utils.test.ts +++ b/apps/sim/lib/environment/utils.test.ts @@ -54,6 +54,7 @@ import { getExecutionEnvironment, getPersonalAndWorkspaceEnv, invalidateEffectiveDecryptedEnvCache, + resolveEffectiveEnvironmentVariables, upsertWorkspaceEnvVars, WorkspaceEnvAccessError, } from '@/lib/environment/utils' @@ -175,6 +176,172 @@ describe('getEffectiveEnvironmentVariableNames', () => { }) }) +describe('resolveEffectiveEnvironmentVariables', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + invalidateEffectiveDecryptedEnvCache({ userId: 'resolver-user' }) + mockCheckWorkspaceAccess.mockResolvedValue({ + exists: true, + hasAccess: true, + canWrite: true, + canAdmin: false, + }) + mockGetAccessibleEnvCredentials.mockResolvedValue([]) + encryptionMockFns.mockDecryptSecret.mockReset() + }) + + it('decrypts only unique requested accessible values with workspace precedence', async () => { + mockGetAccessibleEnvCredentials.mockResolvedValue([ + { + type: 'env_workspace', + envKey: 'VISIBLE_SHARED', + envOwnerUserId: null, + updatedAt: new Date('2026-01-01T00:00:00.000Z'), + unredacted: true, + }, + { + type: 'env_workspace', + envKey: 'HIDDEN_SHARED', + envOwnerUserId: null, + updatedAt: new Date('2026-01-01T00:00:00.000Z'), + unredacted: false, + }, + { + type: 'env_workspace', + envKey: 'DUPLICATE', + envOwnerUserId: null, + updatedAt: new Date('2026-01-01T00:00:00.000Z'), + unredacted: false, + }, + { + type: 'env_workspace', + envKey: 'BROKEN', + envOwnerUserId: null, + updatedAt: new Date('2026-01-01T00:00:00.000Z'), + unredacted: false, + }, + { + type: 'env_personal', + envKey: 'SHARED_PERSONAL', + envOwnerUserId: 'owner-2', + updatedAt: new Date('2026-01-01T00:00:00.000Z'), + }, + ]) + queueTableRows(environment, [ + { + variables: { + OWN_PERSONAL: 'own-cipher', + DUPLICATE: 'personal-shadow-cipher', + UNREQUESTED_PERSONAL: 'unrequested-personal-cipher', + }, + }, + ]) + queueTableRows(workspaceEnvironment, [ + { + variables: { + VISIBLE_SHARED: 'visible-cipher', + HIDDEN_SHARED: 'hidden-cipher', + DUPLICATE: 'workspace-cipher', + BROKEN: 'broken-cipher', + INACCESSIBLE: 'inaccessible-cipher', + UNREQUESTED_WORKSPACE: 'unrequested-workspace-cipher', + }, + }, + ]) + queueTableRows(environment, [ + { userId: 'owner-2', variables: { SHARED_PERSONAL: 'shared-personal-cipher' } }, + ]) + encryptionMockFns.mockDecryptSecret.mockImplementation(async (encryptedValue: string) => { + if (encryptedValue === 'broken-cipher') throw new Error('cannot decrypt') + return { decrypted: `plain:${encryptedValue}` } + }) + + await expect( + resolveEffectiveEnvironmentVariables('resolver-user', 'workspace-1', [ + 'OWN_PERSONAL', + 'SHARED_PERSONAL', + 'VISIBLE_SHARED', + 'HIDDEN_SHARED', + 'DUPLICATE', + 'DUPLICATE', + 'BROKEN', + 'MISSING', + 'INACCESSIBLE', + 'constructor', + ]) + ).resolves.toEqual({ + OWN_PERSONAL: { + value: 'plain:own-cipher', + scope: 'personal', + visible: true, + }, + SHARED_PERSONAL: { + value: 'plain:shared-personal-cipher', + scope: 'personal', + visible: false, + }, + VISIBLE_SHARED: { + value: 'plain:visible-cipher', + scope: 'workspace', + visible: true, + }, + HIDDEN_SHARED: { + value: 'plain:hidden-cipher', + scope: 'workspace', + visible: false, + }, + DUPLICATE: { + value: 'plain:workspace-cipher', + scope: 'workspace', + visible: false, + }, + }) + expect(encryptionMockFns.mockDecryptSecret.mock.calls.map(([value]) => value)).toEqual([ + 'own-cipher', + 'shared-personal-cipher', + 'visible-cipher', + 'hidden-cipher', + 'workspace-cipher', + 'broken-cipher', + ]) + }) + + it('performs a fresh lookup without reading or warming the snapshot cache', async () => { + encryptionMockFns.mockDecryptSecret.mockImplementation(async (encryptedValue: string) => ({ + decrypted: `plain:${encryptedValue}`, + })) + + queueTableRows(environment, [{ variables: { ROTATING: 'first-cipher' } }]) + queueTableRows(workspaceEnvironment, [{ variables: {} }]) + await expect( + resolveEffectiveEnvironmentVariables('resolver-user', 'workspace-1', ['ROTATING']) + ).resolves.toEqual({ + ROTATING: { value: 'plain:first-cipher', scope: 'personal', visible: true }, + }) + + queueTableRows(environment, [{ variables: { ROTATING: 'snapshot-cipher' } }]) + queueTableRows(workspaceEnvironment, [{ variables: {} }]) + await expect( + getEffectiveEnvironmentSnapshot('resolver-user', 'workspace-1') + ).resolves.toMatchObject({ personalDecrypted: { ROTATING: 'plain:snapshot-cipher' } }) + + queueTableRows(environment, [{ variables: { ROTATING: 'fresh-cipher' } }]) + queueTableRows(workspaceEnvironment, [{ variables: {} }]) + await expect( + resolveEffectiveEnvironmentVariables('resolver-user', 'workspace-1', ['ROTATING']) + ).resolves.toEqual({ + ROTATING: { value: 'plain:fresh-cipher', scope: 'personal', visible: true }, + }) + + await expect( + getEffectiveEnvironmentSnapshot('resolver-user', 'workspace-1') + ).resolves.toMatchObject({ personalDecrypted: { ROTATING: 'plain:snapshot-cipher' } }) + expect(encryptionMockFns.mockDecryptSecret).toHaveBeenCalledTimes(3) + expect(mockCheckWorkspaceAccess).toHaveBeenCalledTimes(3) + }) +}) + describe('getPersonalAndWorkspaceEnv access filtering', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/apps/sim/lib/environment/utils.ts b/apps/sim/lib/environment/utils.ts index ed2a71d151e..441b2ddd3c4 100644 --- a/apps/sim/lib/environment/utils.ts +++ b/apps/sim/lib/environment/utils.ts @@ -279,6 +279,67 @@ export async function getEffectiveEnvironmentVariableNames( ].sort() } +export interface ResolvedEnvironmentVariable { + value: string + scope: 'personal' | 'workspace' + visible: boolean +} + +/** + * Resolves only the requested environment variables through a fresh ACL-aware lookup. + * + * This deliberately neither reads nor populates the runtime environment snapshot cache. + * Workspace values take precedence over personal values, matching normal resolution. Missing, + * inaccessible, and undecryptable values are all omitted so callers cannot distinguish them. + */ +export async function resolveEffectiveEnvironmentVariables( + userId: string, + workspaceId: string | undefined, + requestedNames: readonly string[] +): Promise> { + const names = [...new Set(requestedNames)] + if (names.length === 0) return {} + + const { personalEncrypted, workspaceEncrypted, personalOwners, workspaceUnredactedKeys } = + await loadAccessibleEncryptedEnvironment(userId, workspaceId) + const visibleWorkspaceNames = new Set(workspaceUnredactedKeys) + + const resolvedEntries = await Promise.all( + names.map(async (name) => { + const fromWorkspace = Object.hasOwn(workspaceEncrypted, name) + const fromPersonal = Object.hasOwn(personalEncrypted, name) + const encrypted = fromWorkspace + ? workspaceEncrypted[name] + : fromPersonal + ? personalEncrypted[name] + : undefined + if (encrypted === undefined) return null + + try { + const { decrypted } = await decryptSecret(encrypted) + return [ + name, + { + value: decrypted, + scope: fromWorkspace ? 'workspace' : 'personal', + visible: fromWorkspace + ? visibleWorkspaceNames.has(name) + : personalOwners[name] === userId, + }, + ] as const + } catch { + return null + } + }) + ) + + return Object.fromEntries( + resolvedEntries.filter( + (entry): entry is readonly [string, ResolvedEnvironmentVariable] => entry !== null + ) + ) +} + export async function getPersonalAndWorkspaceEnv( userId: string, workspaceId?: string, diff --git a/apps/sim/lib/selectors/application/execute-selector.test.ts b/apps/sim/lib/selectors/application/execute-selector.test.ts index b5e0f53fb60..84dacc04ab1 100644 --- a/apps/sim/lib/selectors/application/execute-selector.test.ts +++ b/apps/sim/lib/selectors/application/execute-selector.test.ts @@ -170,10 +170,22 @@ describe('executeSelector', () => { expect(logged).not.toContain('context') }) - it('restores exact detail-id repeats from reference provenance before sanitization', async () => { + it.each([ + { + name: 'restores exact detail-id repeats after sanitization', + referenceName: 'GOOGLE_FILE_ID', + resolvedId: 'resolved-file-id', + }, + { + name: 'restores a reference whose spelling overlaps its resolved ID', + referenceName: 'ID', + resolvedId: 'ID', + }, + ])('$name', async ({ referenceName, resolvedId }) => { const { sanitizeSelectorResult } = await vi.importActual< typeof import('@/lib/selectors/server/sanitize') >('@/lib/selectors/server/sanitize') + const originalId = `{{${referenceName}}}` mocks.resolveScope.mockImplementationOnce(async () => { mocks.events.push('canonical-scope') @@ -188,16 +200,16 @@ describe('executeSelector', () => { }) mocks.resolveReferences.mockImplementationOnce(async ({ protectedValues }) => { mocks.events.push('reference-resolution') - protectedValues.add('resolved-file-id') + protectedValues.add(resolvedId) return { context: { oauthCredential: 'credential-1' }, - request: { kind: 'detail', id: 'resolved-file-id' }, + request: { kind: 'detail', id: resolvedId }, references: new Map([ [ 'request.id', { field: 'request.id', - name: 'GOOGLE_FILE_ID', + name: referenceName, scope: 'workspace', visible: false, }, @@ -210,38 +222,38 @@ describe('executeSelector', () => { return { kind: 'detail', item: { - id: 'resolved-file-id', - label: 'resolved-file-id', - meta: { resourceId: 'resolved-file-id', mimeType: 'application/pdf' }, + id: resolvedId, + label: resolvedId, + meta: { resourceId: resolvedId, mimeType: 'application/pdf' }, }, } }) - mocks.sanitize.mockImplementationOnce((result, protectedValues) => { + mocks.sanitize.mockImplementationOnce((result, protectedValues, options) => { mocks.events.push('sanitization') expect(result).toEqual({ kind: 'detail', item: { - id: '{{GOOGLE_FILE_ID}}', - label: '{{GOOGLE_FILE_ID}}', - meta: { resourceId: '{{GOOGLE_FILE_ID}}', mimeType: 'application/pdf' }, + id: resolvedId, + label: resolvedId, + meta: { resourceId: resolvedId, mimeType: 'application/pdf' }, }, }) - expect(JSON.stringify(result)).not.toContain('resolved-file-id') - expect(protectedValues.contains('resolved-file-id')).toBe(true) - return sanitizeSelectorResult(result, protectedValues) + expect(protectedValues.contains(resolvedId)).toBe(true) + expect(options).toEqual({ allowedDetailExactProtectedValue: resolvedId }) + return sanitizeSelectorResult(result, protectedValues, options) }) await expect( execute({ selectorKey: 'google.drive', - request: { kind: 'detail', id: '{{GOOGLE_FILE_ID}}' }, + request: { kind: 'detail', id: originalId }, }) ).resolves.toEqual({ kind: 'detail', item: { - id: '{{GOOGLE_FILE_ID}}', - label: '{{GOOGLE_FILE_ID}}', - meta: { resourceId: '{{GOOGLE_FILE_ID}}', mimeType: 'application/pdf' }, + id: originalId, + label: originalId, + meta: { resourceId: originalId, mimeType: 'application/pdf' }, }, }) }) diff --git a/apps/sim/lib/selectors/application/execute-selector.ts b/apps/sim/lib/selectors/application/execute-selector.ts index 8330a97947a..ac8582acefb 100644 --- a/apps/sim/lib/selectors/application/execute-selector.ts +++ b/apps/sim/lib/selectors/application/execute-selector.ts @@ -94,6 +94,21 @@ function restoreReferencedDetailValues(input: { } } +function getReferencedDetailResolvedId(input: { + originalRequest: SelectorRequest + resolvedRequest: SelectorRequest + references: ReadonlyMap +}): string | undefined { + if ( + input.originalRequest.kind !== 'detail' || + input.resolvedRequest.kind !== 'detail' || + !input.references.has('request.id') + ) { + return undefined + } + return input.resolvedRequest.id +} + async function executeAuthorizedSelector(args: { principal: { kind: 'session'; userId: string; sessionId: string } input: ExecuteSelectorInput @@ -140,15 +155,24 @@ async function executeAuthorizedSelector(args: { signal: args.input.signal, protectedValues, }) - const result = sanitizeSelectorResult( - restoreReferencedDetailValues({ - originalRequest: args.input.request, - resolvedRequest: resolved.request, - result: providerResult, - references: resolved.references, - }), - protectedValues + const referencedDetailResolvedId = getReferencedDetailResolvedId({ + originalRequest: args.input.request, + resolvedRequest: resolved.request, + references: resolved.references, + }) + const sanitizedProviderResult = sanitizeSelectorResult( + providerResult, + protectedValues, + referencedDetailResolvedId + ? { allowedDetailExactProtectedValue: referencedDetailResolvedId } + : undefined ) + const result = restoreReferencedDetailValues({ + originalRequest: args.input.request, + resolvedRequest: resolved.request, + result: sanitizedProviderResult, + references: resolved.references, + }) logger.info('Executed selector', { selectorKey: args.input.selectorKey, diff --git a/apps/sim/lib/selectors/server/references.test.ts b/apps/sim/lib/selectors/server/references.test.ts index bca9ea7913c..3f3ce9d405d 100644 --- a/apps/sim/lib/selectors/server/references.test.ts +++ b/apps/sim/lib/selectors/server/references.test.ts @@ -43,25 +43,26 @@ describe('resolveSelectorReferences', () => { }) expect(result.references.size).toBe(0) expect(protectedValues.contains('prefix-literal-password-suffix')).toBe(true) - expect(environmentUtilsMockFns.mockGetEffectiveEnvironmentSnapshot).not.toHaveBeenCalled() + expect(environmentUtilsMockFns.mockResolveEffectiveEnvironmentVariables).not.toHaveBeenCalled() }) it('resolves personal, visible shared, and hidden use-only references with workspace precedence', async () => { - environmentUtilsMockFns.mockGetEffectiveEnvironmentSnapshot.mockResolvedValue({ - personalEncrypted: {}, - workspaceEncrypted: {}, - personalDecrypted: { - PERSONAL_HOST: 'personal.example.com', - SHARED_USERNAME: 'personal-shadow', + environmentUtilsMockFns.mockResolveEffectiveEnvironmentVariables.mockResolvedValue({ + PERSONAL_HOST: { + value: 'personal.example.com', + scope: 'personal', + visible: true, }, - workspaceDecrypted: { - SHARED_USERNAME: 'shared-user', - SHARED_PASSWORD: 'hidden-password', + SHARED_USERNAME: { + value: 'shared-user', + scope: 'workspace', + visible: true, + }, + SHARED_PASSWORD: { + value: 'hidden-password', + scope: 'workspace', + visible: false, }, - personalOwners: { PERSONAL_HOST: 'user-1' }, - conflicts: ['SHARED_USERNAME'], - decryptionFailures: [], - workspaceUnredactedKeys: ['SHARED_USERNAME'], }) const protectedValues = createSelectorProtectedValues() @@ -102,19 +103,15 @@ describe('resolveSelectorReferences', () => { }, ]) expect(protectedValues.contains('hidden-password')).toBe(true) + expect(environmentUtilsMockFns.mockResolveEffectiveEnvironmentVariables).toHaveBeenCalledWith( + 'user-1', + 'workspace-1', + ['PERSONAL_HOST', 'SHARED_USERNAME', 'SHARED_PASSWORD'] + ) }) it('projects missing, inaccessible, embedded, and runtime references to one context error', async () => { - environmentUtilsMockFns.mockGetEffectiveEnvironmentSnapshot.mockResolvedValue({ - personalEncrypted: {}, - workspaceEncrypted: {}, - personalDecrypted: {}, - workspaceDecrypted: {}, - personalOwners: {}, - conflicts: [], - decryptionFailures: ['INACCESSIBLE_SHARED'], - workspaceUnredactedKeys: [], - }) + environmentUtilsMockFns.mockResolveEffectiveEnvironmentVariables.mockResolvedValue({}) const contexts = [ { host: '{{MISSING}}', username: 'user', password: 'password' }, @@ -134,4 +131,39 @@ describe('resolveSelectorReferences', () => { ).rejects.toEqual(new SelectorContextUnavailableError()) } }) + + it('loads duplicate references once while retaining field-level provenance', async () => { + environmentUtilsMockFns.mockResolveEffectiveEnvironmentVariables.mockResolvedValue({ + REPEATED: { + value: 'resolved-value', + scope: 'workspace', + visible: false, + }, + }) + + const result = await resolveSelectorReferences({ + ...baseInput, + context: { + host: '{{REPEATED}}', + port: '993', + secure: 'true', + username: '{{REPEATED}}', + password: 'literal-password', + }, + request: { kind: 'detail', id: '{{REPEATED}}' }, + protectedValues: createSelectorProtectedValues(), + }) + + expect(environmentUtilsMockFns.mockResolveEffectiveEnvironmentVariables).toHaveBeenCalledWith( + 'user-1', + 'workspace-1', + ['REPEATED'] + ) + expect(result.context).toMatchObject({ + host: 'resolved-value', + username: 'resolved-value', + }) + expect(result.request).toEqual({ kind: 'detail', id: 'resolved-value' }) + expect([...result.references.keys()]).toEqual(['host', 'username', 'request.id']) + }) }) diff --git a/apps/sim/lib/selectors/server/references.ts b/apps/sim/lib/selectors/server/references.ts index b44d5900673..13a9dcf7a6c 100644 --- a/apps/sim/lib/selectors/server/references.ts +++ b/apps/sim/lib/selectors/server/references.ts @@ -1,4 +1,4 @@ -import { getEffectiveEnvironmentSnapshot } from '@/lib/environment/utils' +import { resolveEffectiveEnvironmentVariables } from '@/lib/environment/utils' import { getSelectorManifestEntry, type ServerSelectorKey } from '@/lib/selectors/manifest' import { SelectorContextUnavailableError } from '@/lib/selectors/server/errors' import type { @@ -55,31 +55,37 @@ export async function resolveSelectorReferences(input: { return { context, request: input.request, references: new Map() } } - const snapshot = await getEffectiveEnvironmentSnapshot(input.requesterUserId, input.workspaceId) + const referenceNames = [ + ...new Set( + resolvableValues.flatMap((value) => { + const match = EXACT_ENVIRONMENT_REFERENCE.exec(value) + return match ? [match[1]] : [] + }) + ), + ] + const resolvedVariables = await resolveEffectiveEnvironmentVariables( + input.requesterUserId, + input.workspaceId, + referenceNames + ) const references = new Map() - const visibleWorkspaceNames = new Set(snapshot.workspaceUnredactedKeys) const resolve = (field: string, value: string): string => { const match = EXACT_ENVIRONMENT_REFERENCE.exec(value) if (!match) return value const name = match[1] - const fromWorkspace = Object.hasOwn(snapshot.workspaceDecrypted, name) - const resolved = fromWorkspace - ? snapshot.workspaceDecrypted[name] - : snapshot.personalDecrypted[name] - if (resolved === undefined) throw new SelectorContextUnavailableError() + const variable = Object.hasOwn(resolvedVariables, name) ? resolvedVariables[name] : undefined + if (!variable) throw new SelectorContextUnavailableError() - input.protectedValues.add(resolved) + input.protectedValues.add(variable.value) references.set(field, { field, name, - scope: fromWorkspace ? 'workspace' : 'personal', - visible: fromWorkspace - ? visibleWorkspaceNames.has(name) - : snapshot.personalOwners[name] === input.requesterUserId, + scope: variable.scope, + visible: variable.visible, }) - return resolved + return variable.value } const context: SelectorContext = {} diff --git a/apps/sim/lib/selectors/server/sanitize.test.ts b/apps/sim/lib/selectors/server/sanitize.test.ts index 3cc9e305777..c7cdd89ec15 100644 --- a/apps/sim/lib/selectors/server/sanitize.test.ts +++ b/apps/sim/lib/selectors/server/sanitize.test.ts @@ -66,4 +66,78 @@ describe('sanitizeSelectorResult', () => { nextCursor: 'next-page', }) }) + + it('allows only exact protected detail-id repeats for later reference restoration', () => { + const protectedValues = createSelectorProtectedValues() + protectedValues.add('ID') + + expect( + sanitizeSelectorResult( + { + kind: 'detail', + item: { id: 'ID', label: 'ID', meta: { resourceId: 'ID' } }, + }, + protectedValues, + { allowedDetailExactProtectedValue: 'ID' } + ) + ).toEqual({ + kind: 'detail', + item: { id: 'ID', label: 'ID', meta: { resourceId: 'ID' } }, + }) + + const rejectedResults: SelectorExecutionResult[] = [ + { + kind: 'detail', + item: { id: 'ID', label: 'prefix-ID-suffix' }, + }, + { + kind: 'detail', + item: { id: 'ID', label: 'ID', meta: { resourceId: 'prefix-ID-suffix' } }, + }, + { + kind: 'list', + items: [{ id: 'ID', label: 'ID' }], + }, + { + kind: 'list', + items: [{ id: 'safe-id', label: 'Safe label' }], + nextCursor: 'ID', + }, + ] + + for (const result of rejectedResults) { + expect(() => + sanitizeSelectorResult(result, protectedValues, { + allowedDetailExactProtectedValue: 'ID', + }) + ).toThrow(SelectorOptionsUnavailableError) + } + + expect( + sanitizeSelectorResult({ kind: 'detail', item: null }, protectedValues, { + allowedDetailExactProtectedValue: 'ID', + }) + ).toEqual({ kind: 'detail', item: null }) + }) + + it('still rejects other protected values when allowing an exact detail ID', () => { + const protectedValues = createSelectorProtectedValues() + protectedValues.add('resolved-id') + protectedValues.add('another-secret') + + expect(() => + sanitizeSelectorResult( + { + kind: 'detail', + item: { + id: 'resolved-id', + label: 'resolved-id', + meta: { resourceId: 'another-secret' }, + }, + }, + protectedValues, + { allowedDetailExactProtectedValue: 'resolved-id' } + ) + ).toThrow(SelectorOptionsUnavailableError) + }) }) diff --git a/apps/sim/lib/selectors/server/sanitize.ts b/apps/sim/lib/selectors/server/sanitize.ts index 73786e1f7ed..0d014166b5f 100644 --- a/apps/sim/lib/selectors/server/sanitize.ts +++ b/apps/sim/lib/selectors/server/sanitize.ts @@ -11,17 +11,28 @@ const MAX_OPTIONS = 10_000 const MAX_OPTION_TEXT = 16 * 1024 const MAX_META_FIELDS = 32 -function requireSafeString(value: unknown, protectedValues: SelectorProtectedValues): string { +export interface SanitizeSelectorResultOptions { + allowedDetailExactProtectedValue?: string +} + +function requireSafeString( + value: unknown, + protectedValues: SelectorProtectedValues, + allowedExactValue?: string +): string { if (typeof value !== 'string' || value.length === 0 || value.length > MAX_OPTION_TEXT) { throw new SelectorOptionsUnavailableError() } - if (protectedValues.contains(value)) throw new SelectorOptionsUnavailableError() + if (protectedValues.contains(value) && value !== allowedExactValue) { + throw new SelectorOptionsUnavailableError() + } return value } function sanitizeMeta( value: unknown, - protectedValues: SelectorProtectedValues + protectedValues: SelectorProtectedValues, + allowedExactValue?: string ): SafeOptionMeta | undefined { if (value === undefined) return undefined if (!value || typeof value !== 'object' || Array.isArray(value)) { @@ -44,7 +55,11 @@ function sanitizeMeta( if (typeof entry === 'number' && !Number.isFinite(entry)) { throw new SelectorOptionsUnavailableError() } - if (typeof entry === 'string' && protectedValues.contains(entry)) { + if ( + typeof entry === 'string' && + protectedValues.contains(entry) && + entry !== allowedExactValue + ) { throw new SelectorOptionsUnavailableError() } meta[key] = entry as SafeOptionMetaValue @@ -54,28 +69,32 @@ function sanitizeMeta( function sanitizeOption( value: unknown, - protectedValues: SelectorProtectedValues + protectedValues: SelectorProtectedValues, + allowedExactValue?: string ): SafeSelectorOption { if (!value || typeof value !== 'object' || Array.isArray(value)) { throw new SelectorOptionsUnavailableError() } const option = value as { id?: unknown; label?: unknown; meta?: unknown } - const meta = sanitizeMeta(option.meta, protectedValues) + const meta = sanitizeMeta(option.meta, protectedValues, allowedExactValue) return { - id: requireSafeString(option.id, protectedValues), - label: requireSafeString(option.label, protectedValues), + id: requireSafeString(option.id, protectedValues, allowedExactValue), + label: requireSafeString(option.label, protectedValues, allowedExactValue), ...(meta ? { meta } : {}), } } export function sanitizeSelectorResult( result: SelectorExecutionResult, - protectedValues: SelectorProtectedValues + protectedValues: SelectorProtectedValues, + options?: SanitizeSelectorResultOptions ): SelectorExecutionResult { if (result.kind === 'detail') { return { kind: 'detail', - item: result.item ? sanitizeOption(result.item, protectedValues) : null, + item: result.item + ? sanitizeOption(result.item, protectedValues, options?.allowedDetailExactProtectedValue) + : null, } } diff --git a/packages/testing/src/mocks/environment-utils.mock.test.ts b/packages/testing/src/mocks/environment-utils.mock.test.ts index 3141dfeda54..6b4a32fd974 100644 --- a/packages/testing/src/mocks/environment-utils.mock.test.ts +++ b/packages/testing/src/mocks/environment-utils.mock.test.ts @@ -35,6 +35,9 @@ describe('environment-utils mock', () => { await expect( environmentUtilsMock.getEffectiveEnvironmentVariableNames('user-1') ).resolves.toEqual([]) + await expect( + environmentUtilsMock.resolveEffectiveEnvironmentVariables('user-1', 'ws-1', ['API_KEY']) + ).resolves.toEqual({}) await expect(environmentUtilsMock.upsertPersonalEnvVars('user-1', {})).resolves.toEqual({ added: [], updated: [], diff --git a/packages/testing/src/mocks/environment-utils.mock.ts b/packages/testing/src/mocks/environment-utils.mock.ts index b4411d72b66..7e90d37329e 100644 --- a/packages/testing/src/mocks/environment-utils.mock.ts +++ b/packages/testing/src/mocks/environment-utils.mock.ts @@ -91,6 +91,7 @@ export const environmentUtilsMockFns = { .fn() .mockImplementation(async () => emptyPersonalAndWorkspaceEnv()), mockGetEffectiveEnvironmentVariableNames: vi.fn().mockResolvedValue([]), + mockResolveEffectiveEnvironmentVariables: vi.fn().mockResolvedValue({}), mockUpsertPersonalEnvVars: vi.fn().mockResolvedValue({ added: [], updated: [] }), mockUpsertWorkspaceEnvVars: vi.fn().mockResolvedValue([]), mockGetEffectiveDecryptedEnv: vi.fn().mockResolvedValue({}), @@ -114,6 +115,7 @@ export function resetEnvironmentUtilsMock(): void { .mockReset() .mockImplementation(async () => emptyPersonalAndWorkspaceEnv()) environmentUtilsMockFns.mockGetEffectiveEnvironmentVariableNames.mockReset().mockResolvedValue([]) + environmentUtilsMockFns.mockResolveEffectiveEnvironmentVariables.mockReset().mockResolvedValue({}) environmentUtilsMockFns.mockUpsertPersonalEnvVars .mockReset() .mockResolvedValue({ added: [], updated: [] }) @@ -139,6 +141,8 @@ export const environmentUtilsMock = { getEffectiveEnvironmentSnapshot: environmentUtilsMockFns.mockGetEffectiveEnvironmentSnapshot, getEffectiveEnvironmentVariableNames: environmentUtilsMockFns.mockGetEffectiveEnvironmentVariableNames, + resolveEffectiveEnvironmentVariables: + environmentUtilsMockFns.mockResolveEffectiveEnvironmentVariables, upsertPersonalEnvVars: environmentUtilsMockFns.mockUpsertPersonalEnvVars, upsertWorkspaceEnvVars: environmentUtilsMockFns.mockUpsertWorkspaceEnvVars, getEffectiveDecryptedEnv: environmentUtilsMockFns.mockGetEffectiveDecryptedEnv, From 96df0b71c269e8b2a52342c8f236645f84d2455b Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Fri, 28 Aug 2026 10:03:30 -0700 Subject: [PATCH 06/13] fix(selectors): address Cubic review findings --- apps/sim/app/api/auth/oauth/utils.test.ts | 14 +- apps/sim/app/api/webhooks/route.test.ts | 120 +++++++++ apps/sim/app/api/webhooks/route.ts | 3 + .../components/dropdown/dropdown.test.tsx | 13 + .../components/dropdown/dropdown.tsx | 10 + .../selector-combobox.test.tsx | 251 ++++++++++++++++++ .../selector-combobox/selector-combobox.tsx | 67 +++-- .../hooks/use-fetched-options.test.tsx | 159 +++++++++++ .../sub-block/hooks/use-fetched-options.ts | 39 ++- apps/sim/blocks/blocks/microsoft_excel.ts | 2 +- .../blocks/selector-backed-subblocks.test.ts | 25 +- apps/sim/hooks/queries/selectors.test.tsx | 34 ++- apps/sim/hooks/queries/selectors.ts | 6 + apps/sim/lib/auth/auth.ts | 4 +- apps/sim/lib/credentials/draft-hooks.test.ts | 8 +- apps/sim/lib/credentials/draft-hooks.ts | 8 +- apps/sim/lib/imap/connection.server.test.ts | 110 +++++--- apps/sim/lib/imap/connection.server.ts | 55 ++-- .../oauth/__tests__/terminal-errors.test.ts | 25 ++ apps/sim/lib/oauth/credential-service.test.ts | 14 +- apps/sim/lib/oauth/credential-service.ts | 9 +- apps/sim/lib/oauth/refresh-coordination.ts | 21 ++ .../sim/lib/selectors/server/internal.test.ts | 70 ++++- apps/sim/lib/selectors/server/internal.ts | 5 +- .../server/providers/atlassian.test.ts | 41 +++ .../selectors/server/providers/atlassian.ts | 17 +- .../selectors/server/providers/confluence.ts | 1 + .../providers/credential-bundle.test.ts | 38 +++ .../server/providers/credential-bundle.ts | 1 + .../selectors/server/providers/google.test.ts | 74 ++++++ .../lib/selectors/server/providers/google.ts | 31 +-- .../selectors/server/providers/imap.test.ts | 97 +++++++ .../lib/selectors/server/providers/imap.ts | 4 +- .../lib/selectors/server/providers/jira.ts | 1 + .../selectors/server/providers/jsm.test.ts | 13 +- .../sim/lib/selectors/server/providers/jsm.ts | 1 + .../server/providers/provider-http.test.ts | 66 +++++ .../server/providers/provider-http.ts | 52 +++- .../server/providers/zoho-desk.test.ts | 60 +++++ .../selectors/server/providers/zoho-desk.ts | 3 +- .../sim/lib/selectors/server/sanitize.test.ts | 18 ++ apps/sim/lib/selectors/server/sanitize.ts | 11 +- apps/sim/lib/webhooks/deploy.ts | 2 +- apps/sim/lib/webhooks/polling/imap.test.ts | 41 +++ apps/sim/lib/webhooks/providers/imap.ts | 3 +- apps/sim/lib/webhooks/providers/types.ts | 4 +- .../indexer-selector-context.test.ts | 56 ++++ .../lib/workflows/search-replace/indexer.ts | 43 +-- 48 files changed, 1564 insertions(+), 186 deletions(-) create mode 100644 apps/sim/app/api/webhooks/route.test.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox.test.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-fetched-options.test.tsx create mode 100644 apps/sim/lib/oauth/refresh-coordination.ts create mode 100644 apps/sim/lib/selectors/server/providers/atlassian.test.ts create mode 100644 apps/sim/lib/selectors/server/providers/credential-bundle.test.ts create mode 100644 apps/sim/lib/selectors/server/providers/google.test.ts create mode 100644 apps/sim/lib/selectors/server/providers/imap.test.ts create mode 100644 apps/sim/lib/selectors/server/providers/provider-http.test.ts create mode 100644 apps/sim/lib/selectors/server/providers/zoho-desk.test.ts create mode 100644 apps/sim/lib/workflows/search-replace/indexer-selector-context.test.ts diff --git a/apps/sim/app/api/auth/oauth/utils.test.ts b/apps/sim/app/api/auth/oauth/utils.test.ts index 05782849e6d..5bcec970c87 100644 --- a/apps/sim/app/api/auth/oauth/utils.test.ts +++ b/apps/sim/app/api/auth/oauth/utils.test.ts @@ -37,6 +37,7 @@ import { refreshTokenIfNeeded, resolveServiceAccountToken, } from '@/lib/oauth/credential-service' +import { getOAuthRefreshCoordinationIdentity } from '@/lib/oauth/refresh-coordination' import { ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID, GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID, @@ -326,9 +327,11 @@ describe('OAuth Utils', () => { const result = await refreshTokenIfNeeded('request-id', slackCredential(), 'row-1') expect(result).toEqual({ accessToken: 'new-at', refreshed: true }) + const installationIdentity = getOAuthRefreshCoordinationIdentity('slack:T08CM6ZNYBE') expect(redisConfigMockFns.mockAcquireLock.mock.calls[0][0]).toBe( - 'oauth:refresh:slack:T08CM6ZNYBE' + `oauth:refresh:${installationIdentity}` ) + expect(installationIdentity).not.toContain('T08CM6ZNYBE') expect(redisConfigMockFns.mockAcquireLock.mock.calls[0][2]).toBe(30) expect(mockRefreshOAuthToken).toHaveBeenCalledWith('slack', 'live-rt') expect(mockSet).toHaveBeenCalledWith( @@ -367,7 +370,11 @@ describe('OAuth Utils', () => { ) expect(result).toEqual({ accessToken: 'new-at', refreshed: true }) - expect(redisConfigMockFns.mockAcquireLock.mock.calls[0][0]).toBe('oauth:refresh:row-1') + const rowIdentity = getOAuthRefreshCoordinationIdentity('row-1') + expect(redisConfigMockFns.mockAcquireLock.mock.calls[0][0]).toBe( + `oauth:refresh:${rowIdentity}` + ) + expect(rowIdentity).not.toContain('row-1') expect(mockRefreshOAuthToken).toHaveBeenCalledWith('slack', 'stale-rt') }) @@ -391,8 +398,9 @@ describe('OAuth Utils', () => { 'Failed to refresh token' ) + const installationIdentity = getOAuthRefreshCoordinationIdentity('slack:T08CM6ZNYBE') expect(fakeRedis.set).toHaveBeenCalledWith( - 'oauth:dead:slack:T08CM6ZNYBE', + `oauth:dead:${installationIdentity}`, 'token_revoked', 'EX', 3600 diff --git a/apps/sim/app/api/webhooks/route.test.ts b/apps/sim/app/api/webhooks/route.test.ts new file mode 100644 index 00000000000..2e2fad78b4d --- /dev/null +++ b/apps/sim/app/api/webhooks/route.test.ts @@ -0,0 +1,120 @@ +/** + * @vitest-environment node + */ +import { webhook, workflow } from '@sim/db/schema' +import { + auditMock, + authMockFns, + createMockRequest, + dbChainMockFns, + posthogServerMock, + queueTableRows, + resetDbChainMock, + telemetryMock, + workflowAuthzMockFns, +} from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + configurePolling: vi.fn(), + createExternalWebhookSubscription: vi.fn(), + findConflictingWebhookPathOwner: vi.fn(), + getProviderHandler: vi.fn(), + resolveEnvVarsInObject: vi.fn(), + shouldRecreateExternalWebhookSubscription: vi.fn(), +})) + +vi.mock('@sim/audit', () => auditMock) +vi.mock('@/lib/core/telemetry', () => telemetryMock) +vi.mock('@/lib/posthog/server', () => posthogServerMock) +vi.mock('@/lib/webhooks/env-resolver', () => ({ + resolveEnvVarsInObject: mocks.resolveEnvVarsInObject, +})) +vi.mock('@/lib/webhooks/provider-subscriptions', () => ({ + cleanupExternalWebhook: vi.fn(), + createExternalWebhookSubscription: mocks.createExternalWebhookSubscription, + shouldRecreateExternalWebhookSubscription: mocks.shouldRecreateExternalWebhookSubscription, +})) +vi.mock('@/lib/webhooks/providers', () => ({ + getProviderHandler: mocks.getProviderHandler, +})) +vi.mock('@/lib/webhooks/utils.server', () => ({ + findConflictingWebhookPathOwner: mocks.findConflictingWebhookPathOwner, +})) + +import { POST } from '@/app/api/webhooks/route' + +describe('POST /api/webhooks polling configuration', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + authMockFns.mockGetSession.mockResolvedValue({ + user: { id: 'actor-1', name: 'Actor', email: 'actor@example.com' }, + session: { id: 'session-1' }, + }) + workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ + allowed: true, + status: 200, + workflow: { id: 'workflow-1' }, + workspacePermission: 'write', + }) + workflowAuthzMockFns.mockAssertWorkflowMutable.mockResolvedValue(undefined) + mocks.findConflictingWebhookPathOwner.mockResolvedValue(null) + mocks.resolveEnvVarsInObject.mockImplementation(async (config) => config) + mocks.shouldRecreateExternalWebhookSubscription.mockReturnValue(false) + mocks.createExternalWebhookSubscription.mockResolvedValue({ + updatedProviderConfig: { + host: '{{IMAP_HOST}}', + username: '{{IMAP_USERNAME}}', + password: '{{IMAP_PASSWORD}}', + }, + externalSubscriptionCreated: false, + }) + mocks.configurePolling.mockResolvedValue(true) + mocks.getProviderHandler.mockReturnValue({ configurePolling: mocks.configurePolling }) + }) + + it('passes the authenticated actor and canonical workflow workspace to legacy polling setup', async () => { + const savedWebhook = { + id: 'webhook-1', + workflowId: 'workflow-1', + blockId: 'block-1', + path: 'imap-hook', + provider: 'imap', + providerConfig: { + host: '{{IMAP_HOST}}', + username: '{{IMAP_USERNAME}}', + password: '{{IMAP_PASSWORD}}', + }, + isActive: true, + } + queueTableRows(workflow, [ + { id: 'workflow-1', userId: 'owner-1', workspaceId: 'canonical-workspace' }, + ]) + queueTableRows(webhook, []) + dbChainMockFns.returning.mockResolvedValueOnce([savedWebhook]) + + const response = await POST( + createMockRequest( + 'POST', + { + workflowId: 'workflow-1', + blockId: 'block-1', + path: 'imap-hook', + provider: 'imap', + providerConfig: savedWebhook.providerConfig, + }, + {}, + 'http://localhost:3000/api/webhooks' + ) + ) + + expect(response.status).toBe(201) + expect(mocks.configurePolling).toHaveBeenCalledWith({ + webhook: savedWebhook, + requestId: 'mock-request-id', + userId: 'actor-1', + workspaceId: 'canonical-workspace', + }) + }) +}) diff --git a/apps/sim/app/api/webhooks/route.ts b/apps/sim/app/api/webhooks/route.ts index 0efbb4b3f0f..d50941bf811 100644 --- a/apps/sim/app/api/webhooks/route.ts +++ b/apps/sim/app/api/webhooks/route.ts @@ -506,6 +506,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const success = await pollingHandler.configurePolling({ webhook: savedWebhook, requestId, + userId, + workspaceId: + typeof workflowRecord.workspaceId === 'string' ? workflowRecord.workspaceId : null, }) if (!success) { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.test.tsx index 9cfb29e41be..05fdfd8f796 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.test.tsx @@ -13,6 +13,7 @@ const { fetched } = vi.hoisted(() => ({ isLoadingOptions: false, hasLoadedOptions: true, fetchError: null as string | null, + hydratedOptions: [] as { id: string; label: string }[], }, })) @@ -52,6 +53,7 @@ vi.mock( hasLoadedOptions: fetched.hasLoadedOptions, fetchError: fetched.fetchError, hydratedOption: null, + hydratedOptions: fetched.hydratedOptions, missingOptionId: null, refetch: () => {}, }), @@ -141,4 +143,15 @@ describe('Dropdown multi-select stale selections', () => { fetched.options = previous } }) + + it('uses hydrated labels for selected values missing from the loaded list', () => { + fetched.hydratedOptions = [{ id: 'col_gone', label: 'Former column' }] + try { + const html = render() + expect(html).toContain('Former column [selected]') + expect(html).toContain('Former column') + } finally { + fetched.hydratedOptions = [] + } + }) }) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx index 8faa147531b..3c1f29a13e8 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx @@ -162,6 +162,7 @@ export const Dropdown = memo(function Dropdown({ hasLoadedOptions, fetchError, hydratedOption, + hydratedOptions, isDynamic, refetch: refetchOptions, } = useFetchedOptions({ @@ -173,6 +174,7 @@ export const Dropdown = memo(function Dropdown({ isPreview: Boolean(isPreview), disabled: Boolean(disabled), valueToHydrate: singleValue, + valuesToHydrate: multiValues ?? undefined, localOptions: evaluatedOptions, }) @@ -205,6 +207,13 @@ export const Dropdown = memo(function Dropdown({ } } + for (const option of hydratedOptions) { + const alreadyPresent = opts.some((existing) => + typeof existing === 'string' ? existing === option.id : existing.id === option.id + ) + if (!alreadyPresent) opts = [option, ...opts] + } + // A multi-select can only drop a value by clicking its row; a selection the // loaded list no longer carries gets one so it can be removed in place. if (multiValues && isDynamic) { @@ -223,6 +232,7 @@ export const Dropdown = memo(function Dropdown({ normalizedFetchedOptions, evaluatedOptions, hydratedOption, + hydratedOptions, multiValues, hasLoadedOptions, ]) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox.test.tsx new file mode 100644 index 00000000000..43a50ee14bb --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox.test.tsx @@ -0,0 +1,251 @@ +/** + * @vitest-environment node + */ +import type { ReactNode } from 'react' +import { renderToStaticMarkup } from 'react-dom/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockUseSelectorOptionDetail, + mockUseSelectorOptionDetails, + mockUseSelectorOptions, + selectorState, +} = vi.hoisted(() => ({ + mockUseSelectorOptionDetail: vi.fn(), + mockUseSelectorOptionDetails: vi.fn(), + mockUseSelectorOptions: vi.fn(), + selectorState: { storeValue: 'stored-label' as string | string[] }, +})) + +vi.mock('@sim/emcn', () => ({ + Button: ({ children }: { children?: ReactNode }) => , + Combobox: ({ value }: { value?: string }) => {value}, +})) + +vi.mock('@sim/emcn/icons', () => ({ X: () => null })) + +vi.mock('@/hooks/queries/selectors', () => ({ + useSelectorOptions: mockUseSelectorOptions, + useSelectorOptionDetail: mockUseSelectorOptionDetail, + useSelectorOptionDetails: mockUseSelectorOptionDetails, + useSelectorOptionMap: ( + options: Array<{ id: string; label: string }>, + extra?: { id: string; label: string } + ) => new Map((extra ? [extra, ...options] : options).map((option) => [option.id, option])), +})) + +vi.mock('@/hooks/use-debounce', () => ({ useDebounce: (value: string) => value })) + +vi.mock( + '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value', + () => ({ useSubBlockValue: () => [selectorState.storeValue, vi.fn()] }) +) + +vi.mock( + '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/providers/active-search-target-provider', + () => ({ useActiveSearchTarget: () => null }) +) + +vi.mock( + '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/sub-block-input-controller', + () => ({ + SubBlockInputController: ({ + children, + }: { + children: (args: { + ref: { current: null } + onDrop: () => void + onDragOver: () => void + }) => ReactNode + }) => children({ ref: { current: null }, onDrop: vi.fn(), onDragOver: vi.fn() }), + }) +) + +vi.mock( + '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text', + () => ({ formatDisplayText: (text: string) => text }) +) + +vi.mock( + '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/workflow-search-highlight', + () => ({ getWorkflowSearchLabelHighlight: () => undefined }) +) + +import { SelectorCombobox } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox' + +beforeEach(() => { + vi.clearAllMocks() + selectorState.storeValue = 'stored-label' + mockUseSelectorOptions.mockReturnValue({ + data: [], + isLoading: false, + hasMore: false, + error: null, + }) + mockUseSelectorOptionDetail.mockImplementation( + (_key: string, args: { detailId?: string; enabled: boolean }) => ({ + data: args.enabled && args.detailId ? { id: args.detailId, label: 'Hydrated label' } : null, + isLoading: false, + }) + ) + mockUseSelectorOptionDetails.mockImplementation( + (_key: string, args: { detailIds: string[]; enabled: boolean }) => + args.enabled ? args.detailIds.map((id) => ({ id, label: `Hydrated ${id}` })) : [] + ) +}) + +describe('SelectorCombobox label hydration', () => { + it.each([ + { + state: 'preview', + isPreview: true, + disabled: false, + previewValue: 'preview-label', + detailId: 'preview-label', + }, + { + state: 'disabled', + isPreview: false, + disabled: true, + previewValue: undefined, + detailId: 'stored-label', + }, + ])('keeps the list disabled but hydrates the selected value when $state', (state) => { + const html = renderToStaticMarkup( + + ) + + expect(html).toContain('Hydrated label') + expect(mockUseSelectorOptions).toHaveBeenCalledWith( + 'jira.issues', + expect.objectContaining({ enabled: false }) + ) + expect(mockUseSelectorOptionDetail).toHaveBeenCalledWith( + 'jira.issues', + expect.objectContaining({ detailId: state.detailId, enabled: true }) + ) + }) + + it.each([ + { + state: 'preview', + isPreview: true, + disabled: false, + previewValue: ['preview-label', '{{SHARED_LABEL}}', ''], + detailIds: ['preview-label', '{{SHARED_LABEL}}'], + }, + { + state: 'disabled', + isPreview: false, + disabled: true, + previewValue: undefined, + detailIds: ['stored-one', 'stored-two'], + }, + ])('hydrates selected multi-values without enabling the list when $state', (state) => { + if (!state.isPreview) selectorState.storeValue = ['stored-one', 'stored-two'] + + const html = renderToStaticMarkup( + + ) + + for (const id of state.detailIds) expect(html).toContain(`Hydrated ${id}`) + if (state.isPreview) expect(html).toContain('<Block.output>') + expect(mockUseSelectorOptions).toHaveBeenCalledWith( + 'jira.issues', + expect.objectContaining({ enabled: false }) + ) + expect(mockUseSelectorOptionDetails).toHaveBeenCalledWith( + 'jira.issues', + expect.objectContaining({ detailIds: state.detailIds, enabled: true }) + ) + }) + + it.each([ + { + state: 'preview', + isPreview: true, + disabled: false, + previewValue: ['preview-label', '{{SHARED_LABEL}}', ''], + listedValues: ['preview-label'], + }, + { + state: 'disabled', + isPreview: false, + disabled: true, + previewValue: undefined, + listedValues: ['stored-one', 'stored-two'], + }, + ])('uses a search-free list to hydrate no-detail multi-values when $state', (state) => { + if (!state.isPreview) selectorState.storeValue = ['stored-one', 'stored-two'] + mockUseSelectorOptions.mockReturnValue({ + data: state.listedValues.map((id) => ({ id, label: `Listed ${id}` })), + isLoading: false, + hasMore: false, + error: null, + }) + + const html = renderToStaticMarkup( + + ) + + for (const id of state.listedValues) expect(html).toContain(`Listed ${id}`) + if (state.isPreview) { + expect(html).toContain('{{SHARED_LABEL}}') + expect(html).toContain('<Block.output>') + } + expect(mockUseSelectorOptions).toHaveBeenCalledWith( + 'gmail.labels', + expect.objectContaining({ enabled: true, search: undefined }) + ) + expect(mockUseSelectorOptionDetails).toHaveBeenCalledWith( + 'gmail.labels', + expect.objectContaining({ enabled: false }) + ) + }) + + it('does not detail-hydrate a runtime reference', () => { + selectorState.storeValue = '' + + const html = renderToStaticMarkup( + + ) + + expect(html).toContain('<Block.output>') + expect(mockUseSelectorOptionDetail).toHaveBeenCalledWith( + 'gmail.labels', + expect.objectContaining({ detailId: undefined, enabled: false }) + ) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox.tsx index 6cc551ea0b0..5b30e73d4aa 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox.tsx @@ -2,7 +2,7 @@ import type React from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Button, Combobox as EditableCombobox } from '@sim/emcn' import { X } from '@sim/emcn/icons' -import type { SelectorKey } from '@/lib/selectors/manifest' +import { getSelectorManifestEntry, type SelectorKey } from '@/lib/selectors/manifest' import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state' import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text' import { SubBlockInputController } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/sub-block-input-controller' @@ -13,6 +13,7 @@ import type { SubBlockConfig } from '@/blocks/types' import { type SelectorClientContext, useSelectorOptionDetail, + useSelectorOptionDetails, useSelectorOptionMap, useSelectorOptions, } from '@/hooks/queries/selectors' @@ -25,7 +26,7 @@ interface SelectorComboboxProps { selectorContext: SelectorClientContext disabled?: boolean isPreview?: boolean - previewValue?: string | null + previewValue?: string | string[] | null placeholder?: string readOnly?: boolean onOptionChange?: (value: string) => void @@ -51,6 +52,7 @@ export function SelectorCombobox({ multiSelect = false, }: SelectorComboboxProps) { const activeSearchTarget = useActiveSearchTarget() + const manifest = getSelectorManifestEntry(selectorKey) const [storeValueRaw, setStoreValue] = useSubBlockValue( blockId, subBlock.id @@ -63,6 +65,23 @@ export function SelectorCombobox({ : isPreview ? previewedValue : storeValue + const selectedValues = useMemo(() => { + if (!multiSelect) return [] + const source = isPreview ? previewValue : storeValueRaw + if (Array.isArray(source)) return source.map(String) + if (typeof source === 'string' && source.length > 0) { + return source + .split(',') + .map((value) => value.trim()) + .filter(Boolean) + } + return [] + }, [multiSelect, isPreview, previewValue, storeValueRaw]) + const detailId = activeValue && !activeValue.startsWith('<') ? activeValue : undefined + const multiDetailIds = useMemo( + () => selectedValues.filter((id) => !id.startsWith('<')), + [selectedValues] + ) const [searchTerm, setSearchTerm] = useState('') const [isEditing, setIsEditing] = useState(false) const [multiInput, setMultiInput] = useState('') @@ -79,6 +98,11 @@ export function SelectorCombobox({ const debouncedSearch = useDebounce(trimmedSearch, SEARCH_DEBOUNCE_MS) const activeSearch = trimmedSearch === '' ? '' : debouncedSearch const surfaceId = `${blockId}:${subBlock.id}` + const listInteractionEnabled = !disabled && !isPreview && !readOnly + const listHydrationEnabled = + !listInteractionEnabled && + !manifest.supportsDetail && + Boolean(detailId || multiDetailIds.length > 0) const { data: options = [], isLoading, @@ -86,21 +110,35 @@ export function SelectorCombobox({ error, } = useSelectorOptions(selectorKey, { context: selectorContext, - search: allowSearch ? activeSearch : undefined, - enabled: !disabled, + search: listInteractionEnabled && allowSearch ? activeSearch : undefined, + enabled: listInteractionEnabled || listHydrationEnabled, + surfaceId, + }) + const { data: detailOption, isLoading: isLoadingDetail } = useSelectorOptionDetail(selectorKey, { + context: selectorContext, + detailId, + enabled: manifest.supportsDetail && Boolean(detailId), surfaceId, }) - const { data: detailOption } = useSelectorOptionDetail(selectorKey, { + const detailOptions = useSelectorOptionDetails(selectorKey, { context: selectorContext, - detailId: activeValue, - enabled: !disabled, + detailIds: multiDetailIds, + enabled: manifest.supportsDetail && multiSelect && multiDetailIds.length > 0, surfaceId, }) - const optionMap = useSelectorOptionMap(options, detailOption ?? undefined) + const optionsWithDetails = useMemo(() => { + const merged = new Map(options.map((option) => [option.id, option])) + for (const option of detailOptions) { + if (!merged.has(option.id)) merged.set(option.id, option) + } + return [...merged.values()] + }, [detailOptions, options]) + const optionMap = useSelectorOptionMap(optionsWithDetails, detailOption ?? undefined) const hasMissingOption = Boolean(activeValue) && Boolean(missingOptionLabel) && !isLoading && + !isLoadingDetail && !hasMore && !optionMap.get(activeValue!) const selectedLabel: string = activeValue @@ -156,19 +194,6 @@ export function SelectorCombobox({ [setStoreValue, onOptionChange, readOnly, disabled] ) - const selectedValues = useMemo(() => { - if (!multiSelect) return [] - const source = isPreview ? previewValue : storeValueRaw - if (Array.isArray(source)) return source.map(String) - if (typeof source === 'string' && source.length > 0) { - return source - .split(',') - .map((v) => v.trim()) - .filter(Boolean) - } - return [] - }, [multiSelect, isPreview, previewValue, storeValueRaw]) - const handleMultiChange = useCallback( (values: string[]) => { if (readOnly || disabled) return diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-fetched-options.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-fetched-options.test.tsx new file mode 100644 index 00000000000..ee8964dd78a --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-fetched-options.test.tsx @@ -0,0 +1,159 @@ +/** + * @vitest-environment node + */ +import { renderToStaticMarkup } from 'react-dom/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockUseSelectorOptionDetail, mockUseSelectorOptionDetails, mockUseSelectorOptions } = + vi.hoisted(() => ({ + mockUseSelectorOptionDetail: vi.fn(), + mockUseSelectorOptionDetails: vi.fn(), + mockUseSelectorOptions: vi.fn(), + })) + +vi.mock('@/hooks/queries/selectors', () => ({ + useSelectorOptionDetail: mockUseSelectorOptionDetail, + useSelectorOptionDetails: mockUseSelectorOptionDetails, + useSelectorOptions: mockUseSelectorOptions, +})) + +vi.mock('@/stores/workflows/registry/store', () => ({ + useWorkflowRegistry: (selector: (state: unknown) => unknown) => + selector({ activeWorkflowId: 'workflow-1', hydration: { workspaceId: 'workspace-1' } }), +})) + +vi.mock('@/stores/workflows/workflow/store', () => ({ + useWorkflowStore: (selector: (state: unknown) => unknown) => selector({ blocks: {} }), +})) + +vi.mock('@/stores/workflows/subblock/store', () => ({ + useSubBlockStore: (selector: (state: unknown) => unknown) => selector({ workflowValues: {} }), +})) + +import { useFetchedOptions } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-fetched-options' + +beforeEach(() => { + vi.clearAllMocks() + mockUseSelectorOptions.mockReturnValue({ + data: undefined, + isLoading: false, + isSuccess: false, + error: null, + refetch: vi.fn(), + }) + mockUseSelectorOptionDetail.mockImplementation((_key: string, args: { enabled: boolean }) => ({ + data: args.enabled ? { id: 'issue-1', label: 'Hydrated issue' } : null, + isFetched: args.enabled, + isLoading: false, + })) + mockUseSelectorOptionDetails.mockImplementation( + (_key: string, args: { detailIds: string[]; enabled: boolean }) => + args.enabled ? args.detailIds.map((id) => ({ id, label: `Hydrated ${id}` })) : [] + ) +}) + +describe('useFetchedOptions label hydration', () => { + it.each([ + { state: 'preview', isPreview: true, disabled: false }, + { state: 'disabled', isPreview: false, disabled: true }, + ])('keeps the list disabled but hydrates the selected value when $state', (state) => { + function Probe() { + const result = useFetchedOptions({ + blockId: 'block-1', + subBlockId: 'label', + dependsOnFields: ['credential'], + selectorKey: 'jira.issues', + isPreview: state.isPreview, + disabled: state.disabled, + valueToHydrate: 'issue-1', + localOptions: [], + }) + return {result.hydratedOption?.label} + } + + expect(renderToStaticMarkup()).toContain('Hydrated issue') + expect(mockUseSelectorOptions).toHaveBeenCalledWith( + 'jira.issues', + expect.objectContaining({ enabled: false }) + ) + expect(mockUseSelectorOptionDetail).toHaveBeenCalledWith( + 'jira.issues', + expect.objectContaining({ detailId: 'issue-1', enabled: true }) + ) + }) + + it.each([ + { state: 'preview', isPreview: true, disabled: false }, + { state: 'disabled', isPreview: false, disabled: true }, + ])('hydrates selected multi-values without enabling the list when $state', (state) => { + function Probe() { + const result = useFetchedOptions({ + blockId: 'block-1', + subBlockId: 'labels', + dependsOnFields: ['credential'], + selectorKey: 'jira.issues', + isPreview: state.isPreview, + disabled: state.disabled, + valueToHydrate: undefined, + valuesToHydrate: ['label-1', '{{SHARED_LABEL}}', '', 'local-label'], + localOptions: [{ id: 'local-label' }], + }) + return {result.hydratedOptions.map((option) => option.label).join(',')} + } + + const html = renderToStaticMarkup() + expect(html).toContain('Hydrated label-1') + expect(html).toContain('Hydrated {{SHARED_LABEL}}') + expect(mockUseSelectorOptions).toHaveBeenCalledWith( + 'jira.issues', + expect.objectContaining({ enabled: false }) + ) + expect(mockUseSelectorOptionDetails).toHaveBeenCalledWith( + 'jira.issues', + expect.objectContaining({ + detailIds: ['label-1', '{{SHARED_LABEL}}'], + enabled: true, + }) + ) + }) + + it.each([ + { state: 'preview', isPreview: true, disabled: false }, + { state: 'disabled', isPreview: false, disabled: true }, + ])('uses a search-free list to hydrate no-detail multi-values when $state', (state) => { + mockUseSelectorOptions.mockReturnValue({ + data: [{ id: 'label-1', label: 'Primary label' }], + isLoading: false, + isSuccess: true, + error: null, + refetch: vi.fn(), + }) + + function Probe() { + const result = useFetchedOptions({ + blockId: 'block-1', + subBlockId: 'labels', + dependsOnFields: ['credential'], + selectorKey: 'gmail.labels', + isPreview: state.isPreview, + disabled: state.disabled, + valueToHydrate: undefined, + valuesToHydrate: ['label-1', '{{SHARED_LABEL}}', ''], + localOptions: [], + }) + return {result.fetchedOptions.map((option) => option.label).join(',')} + } + + const html = renderToStaticMarkup() + expect(html).toContain('Primary label') + expect(mockUseSelectorOptions).toHaveBeenCalledWith( + 'gmail.labels', + expect.objectContaining({ enabled: true }) + ) + expect(mockUseSelectorOptions.mock.calls.at(-1)?.[1]).not.toHaveProperty('search') + expect(mockUseSelectorOptionDetails).toHaveBeenCalledWith( + 'gmail.labels', + expect.objectContaining({ enabled: false }) + ) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-fetched-options.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-fetched-options.ts index 1050c8920ce..d5467412ca4 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-fetched-options.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-fetched-options.ts @@ -2,12 +2,13 @@ import { useMemo } from 'react' import { getErrorMessage } from '@sim/utils/errors' -import type { SelectorKey } from '@/lib/selectors/manifest' +import { getSelectorManifestEntry, type SelectorKey } from '@/lib/selectors/manifest' import { buildSelectorContextFromBlock } from '@/lib/workflows/subblocks/context' import { getBlock } from '@/blocks/registry' import { type SelectorClientContext, useSelectorOptionDetail, + useSelectorOptionDetails, useSelectorOptions, } from '@/hooks/queries/selectors' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' @@ -30,6 +31,7 @@ interface UseFetchedOptionsProps { isPreview: boolean disabled: boolean valueToHydrate: string | null | undefined + valuesToHydrate?: readonly string[] localOptions: readonly LocalOption[] } @@ -40,6 +42,7 @@ export interface UseFetchedOptionsResult { hasLoadedOptions: boolean fetchError: string | null hydratedOption: FetchedOption | null + hydratedOptions: FetchedOption[] missingOptionId: string | null refetch: () => void } @@ -62,6 +65,7 @@ export function useFetchedOptions({ isPreview, disabled, valueToHydrate, + valuesToHydrate, localOptions, }: UseFetchedOptionsProps): UseFetchedOptionsResult { const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId) @@ -71,6 +75,7 @@ export function useFetchedOptions({ activeWorkflowId ? state.workflowValues[activeWorkflowId]?.[blockId] : undefined ) const effectiveKey = selectorKey ?? 'workspace.triggerTypes' + const manifest = getSelectorManifestEntry(effectiveKey) const context = useMemo(() => { if (!selectorKey || !block?.type) return {} @@ -107,18 +112,41 @@ export function useFetchedOptions({ workspaceId, ]) - const enabled = Boolean(selectorKey) && !isPreview && !disabled const surfaceId = `${blockId}:${subBlockId}` - const list = useSelectorOptions(effectiveKey, { context, enabled, surfaceId }) const hydrate = Boolean( valueToHydrate && !valueToHydrate.startsWith('<') && !hasLocalOption(localOptions, valueToHydrate) ) + const detailIds = useMemo( + () => + (valuesToHydrate ?? []).filter( + (id) => id && !id.startsWith('<') && !hasLocalOption(localOptions, id) + ), + [localOptions, valuesToHydrate] + ) + const listInteractionEnabled = Boolean(selectorKey) && !isPreview && !disabled + const listHydrationEnabled = Boolean( + selectorKey && + !listInteractionEnabled && + !manifest.supportsDetail && + (hydrate || detailIds.length > 0) + ) + const list = useSelectorOptions(effectiveKey, { + context, + enabled: listInteractionEnabled || listHydrationEnabled, + surfaceId, + }) const detail = useSelectorOptionDetail(effectiveKey, { context, - detailId: valueToHydrate ?? undefined, - enabled: enabled && hydrate, + detailId: manifest.supportsDetail ? (valueToHydrate ?? undefined) : undefined, + enabled: Boolean(selectorKey) && manifest.supportsDetail && hydrate, + surfaceId, + }) + const details = useSelectorOptionDetails(effectiveKey, { + context, + detailIds, + enabled: Boolean(selectorKey) && manifest.supportsDetail && detailIds.length > 0, surfaceId, }) @@ -129,6 +157,7 @@ export function useFetchedOptions({ hasLoadedOptions: list.isSuccess, fetchError: list.error ? getErrorMessage(list.error, 'Failed to fetch options') : null, hydratedOption: detail.data ?? null, + hydratedOptions: details, missingOptionId: hydrate && detail.isFetched && !detail.isLoading && detail.data === null ? (valueToHydrate ?? null) diff --git a/apps/sim/blocks/blocks/microsoft_excel.ts b/apps/sim/blocks/blocks/microsoft_excel.ts index 5e687b5489c..9b38d63a74d 100644 --- a/apps/sim/blocks/blocks/microsoft_excel.ts +++ b/apps/sim/blocks/blocks/microsoft_excel.ts @@ -170,7 +170,7 @@ export const MicrosoftExcelBlock: BlockConfig = { requiredScopes: [], mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', placeholder: 'Select a spreadsheet', - dependsOn: ['credential', 'driveId'], + dependsOn: { all: ['credential'], any: ['credential', 'driveId'] }, mode: 'basic', }, { diff --git a/apps/sim/blocks/selector-backed-subblocks.test.ts b/apps/sim/blocks/selector-backed-subblocks.test.ts index 999307fbbfa..3e9f961cc79 100644 --- a/apps/sim/blocks/selector-backed-subblocks.test.ts +++ b/apps/sim/blocks/selector-backed-subblocks.test.ts @@ -86,22 +86,15 @@ describe('selector-backed sub-blocks', () => { } }) - it('declares optional cascade fields whose values change provider enumeration', () => { - const cases = [{ block: 'microsoft_excel', subBlock: 'spreadsheetId', dependency: 'driveId' }] + it('projects the optional Excel drive without requiring it for OneDrive readiness', () => { + const match = selectorBacked.find( + ({ block, sub }) => block === 'microsoft_excel' && sub.id === 'spreadsheetId' + ) - for (const expected of cases) { - const match = selectorBacked.find( - ({ block, sub }) => block === expected.block && sub.id === expected.subBlock - ) - expect(match, `${expected.block}.${expected.subBlock} is missing`).toBeDefined() - const dependsOn = match?.sub.dependsOn - const declared = Array.isArray(dependsOn) - ? dependsOn - : [...(dependsOn?.all ?? []), ...(dependsOn?.any ?? [])] - expect( - declared, - `${expected.block}.${expected.subBlock} must send ${expected.dependency}` - ).toContain(expected.dependency) - } + expect(match, 'microsoft_excel.spreadsheetId is missing').toBeDefined() + expect(match?.sub.dependsOn).toEqual({ + all: ['credential'], + any: ['credential', 'driveId'], + }) }) }) diff --git a/apps/sim/hooks/queries/selectors.test.tsx b/apps/sim/hooks/queries/selectors.test.tsx index b88ca3d97ea..1d5ff8865f1 100644 --- a/apps/sim/hooks/queries/selectors.test.tsx +++ b/apps/sim/hooks/queries/selectors.test.tsx @@ -180,7 +180,7 @@ describe('generic selector queries', () => { expect(serializedKeys(hook.queryClient)).not.toContain('private phrase') }) - it('uses distinct opaque revisions for dependency changes and later mounts', async () => { + it('uses distinct opaque revisions without retaining obsolete query closures', async () => { mockExecuteSelectorRequest.mockResolvedValue({ kind: 'list', items: [] }) const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) let credential = '{{FIRST_SHARED_CREDENTIAL}}' @@ -195,7 +195,17 @@ describe('generic selector queries', () => { credential = '{{SECOND_SHARED_CREDENTIAL}}' first.rerender(useHook) await waitFor(() => expect(mockExecuteSelectorRequest).toHaveBeenCalledTimes(2)) + await waitFor(() => { + const revisions = queryClient + .getQueryCache() + .getAll() + .map((query) => query.queryKey) + .filter((key) => key.at(-1) !== 'paged') + .map((key) => key.at(-1)) + expect(new Set(revisions).size).toBe(1) + }) first.unmount() + await waitFor(() => expect(queryClient.getQueryCache().getAll()).toHaveLength(0)) const second = renderHookWithClient(useHook, queryClient) await waitFor(() => expect(mockExecuteSelectorRequest).toHaveBeenCalledTimes(3)) @@ -205,11 +215,31 @@ describe('generic selector queries', () => { .getAll() .map((query) => query.queryKey) const revisions = keys.filter((key) => key.at(-1) !== 'paged').map((key) => key.at(-1)) - expect(new Set(revisions).size).toBe(3) + expect(new Set(revisions).size).toBe(1) expect(serializedKeys(queryClient)).not.toContain('SHARED_CREDENTIAL') second.unmount() }) + it.each(['gmail.labels', 'bitbucket.workspaces'] as const)( + 'does not manually refetch the unready %s selector', + async (selectorKey) => { + mockExecuteSelectorRequest.mockResolvedValue({ kind: 'list', items: [] }) + const hook = renderHookWithClient(() => + useSelectorOptions(selectorKey, { + context: { workspaceId: 'workspace-1' }, + surfaceId: `connector:${selectorKey}:field`, + }) + ) + + act(() => hook.getResult().refetch()) + await act(async () => { + await sleep(5) + }) + + expect(mockExecuteSelectorRequest).not.toHaveBeenCalled() + } + ) + it('progressively drains paginated selectors without putting cursors in the base key', async () => { mockExecuteSelectorRequest.mockImplementation( async ({ request }: { request: { cursor?: string } }) => diff --git a/apps/sim/hooks/queries/selectors.ts b/apps/sim/hooks/queries/selectors.ts index 617df441297..7cbbb333174 100644 --- a/apps/sim/hooks/queries/selectors.ts +++ b/apps/sim/hooks/queries/selectors.ts @@ -144,6 +144,7 @@ export function useSelectorOptions( }, enabled: !supportsPagination && prepared.ready, staleTime: prepared.manifest.staleTime, + gcTime: 0, }) const pagedQuery = useInfiniteQuery({ @@ -168,6 +169,7 @@ export function useSelectorOptions( initialPageParam: undefined as string | undefined, enabled: supportsPagination && prepared.ready, staleTime: prepared.manifest.staleTime, + gcTime: 0, }) const pageCount = pagedQuery.data?.pages.length ?? 0 @@ -208,6 +210,7 @@ export function useSelectorOptions( error: (pagedQuery.error as Error | null) ?? null, isSuccess: pagedQuery.isSuccess, refetch: () => { + if (!prepared.ready) return void pagedQuery.refetch() }, } @@ -222,6 +225,7 @@ export function useSelectorOptions( error: (flatQuery.error as Error | null) ?? null, isSuccess: flatQuery.isSuccess, refetch: () => { + if (!prepared.ready) return void flatQuery.refetch() }, } @@ -259,6 +263,7 @@ export function useSelectorOptionDetail( }, enabled, staleTime: prepared.manifest.staleTime, + gcTime: 0, }) } @@ -292,6 +297,7 @@ export function useSelectorOptionDetails( }, enabled: prepared.ready && prepared.manifest.supportsDetail && args.enabled !== false, staleTime: prepared.manifest.staleTime, + gcTime: 0, })), }) return useMemo(() => results.flatMap((result) => (result.data ? [result.data] : [])), [results]) diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts index 152a8354199..07622065a6c 100644 --- a/apps/sim/lib/auth/auth.ts +++ b/apps/sim/lib/auth/auth.ts @@ -112,6 +112,7 @@ import { isMicrosoftProvider, mapMicrosoftProfileToUser, } from '@/lib/oauth/microsoft' +import { clearOAuthRefreshDeadFlag } from '@/lib/oauth/refresh-coordination' import { isSalesforceLoginOrigin, isSalesforceOAuthProviderId, @@ -119,7 +120,6 @@ import { withSalesforceInstanceScope, } from '@/lib/oauth/salesforce' import { extractSlackTeamId, fanOutSlackTokenChain } from '@/lib/oauth/slack' -import { clearDeadFlag } from '@/lib/oauth/terminal-errors' import { getCanonicalScopesForProvider } from '@/lib/oauth/utils' import { joinInstanceOrganization } from '@/lib/organizations/instance-org' import { captureServerEvent, getPostHogClient } from '@/lib/posthog/server' @@ -486,7 +486,7 @@ export const auth = betterAuth({ // Clear the dead flag before fanning out: the connect itself // proves the installation has live tokens, and a fan-out // failure must not leave the hour-long flag blocking refreshes. - await clearDeadFlag(`slack:${teamId}`) + await clearOAuthRefreshDeadFlag(`slack:${teamId}`) await fanOutSlackTokenChain(teamId, { accessToken: account.accessToken, refreshToken: account.refreshToken ?? null, diff --git a/apps/sim/lib/credentials/draft-hooks.test.ts b/apps/sim/lib/credentials/draft-hooks.test.ts index 5737c26ac23..dbbd887f51f 100644 --- a/apps/sim/lib/credentials/draft-hooks.test.ts +++ b/apps/sim/lib/credentials/draft-hooks.test.ts @@ -23,6 +23,7 @@ import { handleCreateCredentialFromDraft, handleReconnectCredential, } from '@/lib/credentials/draft-hooks' +import { getOAuthRefreshCoordinationIdentity } from '@/lib/oauth/refresh-coordination' describe('handleCreateCredentialFromDraft', () => { beforeEach(() => { @@ -55,7 +56,9 @@ describe('handleCreateCredentialFromDraft', () => { expect(dbChainMockFns.update).toHaveBeenCalledWith(schemaMock.credential) expect(dbChainMockFns.set).toHaveBeenCalledWith({ updatedAt: now }) - expect(mocks.clearDeadFlag).toHaveBeenCalledWith('account-1') + expect(mocks.clearDeadFlag).toHaveBeenCalledWith( + getOAuthRefreshCoordinationIdentity('account-1') + ) expect(auditMockFns.mockRecordAudit).toHaveBeenCalledWith( expect.objectContaining({ action: 'credential.reconnected', @@ -109,6 +112,9 @@ describe('handleReconnectCredential', () => { now: new Date('2026-08-14T18:00:00.000Z'), }) + expect(mocks.clearDeadFlag).toHaveBeenCalledWith( + getOAuthRefreshCoordinationIdentity('account-new') + ) expect(auditMockFns.mockRecordAudit).toHaveBeenCalledWith( expect.objectContaining({ resourceId: 'credential-1', diff --git a/apps/sim/lib/credentials/draft-hooks.ts b/apps/sim/lib/credentials/draft-hooks.ts index 79852f7824a..6cf0b4c9f1f 100644 --- a/apps/sim/lib/credentials/draft-hooks.ts +++ b/apps/sim/lib/credentials/draft-hooks.ts @@ -6,7 +6,7 @@ import { getPostgresConstraintName, getPostgresErrorCode } from '@sim/utils/erro import { generateId } from '@sim/utils/id' import { and, eq, sql } from 'drizzle-orm' import { deleteOrphanedOAuthAccount } from '@/lib/credentials/deletion' -import { clearDeadFlag } from '@/lib/oauth/terminal-errors' +import { clearOAuthRefreshDeadFlag } from '@/lib/oauth/refresh-coordination' import { captureServerEvent } from '@/lib/posthog/server' const logger = createLogger('CredentialDraftHooks') @@ -74,7 +74,7 @@ export async function handleCreateCredentialFromDraft(params: { .set({ updatedAt: now }) .where(eq(schema.credential.id, existingCredential.id)) - await clearDeadFlag(accountId) + await clearOAuthRefreshDeadFlag(accountId) recordAudit({ workspaceId: draft.workspaceId, @@ -108,7 +108,7 @@ export async function handleCreateCredentialFromDraft(params: { accountId, }) - await clearDeadFlag(accountId) + await clearOAuthRefreshDeadFlag(accountId) recordAudit({ workspaceId: draft.workspaceId, @@ -208,7 +208,7 @@ export async function handleReconnectCredential(params: { } ) - await clearDeadFlag(newAccountId) + await clearOAuthRefreshDeadFlag(newAccountId) recordAudit({ workspaceId, diff --git a/apps/sim/lib/imap/connection.server.test.ts b/apps/sim/lib/imap/connection.server.test.ts index f97ff748e25..bac121fdebf 100644 --- a/apps/sim/lib/imap/connection.server.test.ts +++ b/apps/sim/lib/imap/connection.server.test.ts @@ -23,23 +23,10 @@ import { createSecureImapClient, type ImapConnectionPolicyError, normalizeLiteralImapConnection, + normalizeResolvedImapConnection, resolveImapConnectionForActor, } from '@/lib/imap/connection.server' -function environmentSnapshot(overrides: Record = {}) { - return { - personalEncrypted: {}, - workspaceEncrypted: {}, - personalDecrypted: {}, - workspaceDecrypted: {}, - personalOwners: {}, - conflicts: [], - decryptionFailures: [], - workspaceUnredactedKeys: [], - ...overrides, - } -} - describe('IMAP connection policy', () => { beforeEach(() => { vi.clearAllMocks() @@ -104,19 +91,17 @@ describe('IMAP connection policy', () => { }) it('resolves exact personal and visible shared references for the deployment actor', async () => { - environmentUtilsMockFns.mockGetEffectiveEnvironmentSnapshot.mockResolvedValue( - environmentSnapshot({ - personalDecrypted: { PERSONAL_PASSWORD: 'personal-password' }, - workspaceDecrypted: { - SHARED_HOST: 'imap.shared.example', - SHARED_PORT: '143', - SHARED_SECURE: 'false', - SHARED_USERNAME: 'shared-user', - }, - personalOwners: { PERSONAL_PASSWORD: 'actor-1' }, - workspaceUnredactedKeys: ['SHARED_USERNAME'], - }) - ) + environmentUtilsMockFns.mockResolveEffectiveEnvironmentVariables.mockResolvedValue({ + PERSONAL_PASSWORD: { + value: 'personal-password', + scope: 'personal', + visible: true, + }, + SHARED_HOST: { value: 'imap.shared.example', scope: 'workspace', visible: false }, + SHARED_PORT: { value: '143', scope: 'workspace', visible: false }, + SHARED_SECURE: { value: 'false', scope: 'workspace', visible: false }, + SHARED_USERNAME: { value: 'shared-user', scope: 'workspace', visible: true }, + }) await expect( resolveImapConnectionForActor({ @@ -137,18 +122,18 @@ describe('IMAP connection policy', () => { username: 'shared-user', password: 'personal-password', }) - expect(environmentUtilsMockFns.mockGetEffectiveEnvironmentSnapshot).toHaveBeenCalledWith( + expect(environmentUtilsMockFns.mockResolveEffectiveEnvironmentVariables).toHaveBeenCalledWith( 'actor-1', - 'workspace-1' + 'workspace-1', + ['SHARED_HOST', 'SHARED_PORT', 'SHARED_SECURE', 'SHARED_USERNAME', 'PERSONAL_PASSWORD'] ) + expect(environmentUtilsMockFns.mockGetEffectiveEnvironmentSnapshot).not.toHaveBeenCalled() }) it('rejects hidden shared username and password references before DNS or ImapFlow', async () => { - environmentUtilsMockFns.mockGetEffectiveEnvironmentSnapshot.mockResolvedValue( - environmentSnapshot({ - workspaceDecrypted: { HIDDEN_AUTH: 'use-only-secret' }, - }) - ) + environmentUtilsMockFns.mockResolveEffectiveEnvironmentVariables.mockResolvedValue({ + HIDDEN_AUTH: { value: 'use-only-secret', scope: 'workspace', visible: false }, + }) for (const field of ['username', 'password'] as const) { const connection = { @@ -173,4 +158,61 @@ describe('IMAP connection policy', () => { expect(mockValidateDatabaseHost).not.toHaveBeenCalled() expect(mockImapFlow).not.toHaveBeenCalled() }) + + it('permits braces introduced by authorized resolution while keeping raw literals strict', () => { + expect( + normalizeResolvedImapConnection({ + host: 'imap.example.com', + username: 'mailbox-user', + password: 'literal{{brace}}secret', + }) + ).toMatchObject({ password: 'literal{{brace}}secret' }) + + expect(() => + normalizeLiteralImapConnection({ + host: 'imap.example.com', + username: 'mailbox-user', + password: 'literal{{brace}}secret', + }) + ).toThrowError( + expect.objectContaining>({ + name: 'ImapConnectionPolicyError', + code: 'context', + }) + ) + }) + + it('reauthorizes requested references on every resolution and fails closed after revocation', async () => { + environmentUtilsMockFns.mockResolveEffectiveEnvironmentVariables + .mockResolvedValueOnce({ + PASSWORD: { value: 'visible-password', scope: 'workspace', visible: true }, + }) + .mockResolvedValueOnce({ + PASSWORD: { value: 'hidden-password', scope: 'workspace', visible: false }, + }) + const input = { + connection: { + host: 'imap.example.com', + username: 'mailbox-user', + password: '{{PASSWORD}}', + }, + actorUserId: 'actor-1', + workspaceId: 'workspace-1', + } + + await expect(resolveImapConnectionForActor(input)).resolves.toMatchObject({ + password: 'visible-password', + }) + await expect(resolveImapConnectionForActor(input)).rejects.toMatchObject< + Partial + >({ + name: 'ImapConnectionPolicyError', + code: 'hidden_auth', + }) + + expect(environmentUtilsMockFns.mockResolveEffectiveEnvironmentVariables).toHaveBeenCalledTimes( + 2 + ) + expect(environmentUtilsMockFns.mockGetEffectiveEnvironmentSnapshot).not.toHaveBeenCalled() + }) }) diff --git a/apps/sim/lib/imap/connection.server.ts b/apps/sim/lib/imap/connection.server.ts index 98873ea5e72..4c9ee63d66d 100644 --- a/apps/sim/lib/imap/connection.server.ts +++ b/apps/sim/lib/imap/connection.server.ts @@ -1,6 +1,6 @@ import { ImapFlow } from 'imapflow' import { validateDatabaseHost } from '@/lib/core/security/input-validation.server' -import { getEffectiveEnvironmentSnapshot } from '@/lib/environment/utils' +import { resolveEffectiveEnvironmentVariables } from '@/lib/environment/utils' const EXACT_ENVIRONMENT_REFERENCE = /^\{\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*\}\}$/ @@ -65,37 +65,43 @@ export async function resolveImapConnectionForActor(input: { return normalizeLiteralImapConnection(input.connection) } - const snapshot = await getEffectiveEnvironmentSnapshot( + const referenceNames = [ + ...new Set( + [ + input.connection.host, + input.connection.port, + input.connection.secure, + input.connection.username, + input.connection.password, + ].flatMap((value) => { + if (typeof value !== 'string') return [] + const match = EXACT_ENVIRONMENT_REFERENCE.exec(value) + if (match) return [match[1]] + if (containsTemplateDelimiter(value)) throw new ImapConnectionPolicyError('context') + return [] + }) + ), + ] + const resolvedVariables = await resolveEffectiveEnvironmentVariables( input.actorUserId, - input.workspaceId ?? undefined + input.workspaceId ?? undefined, + referenceNames ) - const visibleWorkspaceNames = new Set(snapshot.workspaceUnredactedKeys) const resolve = (field: 'host' | 'port' | 'secure' | 'username' | 'password', value: unknown) => { if (typeof value !== 'string') return value const match = EXACT_ENVIRONMENT_REFERENCE.exec(value) - if (!match) { - if (containsTemplateDelimiter(value)) throw new ImapConnectionPolicyError('context') - return value - } + if (!match) return value const name = match[1] - const workspaceReference = Object.hasOwn(snapshot.workspaceDecrypted, name) - const resolved = workspaceReference - ? snapshot.workspaceDecrypted[name] - : snapshot.personalDecrypted[name] - if (resolved === undefined) throw new ImapConnectionPolicyError('context') - if ( - (field === 'username' || field === 'password') && - (workspaceReference - ? !visibleWorkspaceNames.has(name) - : snapshot.personalOwners[name] !== input.actorUserId) - ) { + const variable = Object.hasOwn(resolvedVariables, name) ? resolvedVariables[name] : undefined + if (!variable) throw new ImapConnectionPolicyError('context') + if ((field === 'username' || field === 'password') && !variable.visible) { throw new ImapConnectionPolicyError('hidden_auth') } - return resolved + return variable.value } - return normalizeConnection({ + return normalizeResolvedImapConnection({ host: resolve('host', input.connection.host), port: resolve('port', input.connection.port), secure: resolve('secure', input.connection.secure), @@ -104,6 +110,13 @@ export async function resolveImapConnectionForActor(input: { }) } +/** Normalizes values only after exact environment references have been resolved and authorized. */ +export function normalizeResolvedImapConnection( + input: ImapConnectionInput +): ResolvedImapConnection { + return normalizeConnection(input) +} + export function normalizeLiteralImapConnection(input: ImapConnectionInput): ResolvedImapConnection { for (const value of [input.host, input.port, input.secure, input.username, input.password]) { if (typeof value === 'string' && containsTemplateDelimiter(value)) { diff --git a/apps/sim/lib/oauth/__tests__/terminal-errors.test.ts b/apps/sim/lib/oauth/__tests__/terminal-errors.test.ts index 9755c64b2d7..cb4434e7dc5 100644 --- a/apps/sim/lib/oauth/__tests__/terminal-errors.test.ts +++ b/apps/sim/lib/oauth/__tests__/terminal-errors.test.ts @@ -3,6 +3,10 @@ */ import { redisConfigMockFns } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + clearOAuthRefreshDeadFlag, + getOAuthRefreshCoordinationIdentity, +} from '@/lib/oauth/refresh-coordination' import { clearDeadFlag, getRecentTerminalError, @@ -74,6 +78,27 @@ describe('markCredentialDead / getRecentTerminalError / clearDeadFlag', () => { expect(await getRecentTerminalError('acc-1')).toBeNull() }) + it.each(['account-1', 'slack:T08CM6ZNYBE'])( + 'reconnect clears the matching private refresh flag for %s', + async (scopeKey) => { + const redis = createFakeRedis() + redisConfigMockFns.mockGetRedisClient.mockReturnValue(redis as never) + const identity = getOAuthRefreshCoordinationIdentity(scopeKey) + + await markCredentialDead(identity, 'invalid_refresh_token') + await clearOAuthRefreshDeadFlag(scopeKey) + + expect(redis.set).toHaveBeenCalledWith( + `oauth:dead:${identity}`, + 'invalid_refresh_token', + 'EX', + 3600 + ) + expect(redis.del).toHaveBeenCalledWith(`oauth:dead:${identity}`) + expect(identity).not.toContain(scopeKey) + } + ) + it('all functions are no-ops when Redis is unavailable', async () => { await expect(markCredentialDead('acc-1', 'code')).resolves.toBeUndefined() await expect(getRecentTerminalError('acc-1')).resolves.toBeNull() diff --git a/apps/sim/lib/oauth/credential-service.test.ts b/apps/sim/lib/oauth/credential-service.test.ts index 4f05c6aba37..aff22174309 100644 --- a/apps/sim/lib/oauth/credential-service.test.ts +++ b/apps/sim/lib/oauth/credential-service.test.ts @@ -179,19 +179,23 @@ describe('resolveCredentialAccessToken selector privacy', () => { } }) - it('preserves the existing raw refresh identity and diagnostics outside selector mode', async () => { + it('shares private refresh coordination across privacy modes without changing ordinary diagnostics', async () => { + const privateGoogle = await observeRefresh('google', 'selector') const google = await observeRefresh('google') - expect(google.cacheKey).toBe(RAW_ACCOUNT_ID) - expect(google.coalescingKey).toBe(`oauth:refresh:${RAW_ACCOUNT_ID}`) + expect(google.cacheKey).toBe(privateGoogle.cacheKey) + expect(google.coalescingKey).toBe(privateGoogle.coalescingKey) expect(google.lockKey).toBe(google.coalescingKey) + expect(google.coalescingKey).not.toContain(RAW_ACCOUNT_ID) expect(google.logs).toContain(RAW_ACCOUNT_ID) expect(google.logs).toContain(RAW_USER_ID) expect(google.logs).toContain(RAW_PROVIDER_ERROR) + const privateSlack = await observeRefresh('slack', 'selector') const slack = await observeRefresh('slack') - expect(slack.cacheKey).toBe(`slack:${RAW_SLACK_TEAM_ID}`) - expect(slack.coalescingKey).toBe(`oauth:refresh:slack:${RAW_SLACK_TEAM_ID}`) + expect(slack.cacheKey).toBe(privateSlack.cacheKey) + expect(slack.coalescingKey).toBe(privateSlack.coalescingKey) expect(slack.lockKey).toBe(slack.coalescingKey) + expect(slack.coalescingKey).not.toContain(RAW_SLACK_TEAM_ID) expect(slack.logs).toContain(RAW_SLACK_TEAM_ID) expect(slack.logs).toContain(RAW_PROVIDER_ERROR) }) diff --git a/apps/sim/lib/oauth/credential-service.ts b/apps/sim/lib/oauth/credential-service.ts index d2f7ff586a9..847ee7f5111 100644 --- a/apps/sim/lib/oauth/credential-service.ts +++ b/apps/sim/lib/oauth/credential-service.ts @@ -28,6 +28,7 @@ import { PROACTIVE_REFRESH_THRESHOLD_DAYS, } from '@/lib/oauth/microsoft' import { refreshOAuthToken } from '@/lib/oauth/oauth' +import { getOAuthRefreshCoordinationIdentity } from '@/lib/oauth/refresh-coordination' import { extractSlackTeamId, fanOutSlackTokenChain, @@ -53,7 +54,8 @@ export interface CredentialTokenResolutionOptions { /** * Selector execution may receive a credential/account id through a hidden * workspace environment reference. In that mode identifiers are omitted - * from diagnostics and HMACed before they are used as cache or lock keys. + * from diagnostics. Refresh coordination identities are private in every + * mode so selector and ordinary calls share the same locks and dead flags. */ privacyMode?: 'selector' } @@ -787,10 +789,7 @@ async function performCoalescedRefresh({ */ const slackTeamId = isSlackProvider(providerId) ? extractSlackTeamId(providerAccountId) : null const rawScopeKey = slackTeamId ? `slack:${slackTeamId}` : accountId - const scopeKey = - privacyMode === 'selector' - ? privateCredentialIdentity('selector-oauth-refresh', rawScopeKey) - : rawScopeKey + const scopeKey = getOAuthRefreshCoordinationIdentity(rawScopeKey) const logContext = { ...(requestId ? { requestId } : {}), diff --git a/apps/sim/lib/oauth/refresh-coordination.ts b/apps/sim/lib/oauth/refresh-coordination.ts new file mode 100644 index 00000000000..0f1f7466122 --- /dev/null +++ b/apps/sim/lib/oauth/refresh-coordination.ts @@ -0,0 +1,21 @@ +import { createHmac } from 'crypto' +import { env } from '@/lib/core/config/env' +import { clearDeadFlag } from '@/lib/oauth/terminal-errors' + +/** + * Returns the private identity shared by OAuth refresh locks and terminal-error + * flags. Callers choose the semantic scope: an account row for ordinary OAuth, + * or `slack:${teamId}` for a Slack installation. + */ +export function getOAuthRefreshCoordinationIdentity(scopeKey: string): string { + return createHmac('sha256', env.ENCRYPTION_KEY) + .update('oauth-refresh') + .update('\0') + .update(scopeKey) + .digest('base64url') +} + +/** Clears the terminal-error flag written by the refresh path for one raw scope. */ +export function clearOAuthRefreshDeadFlag(scopeKey: string): Promise { + return clearDeadFlag(getOAuthRefreshCoordinationIdentity(scopeKey)) +} diff --git a/apps/sim/lib/selectors/server/internal.test.ts b/apps/sim/lib/selectors/server/internal.test.ts index 1488d6e54f1..0d02fd00eb0 100644 --- a/apps/sim/lib/selectors/server/internal.test.ts +++ b/apps/sim/lib/selectors/server/internal.test.ts @@ -2,12 +2,36 @@ * @vitest-environment node */ import { environmentUtilsMockFns, resetEnvironmentUtilsMock } from '@sim/testing' -import { beforeEach, describe, expect, it } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mockListWorkflows = vi.hoisted(() => vi.fn()) + +vi.mock('@/lib/workflows/application/list-workflows', () => ({ + listWorkflows: { execute: mockListWorkflows }, +})) + +import { SelectorOptionsUnavailableError } from '@/lib/selectors/server/errors' import { internalSelectorAttachments } from '@/lib/selectors/server/internal' import { createSelectorProtectedValues } from '@/lib/selectors/server/protected-values' +import type { ExecuteServerSelectorArgs } from '@/lib/selectors/server/types' + +function workflowArgs(): ExecuteServerSelectorArgs { + return { + selectorKey: 'sim.workflows', + context: {}, + request: { kind: 'list' }, + scope: { kind: 'workspace', workspaceId: 'workspace-1' }, + workspaceId: 'workspace-1', + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + requesterUserId: 'user-1', + references: new Map(), + protectedValues: createSelectorProtectedValues(), + } +} describe('workspace.secretNames selector', () => { beforeEach(() => { + vi.clearAllMocks() resetEnvironmentUtilsMock() }) @@ -44,3 +68,47 @@ describe('workspace.secretNames selector', () => { expect(environmentUtilsMockFns.mockGetEffectiveEnvironmentSnapshot).not.toHaveBeenCalled() }) }) + +describe('sim.workflows selector', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('continues beyond the former 5,000-workflow limit', async () => { + for (let page = 0; page < 20; page += 1) { + mockListWorkflows.mockResolvedValueOnce({ + workflows: [], + nextCursorKeys: [`page-${page + 1}`], + }) + } + mockListWorkflows.mockResolvedValueOnce({ + workflows: [{ id: 'workflow-late', name: 'Late workflow', folderPath: '/' }], + nextCursorKeys: null, + }) + + await expect( + internalSelectorAttachments['sim.workflows'].execute(workflowArgs()) + ).resolves.toEqual({ + kind: 'list', + items: [{ id: 'workflow-late', label: 'Late workflow' }], + }) + expect(mockListWorkflows).toHaveBeenCalledTimes(21) + }) + + it('fails rather than returning a partial list after 10,000 workflows', async () => { + const workflowPage = Array.from({ length: 250 }, (_, index) => ({ + id: `workflow-${index}`, + name: `Workflow ${index}`, + folderPath: '/', + })) + mockListWorkflows.mockResolvedValue({ + workflows: workflowPage, + nextCursorKeys: ['more'], + }) + + await expect( + internalSelectorAttachments['sim.workflows'].execute(workflowArgs()) + ).rejects.toBeInstanceOf(SelectorOptionsUnavailableError) + expect(mockListWorkflows).toHaveBeenCalledTimes(40) + }) +}) diff --git a/apps/sim/lib/selectors/server/internal.ts b/apps/sim/lib/selectors/server/internal.ts index 8b22b7aa0ee..36563f80ccc 100644 --- a/apps/sim/lib/selectors/server/internal.ts +++ b/apps/sim/lib/selectors/server/internal.ts @@ -10,6 +10,7 @@ import { } from '@/lib/knowledge/application/documents' import { getServiceConfigByProviderId } from '@/lib/oauth/utils' import type { InternalSelectorKey } from '@/lib/selectors/manifest' +import { SelectorOptionsUnavailableError } from '@/lib/selectors/server/errors' import { detailSelectorResult, type ExecuteServerSelectorArgs, @@ -21,8 +22,9 @@ import { getColumnId } from '@/lib/table/column-keys' import { listWorkflows } from '@/lib/workflows/application/list-workflows' import { filterBlacklistedModels, isProviderBlacklisted } from '@/providers/utils' -const MAX_WORKFLOW_PAGES = 20 const WORKFLOW_PAGE_SIZE = 250 +const MAX_WORKFLOWS = 10_000 +const MAX_WORKFLOW_PAGES = MAX_WORKFLOWS / WORKFLOW_PAGE_SIZE const KNOWLEDGE_PAGE_SIZE = 100 function labelWorkflow( @@ -61,6 +63,7 @@ async function loadWorkflows( cursorKeys = result.nextCursorKeys if (!cursorKeys) break } + if (cursorKeys) throw new SelectorOptionsUnavailableError() return workflows } diff --git a/apps/sim/lib/selectors/server/providers/atlassian.test.ts b/apps/sim/lib/selectors/server/providers/atlassian.test.ts new file mode 100644 index 00000000000..b97e4b19055 --- /dev/null +++ b/apps/sim/lib/selectors/server/providers/atlassian.test.ts @@ -0,0 +1,41 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mockFetchProviderJson = vi.hoisted(() => vi.fn()) + +vi.mock('@/lib/selectors/server/providers/provider-http', () => ({ + fetchProviderJson: mockFetchProviderJson, +})) + +import { SelectorConnectionUnavailableError } from '@/lib/selectors/server/errors' +import { resolveSelectorAtlassianCloudId } from '@/lib/selectors/server/providers/atlassian' + +describe('Atlassian server selector authentication', () => { + beforeEach(() => vi.clearAllMocks()) + + it('accepts only a service-account cloud id bound to the selected domain', async () => { + await expect( + resolveSelectorAtlassianCloudId({ + accessToken: 'server-only-token', + domain: 'https://ACME.atlassian.net/', + providedCloudId: 'cloud-1', + providedDomain: 'acme.atlassian.net', + product: 'Jira', + }) + ).resolves.toBe('cloud-1') + expect(mockFetchProviderJson).not.toHaveBeenCalled() + + await expect( + resolveSelectorAtlassianCloudId({ + accessToken: 'server-only-token', + domain: 'other.atlassian.net', + providedCloudId: 'cloud-1', + providedDomain: 'acme.atlassian.net', + product: 'Jira', + }) + ).rejects.toBeInstanceOf(SelectorConnectionUnavailableError) + expect(mockFetchProviderJson).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/selectors/server/providers/atlassian.ts b/apps/sim/lib/selectors/server/providers/atlassian.ts index b2d2089089e..a18cb4eb0d2 100644 --- a/apps/sim/lib/selectors/server/providers/atlassian.ts +++ b/apps/sim/lib/selectors/server/providers/atlassian.ts @@ -1,5 +1,6 @@ -import { selectAtlassianCloudId } from '@/lib/atlassian/discovery' +import { normalizeAtlassianSiteUrl, selectAtlassianCloudId } from '@/lib/atlassian/discovery' import { + SelectorConnectionUnavailableError, SelectorContextUnavailableError, SelectorOptionsUnavailableError, } from '@/lib/selectors/server/errors' @@ -30,10 +31,22 @@ export async function resolveSelectorAtlassianCloudId(input: { accessToken: string domain: string | undefined providedCloudId?: string + providedDomain?: string product: 'Jira' | 'Confluence' signal?: AbortSignal }): Promise { - if (input.providedCloudId) return requireCloudId(input.providedCloudId) + if (input.providedCloudId) { + const contextDomain = input.domain?.trim() + const credentialDomain = input.providedDomain?.trim() + if ( + !contextDomain || + !credentialDomain || + normalizeAtlassianSiteUrl(contextDomain) !== normalizeAtlassianSiteUrl(credentialDomain) + ) { + throw new SelectorConnectionUnavailableError() + } + return requireCloudId(input.providedCloudId) + } const domain = input.domain?.trim() if (!domain) throw new SelectorContextUnavailableError() diff --git a/apps/sim/lib/selectors/server/providers/confluence.ts b/apps/sim/lib/selectors/server/providers/confluence.ts index 854110384fa..96cddf41d03 100644 --- a/apps/sim/lib/selectors/server/providers/confluence.ts +++ b/apps/sim/lib/selectors/server/providers/confluence.ts @@ -75,6 +75,7 @@ async function resolveConfluenceAuth(args: ExecuteServerSelectorArgs) { accessToken: bundle.accessToken, domain, providedCloudId: bundle.cloudId, + providedDomain: bundle.domain, product: 'Confluence', signal: args.signal, }) diff --git a/apps/sim/lib/selectors/server/providers/credential-bundle.test.ts b/apps/sim/lib/selectors/server/providers/credential-bundle.test.ts new file mode 100644 index 00000000000..b74a125572d --- /dev/null +++ b/apps/sim/lib/selectors/server/providers/credential-bundle.test.ts @@ -0,0 +1,38 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mockResolveCredentialAccessToken = vi.hoisted(() => vi.fn()) + +vi.mock('@/lib/oauth/credential-service', () => ({ + resolveCredentialAccessToken: mockResolveCredentialAccessToken, +})) + +import { createSelectorProtectedValues } from '@/lib/selectors/server/protected-values' +import { resolveSelectorCredentialBundle } from '@/lib/selectors/server/providers/credential-bundle' + +describe('selector credential bundles', () => { + beforeEach(() => vi.clearAllMocks()) + + it('protects credential-bound cloud ids with the rest of the resolved bundle', async () => { + mockResolveCredentialAccessToken.mockResolvedValue({ + accessToken: 'server-only-token', + cloudId: 'cloud-1', + domain: 'acme.atlassian.net', + }) + const protectedValues = createSelectorProtectedValues() + + await expect( + resolveSelectorCredentialBundle({ + credential: { + suppliedId: 'credential-1', + access: { ok: true, credentialOwnerUserId: 'owner-1' }, + }, + protectedValues, + }) + ).resolves.toMatchObject({ cloudId: 'cloud-1' }) + + expect(protectedValues.contains('prefix-cloud-1-suffix')).toBe(true) + }) +}) diff --git a/apps/sim/lib/selectors/server/providers/credential-bundle.ts b/apps/sim/lib/selectors/server/providers/credential-bundle.ts index dbd23c81a72..78de12cba56 100644 --- a/apps/sim/lib/selectors/server/providers/credential-bundle.ts +++ b/apps/sim/lib/selectors/server/providers/credential-bundle.ts @@ -45,6 +45,7 @@ export async function resolveSelectorCredentialBundle(input: { if (!bundle?.accessToken) throw new SelectorConnectionUnavailableError() input.protectedValues.add(bundle.accessToken) + input.protectedValues.add(bundle.cloudId) input.protectedValues.add(bundle.domain) input.protectedValues.add(bundle.instanceUrl) input.protectedValues.add(bundle.apiDomain) diff --git a/apps/sim/lib/selectors/server/providers/google.test.ts b/apps/sim/lib/selectors/server/providers/google.test.ts new file mode 100644 index 00000000000..a39db67e42b --- /dev/null +++ b/apps/sim/lib/selectors/server/providers/google.test.ts @@ -0,0 +1,74 @@ +/** + * @vitest-environment node + */ +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockFetch, mockResolveSelectorOAuthAccessToken } = vi.hoisted(() => ({ + mockFetch: vi.fn(), + mockResolveSelectorOAuthAccessToken: vi.fn(), +})) + +vi.mock('@/lib/selectors/server/credentials', () => ({ + resolveSelectorOAuthAccessToken: mockResolveSelectorOAuthAccessToken, +})) + +import { createSelectorProtectedValues } from '@/lib/selectors/server/protected-values' +import { googleSelectorAttachments } from '@/lib/selectors/server/providers/google' +import type { ExecuteServerSelectorArgs } from '@/lib/selectors/server/types' + +function driveDetailArgs(signal?: AbortSignal): ExecuteServerSelectorArgs { + return { + selectorKey: 'google.drive', + context: { oauthCredential: 'credential-1' }, + request: { kind: 'detail', id: 'drive-item-1' }, + scope: { kind: 'workspace', workspaceId: 'workspace-1' }, + workspaceId: 'workspace-1', + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + requesterUserId: 'user-1', + credential: { suppliedId: 'credential-1' }, + references: new Map(), + protectedValues: createSelectorProtectedValues(), + signal, + } +} + +describe('Google server selector adapters', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.stubGlobal('fetch', mockFetch) + mockResolveSelectorOAuthAccessToken.mockResolvedValue('server-only-token') + }) + + afterAll(() => vi.unstubAllGlobals()) + + it('uses the bounded 404 path before hydrating a shared drive', async () => { + mockFetch + .mockResolvedValueOnce(new Response('not forwarded', { status: 404 })) + .mockResolvedValueOnce( + new Response(JSON.stringify({ id: 'drive-item-1', name: 'Shared drive' }), { + status: 200, + }) + ) + + await expect( + googleSelectorAttachments['google.drive'].execute(driveDetailArgs()) + ).resolves.toEqual({ + kind: 'detail', + item: { id: 'drive-item-1', label: 'Shared drive' }, + }) + + expect(String(mockFetch.mock.calls[0]?.[0])).toContain('/drive/v3/files/drive-item-1') + expect(String(mockFetch.mock.calls[1]?.[0])).toContain('/drive/v3/drives/drive-item-1') + }) + + it('preserves caller cancellation during detail hydration', async () => { + const controller = new AbortController() + const abortError = new DOMException('The operation was aborted', 'AbortError') + controller.abort() + mockFetch.mockRejectedValueOnce(abortError) + + await expect( + googleSelectorAttachments['google.drive'].execute(driveDetailArgs(controller.signal)) + ).rejects.toBe(abortError) + }) +}) diff --git a/apps/sim/lib/selectors/server/providers/google.ts b/apps/sim/lib/selectors/server/providers/google.ts index 37a94c9866d..e8b0b5aa3b5 100644 --- a/apps/sim/lib/selectors/server/providers/google.ts +++ b/apps/sim/lib/selectors/server/providers/google.ts @@ -6,7 +6,10 @@ import { SelectorContextUnavailableError, SelectorOptionsUnavailableError, } from '@/lib/selectors/server/errors' -import { fetchProviderJson } from '@/lib/selectors/server/providers/provider-http' +import { + fetchProviderJson, + fetchProviderJsonWithStatus, +} from '@/lib/selectors/server/providers/provider-http' import { detailSelectorResult, type ExecuteServerSelectorArgs, @@ -248,32 +251,20 @@ async function fetchDriveDetail( ): Promise { const id = requireGoogleId(fileId) const headers = { Authorization: `Bearer ${accessToken}` } - let response: Response - try { - response = await fetch( - `https://www.googleapis.com/drive/v3/files/${id}?fields=id,name,mimeType,shortcutDetails&supportsAllDrives=true`, - { headers, redirect: 'error', signal: args.signal } - ) - } catch (error) { - if (args.signal?.aborted) throw error - throw new SelectorOptionsUnavailableError() - } + const result = await fetchProviderJsonWithStatus( + `https://www.googleapis.com/drive/v3/files/${id}?fields=id,name,mimeType,shortcutDetails&supportsAllDrives=true`, + { headers, redirect: 'error', signal: args.signal }, + { passthroughStatuses: [404] } + ) - if (response.status === 404) { + if (!result.ok) { const drive = await fetchProviderJson<{ id: string; name: string }>( `https://www.googleapis.com/drive/v3/drives/${id}?fields=id,name`, { headers, signal: args.signal } ) return { id: drive.id, name: drive.name, mimeType: 'application/vnd.google-apps.folder' } } - if (!response.ok) throw new SelectorOptionsUnavailableError() - - let file: DriveFile - try { - file = (await response.json()) as DriveFile - } catch { - throw new SelectorOptionsUnavailableError() - } + const file = result.data const targetId = file.mimeType === 'application/vnd.google-apps.shortcut' ? file.shortcutDetails?.targetId diff --git a/apps/sim/lib/selectors/server/providers/imap.test.ts b/apps/sim/lib/selectors/server/providers/imap.test.ts new file mode 100644 index 00000000000..657348baf2d --- /dev/null +++ b/apps/sim/lib/selectors/server/providers/imap.test.ts @@ -0,0 +1,97 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockListImapMailboxes, mockNormalizeResolvedImapConnection } = vi.hoisted(() => ({ + mockListImapMailboxes: vi.fn(), + mockNormalizeResolvedImapConnection: vi.fn(), +})) + +vi.mock('@/lib/imap/connection.server', () => ({ + listImapMailboxes: mockListImapMailboxes, + normalizeResolvedImapConnection: mockNormalizeResolvedImapConnection, +})) + +import { SelectorConnectionUnavailableError } from '@/lib/selectors/server/errors' +import { createSelectorProtectedValues } from '@/lib/selectors/server/protected-values' +import { imapSelectorAttachments } from '@/lib/selectors/server/providers/imap' +import type { ExecuteServerSelectorArgs } from '@/lib/selectors/server/types' + +function mailboxArgs( + overrides: Partial = {} +): ExecuteServerSelectorArgs { + return { + selectorKey: 'imap.mailboxes', + context: { + host: 'imap.example.com', + port: '993', + secure: 'true', + username: 'mailbox-user', + password: 'secret{{literal}}value', + }, + request: { kind: 'list' }, + scope: { kind: 'workspace', workspaceId: 'workspace-1' }, + workspaceId: 'workspace-1', + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + requesterUserId: 'user-1', + references: new Map(), + protectedValues: createSelectorProtectedValues(), + ...overrides, + } +} + +describe('IMAP server selector adapter', () => { + beforeEach(() => { + vi.clearAllMocks() + mockNormalizeResolvedImapConnection.mockReturnValue({ + host: 'imap.example.com', + port: 993, + secure: true, + username: 'mailbox-user', + password: 'secret{{literal}}value', + }) + mockListImapMailboxes.mockResolvedValue([{ path: 'INBOX', name: 'Inbox', delimiter: '/' }]) + }) + + it('normalizes authorized resolved values without treating their braces as templates', async () => { + await expect(imapSelectorAttachments['imap.mailboxes'].execute(mailboxArgs())).resolves.toEqual( + { + kind: 'list', + items: [{ id: 'INBOX', label: 'Inbox' }], + } + ) + + expect(mockNormalizeResolvedImapConnection).toHaveBeenCalledWith({ + host: 'imap.example.com', + port: '993', + secure: 'true', + username: 'mailbox-user', + password: 'secret{{literal}}value', + }) + expect(mockListImapMailboxes).toHaveBeenCalledOnce() + }) + + it('rejects hidden shared authentication before normalization or network access', async () => { + await expect( + imapSelectorAttachments['imap.mailboxes'].execute( + mailboxArgs({ + references: new Map([ + [ + 'password', + { + field: 'password', + name: 'IMAP_PASSWORD', + scope: 'workspace', + visible: false, + }, + ], + ]), + }) + ) + ).rejects.toBeInstanceOf(SelectorConnectionUnavailableError) + + expect(mockNormalizeResolvedImapConnection).not.toHaveBeenCalled() + expect(mockListImapMailboxes).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/selectors/server/providers/imap.ts b/apps/sim/lib/selectors/server/providers/imap.ts index 210f5064a4f..f474edaf6c0 100644 --- a/apps/sim/lib/selectors/server/providers/imap.ts +++ b/apps/sim/lib/selectors/server/providers/imap.ts @@ -1,4 +1,4 @@ -import { listImapMailboxes, normalizeLiteralImapConnection } from '@/lib/imap/connection.server' +import { listImapMailboxes, normalizeResolvedImapConnection } from '@/lib/imap/connection.server' import { SelectorConnectionUnavailableError } from '@/lib/selectors/server/errors' import { listSelectorResult, type ServerSelectorAttachmentMap } from '@/lib/selectors/server/types' @@ -12,7 +12,7 @@ export const imapSelectorAttachments = { }) if (hiddenSharedAuth) throw new SelectorConnectionUnavailableError() - const connection = normalizeLiteralImapConnection({ + const connection = normalizeResolvedImapConnection({ host: args.context.host, port: args.context.port, secure: args.context.secure, diff --git a/apps/sim/lib/selectors/server/providers/jira.ts b/apps/sim/lib/selectors/server/providers/jira.ts index 15700fab424..747839b0d4b 100644 --- a/apps/sim/lib/selectors/server/providers/jira.ts +++ b/apps/sim/lib/selectors/server/providers/jira.ts @@ -76,6 +76,7 @@ async function resolveJiraAuth(args: ExecuteServerSelectorArgs) { accessToken: bundle.accessToken, domain: args.context.domain, providedCloudId: bundle.cloudId, + providedDomain: bundle.domain, product: 'Jira', signal: args.signal, }) diff --git a/apps/sim/lib/selectors/server/providers/jsm.test.ts b/apps/sim/lib/selectors/server/providers/jsm.test.ts index 9e86cc60df6..b8503ca8bef 100644 --- a/apps/sim/lib/selectors/server/providers/jsm.test.ts +++ b/apps/sim/lib/selectors/server/providers/jsm.test.ts @@ -47,7 +47,11 @@ describe('JSM server selector adapters', () => { beforeEach(() => { vi.clearAllMocks() vi.stubGlobal('fetch', mockFetch) - mockResolveJsmAuth.mockResolvedValue({ accessToken: 'server-only-token' }) + mockResolveJsmAuth.mockResolvedValue({ + accessToken: 'server-only-token', + cloudId: 'cloud-1', + domain: 'example.atlassian.net', + }) mockResolveCloudId.mockResolvedValue('cloud-1') }) @@ -82,5 +86,12 @@ describe('JSM server selector adapters', () => { const secondUrl = new URL(String(mockFetch.mock.calls[1]?.[0])) expect(firstUrl.search).toBe('?start=0&limit=100') expect(secondUrl.search).toBe('?start=1&limit=100') + expect(mockResolveCloudId).toHaveBeenCalledWith( + expect.objectContaining({ + domain: 'example.atlassian.net', + providedCloudId: 'cloud-1', + providedDomain: 'example.atlassian.net', + }) + ) }) }) diff --git a/apps/sim/lib/selectors/server/providers/jsm.ts b/apps/sim/lib/selectors/server/providers/jsm.ts index a355355977c..1e404d792c8 100644 --- a/apps/sim/lib/selectors/server/providers/jsm.ts +++ b/apps/sim/lib/selectors/server/providers/jsm.ts @@ -64,6 +64,7 @@ async function resolveJsmAuth(args: ExecuteServerSelectorArgs) { accessToken: bundle.accessToken, domain: args.context.domain, providedCloudId: bundle.cloudId, + providedDomain: bundle.domain, product: 'Jira', signal: args.signal, }) diff --git a/apps/sim/lib/selectors/server/providers/provider-http.test.ts b/apps/sim/lib/selectors/server/providers/provider-http.test.ts new file mode 100644 index 00000000000..aad917d7b43 --- /dev/null +++ b/apps/sim/lib/selectors/server/providers/provider-http.test.ts @@ -0,0 +1,66 @@ +/** + * @vitest-environment node + */ +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { SelectorOptionsUnavailableError } from '@/lib/selectors/server/errors' +import { + fetchProviderJson, + fetchProviderJsonWithStatus, +} from '@/lib/selectors/server/providers/provider-http' + +const mockFetch = vi.fn() + +function openBody(onCancel: () => void): ReadableStream { + return new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('{}')) + }, + cancel: onCancel, + }) +} + +describe('provider HTTP selector boundary', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.stubGlobal('fetch', mockFetch) + }) + + afterAll(() => vi.unstubAllGlobals()) + + it('cancels rejected and declared-oversized provider bodies', async () => { + const rejectedCancel = vi.fn() + mockFetch.mockResolvedValueOnce( + new Response(openBody(rejectedCancel), { status: 502, statusText: 'Bad Gateway' }) + ) + + await expect(fetchProviderJson('https://provider.example/items')).rejects.toBeInstanceOf( + SelectorOptionsUnavailableError + ) + expect(rejectedCancel).toHaveBeenCalledOnce() + + const oversizedCancel = vi.fn() + mockFetch.mockResolvedValueOnce( + new Response(openBody(oversizedCancel), { + status: 200, + headers: { 'content-length': String(16 * 1024 * 1024 + 1) }, + }) + ) + + await expect(fetchProviderJson('https://provider.example/items')).rejects.toBeInstanceOf( + SelectorOptionsUnavailableError + ) + expect(oversizedCancel).toHaveBeenCalledOnce() + }) + + it('returns only an allowlisted error status after discarding its body', async () => { + const cancel = vi.fn() + mockFetch.mockResolvedValueOnce(new Response(openBody(cancel), { status: 404 })) + + await expect( + fetchProviderJsonWithStatus('https://provider.example/item', undefined, { + passthroughStatuses: [404], + }) + ).resolves.toEqual({ ok: false, status: 404 }) + expect(cancel).toHaveBeenCalledOnce() + }) +}) diff --git a/apps/sim/lib/selectors/server/providers/provider-http.ts b/apps/sim/lib/selectors/server/providers/provider-http.ts index a9f494ce9e7..b178be73336 100644 --- a/apps/sim/lib/selectors/server/providers/provider-http.ts +++ b/apps/sim/lib/selectors/server/providers/provider-http.ts @@ -3,9 +3,16 @@ import { SelectorOptionsUnavailableError } from '@/lib/selectors/server/errors' const PROVIDER_TIMEOUT_MS = 30_000 const MAX_PROVIDER_RESPONSE_BYTES = 16 * 1024 * 1024 +async function cancelResponseBody(response: Response): Promise { + try { + await response.body?.cancel() + } catch {} +} + async function readBoundedBody(response: Response): Promise { const declaredLength = Number(response.headers.get('content-length')) if (Number.isFinite(declaredLength) && declaredLength > MAX_PROVIDER_RESPONSE_BYTES) { + await cancelResponseBody(response) throw new SelectorOptionsUnavailableError() } if (!response.body) return '' @@ -18,7 +25,7 @@ async function readBoundedBody(response: Response): Promise { if (done) break total += value.byteLength if (total > MAX_PROVIDER_RESPONSE_BYTES) { - await reader.cancel() + await reader.cancel().catch(() => undefined) throw new SelectorOptionsUnavailableError() } chunks.push(value) @@ -33,10 +40,24 @@ async function readBoundedBody(response: Response): Promise { return new TextDecoder().decode(body) } -export async function fetchProviderJson( +export type ProviderJsonStatusResult = + | { ok: true; status: number; data: T } + | { ok: false; status: number } + +export interface FetchProviderJsonStatusOptions { + passthroughStatuses?: readonly number[] +} + +/** + * Fetches bounded provider JSON while allowing callers to branch on explicitly + * allowlisted non-success statuses. Passthrough response bodies are discarded; + * provider payloads never escape this boundary on an error status. + */ +export async function fetchProviderJsonWithStatus( input: RequestInfo | URL, - init?: RequestInit -): Promise { + init?: RequestInit, + options: FetchProviderJsonStatusOptions = {} +): Promise> { let response: Response const timeoutSignal = AbortSignal.timeout(PROVIDER_TIMEOUT_MS) const signal = init?.signal ? AbortSignal.any([init.signal, timeoutSignal]) : timeoutSignal @@ -47,12 +68,31 @@ export async function fetchProviderJson( throw new SelectorOptionsUnavailableError() } - if (!response.ok) throw new SelectorOptionsUnavailableError() + if (!response.ok) { + await cancelResponseBody(response) + if (options.passthroughStatuses?.includes(response.status)) { + return { ok: false, status: response.status } + } + throw new SelectorOptionsUnavailableError() + } try { - return JSON.parse(await readBoundedBody(response)) as T + return { + ok: true, + status: response.status, + data: JSON.parse(await readBoundedBody(response)) as T, + } } catch (error) { if (init?.signal?.aborted) throw error if (error instanceof SelectorOptionsUnavailableError) throw error throw new SelectorOptionsUnavailableError() } } + +export async function fetchProviderJson( + input: RequestInfo | URL, + init?: RequestInit +): Promise { + const result = await fetchProviderJsonWithStatus(input, init) + if (!result.ok) throw new SelectorOptionsUnavailableError() + return result.data +} diff --git a/apps/sim/lib/selectors/server/providers/zoho-desk.test.ts b/apps/sim/lib/selectors/server/providers/zoho-desk.test.ts new file mode 100644 index 00000000000..b44d24ccca2 --- /dev/null +++ b/apps/sim/lib/selectors/server/providers/zoho-desk.test.ts @@ -0,0 +1,60 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockResolveSelectorCredentialBundle, mockSecureFetchWithValidation } = vi.hoisted(() => ({ + mockResolveSelectorCredentialBundle: vi.fn(), + mockSecureFetchWithValidation: vi.fn(), +})) + +vi.mock('@/lib/core/security/input-validation.server', () => ({ + secureFetchWithValidation: mockSecureFetchWithValidation, +})) + +vi.mock('@/lib/selectors/server/providers/credential-bundle', () => ({ + resolveSelectorCredentialBundle: mockResolveSelectorCredentialBundle, +})) + +import { createSelectorProtectedValues } from '@/lib/selectors/server/protected-values' +import { zohoDeskSelectorAttachments } from '@/lib/selectors/server/providers/zoho-desk' +import type { ExecuteServerSelectorArgs } from '@/lib/selectors/server/types' + +function organizationArgs(signal: AbortSignal): ExecuteServerSelectorArgs { + return { + selectorKey: 'zoho_desk.organizations', + context: { oauthCredential: 'credential-1' }, + request: { kind: 'list' }, + scope: { kind: 'workspace', workspaceId: 'workspace-1' }, + workspaceId: 'workspace-1', + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + requesterUserId: 'user-1', + credential: { suppliedId: 'credential-1' }, + references: new Map(), + protectedValues: createSelectorProtectedValues(), + signal, + } +} + +describe('Zoho Desk server selector adapters', () => { + beforeEach(() => { + vi.clearAllMocks() + mockResolveSelectorCredentialBundle.mockResolvedValue({ + accessToken: 'server-only-token', + apiDomain: 'https://desk.zoho.com', + }) + }) + + it('preserves caller cancellation from the provider boundary', async () => { + const controller = new AbortController() + const abortError = new DOMException('The operation was aborted', 'AbortError') + controller.abort() + mockSecureFetchWithValidation.mockRejectedValueOnce(abortError) + + await expect( + zohoDeskSelectorAttachments['zoho_desk.organizations'].execute( + organizationArgs(controller.signal) + ) + ).rejects.toBe(abortError) + }) +}) diff --git a/apps/sim/lib/selectors/server/providers/zoho-desk.ts b/apps/sim/lib/selectors/server/providers/zoho-desk.ts index 96268bdb425..f2e92476748 100644 --- a/apps/sim/lib/selectors/server/providers/zoho-desk.ts +++ b/apps/sim/lib/selectors/server/providers/zoho-desk.ts @@ -72,7 +72,8 @@ async function fetchZoho( stripAuthOnRedirect: true, signal: args.signal, }) - } catch { + } catch (error) { + if (args.signal?.aborted) throw error throw new SelectorOptionsUnavailableError() } if (response.status === 204) return { status: 204, data: [] } diff --git a/apps/sim/lib/selectors/server/sanitize.test.ts b/apps/sim/lib/selectors/server/sanitize.test.ts index c7cdd89ec15..2211bb5d11d 100644 --- a/apps/sim/lib/selectors/server/sanitize.test.ts +++ b/apps/sim/lib/selectors/server/sanitize.test.ts @@ -140,4 +140,22 @@ describe('sanitizeSelectorResult', () => { ) ).toThrow(SelectorOptionsUnavailableError) }) + + it('rejects metadata strings larger than the response contract permits', () => { + expect(() => + sanitizeSelectorResult( + { + kind: 'list', + items: [ + { + id: 'resource-1', + label: 'Resource one', + meta: { description: 'x'.repeat(16 * 1024 + 1) }, + }, + ], + }, + createSelectorProtectedValues() + ) + ).toThrow(SelectorOptionsUnavailableError) + }) }) diff --git a/apps/sim/lib/selectors/server/sanitize.ts b/apps/sim/lib/selectors/server/sanitize.ts index 0d014166b5f..aa751d8fa4d 100644 --- a/apps/sim/lib/selectors/server/sanitize.ts +++ b/apps/sim/lib/selectors/server/sanitize.ts @@ -55,12 +55,11 @@ function sanitizeMeta( if (typeof entry === 'number' && !Number.isFinite(entry)) { throw new SelectorOptionsUnavailableError() } - if ( - typeof entry === 'string' && - protectedValues.contains(entry) && - entry !== allowedExactValue - ) { - throw new SelectorOptionsUnavailableError() + if (typeof entry === 'string') { + if (entry.length > MAX_OPTION_TEXT) throw new SelectorOptionsUnavailableError() + if (protectedValues.contains(entry) && entry !== allowedExactValue) { + throw new SelectorOptionsUnavailableError() + } } meta[key] = entry as SafeOptionMetaValue } diff --git a/apps/sim/lib/webhooks/deploy.ts b/apps/sim/lib/webhooks/deploy.ts index 953ccd8bb15..0de2b04dd1c 100644 --- a/apps/sim/lib/webhooks/deploy.ts +++ b/apps/sim/lib/webhooks/deploy.ts @@ -661,7 +661,7 @@ async function configurePollingIfNeeded( provider: string, savedWebhook: Record, requestId: string, - actor: { userId: string; workspaceId?: string | null; deploymentVersionId?: string | null } + actor: { userId: string; workspaceId: string | null; deploymentVersionId?: string | null } ): Promise { const handler = getProviderHandler(provider) if (!handler.configurePolling) { diff --git a/apps/sim/lib/webhooks/polling/imap.test.ts b/apps/sim/lib/webhooks/polling/imap.test.ts index d22ff7f9b7a..90bd8151eeb 100644 --- a/apps/sim/lib/webhooks/polling/imap.test.ts +++ b/apps/sim/lib/webhooks/polling/imap.test.ts @@ -80,4 +80,45 @@ describe('IMAP runtime polling policy', () => { expect(logged).not.toContain('literal-password') expect(logged).not.toContain('Referenced IMAP authentication requires redeployment') }) + + it('uses the deployment actor and canonical workflow workspace for each referenced poll', async () => { + const mockLimit = vi.fn().mockResolvedValue([{ createdBy: 'deployment-actor' }]) + const mockWhere = vi.fn().mockReturnValue({ limit: mockLimit }) + const mockFrom = vi.fn().mockReturnValue({ where: mockWhere }) + mockDbSelect.mockReturnValue({ from: mockFrom }) + mockResolveImapConnectionForActor.mockResolvedValue({ + host: 'imap.example.com', + port: 993, + secure: true, + username: 'resolved-user', + password: 'resolved-password', + }) + mockCreateSecureImapClient.mockRejectedValue(new Error('connection unavailable')) + + const result = await imapPollingHandler.pollWebhook({ + webhookData: { + id: 'webhook-1', + deploymentVersionId: 'deployment-1', + providerConfig: { + host: '{{IMAP_HOST}}', + username: '{{IMAP_USERNAME}}', + password: '{{IMAP_PASSWORD}}', + }, + } as never, + workflowData: { id: 'workflow-1', workspaceId: 'canonical-workspace' } as never, + requestId: 'request-1', + logger: mockLogger as never, + }) + + expect(result).toBe('failure') + expect(mockResolveImapConnectionForActor).toHaveBeenCalledWith({ + connection: expect.objectContaining({ password: '{{IMAP_PASSWORD}}' }), + actorUserId: 'deployment-actor', + workspaceId: 'canonical-workspace', + }) + expect(mockCreateSecureImapClient).toHaveBeenCalledWith( + expect.objectContaining({ username: 'resolved-user', password: 'resolved-password' }) + ) + expect(mockMarkWebhookFailed).toHaveBeenCalledWith('webhook-1', mockLogger) + }) }) diff --git a/apps/sim/lib/webhooks/providers/imap.ts b/apps/sim/lib/webhooks/providers/imap.ts index 72716cef8c3..1b6483b124c 100644 --- a/apps/sim/lib/webhooks/providers/imap.ts +++ b/apps/sim/lib/webhooks/providers/imap.ts @@ -70,11 +70,10 @@ export const imapHandler: WebhookProviderHandler = { secure?: boolean } const hasReferences = hasImapEnvironmentReferences(connection) - if (hasReferences && !userId) return false const resolved = hasReferences ? await resolveImapConnectionForActor({ connection, - actorUserId: userId!, + actorUserId: userId, workspaceId, }) : normalizeLiteralImapConnection(connection) diff --git a/apps/sim/lib/webhooks/providers/types.ts b/apps/sim/lib/webhooks/providers/types.ts index 29e291d890f..bc239f3ce3e 100644 --- a/apps/sim/lib/webhooks/providers/types.ts +++ b/apps/sim/lib/webhooks/providers/types.ts @@ -86,8 +86,8 @@ export interface PollingConfigContext { webhook: Record requestId: string /** Deployment actor used to validate reference-backed polling configuration. */ - userId?: string - workspaceId?: string | null + userId: string + workspaceId: string | null deploymentVersionId?: string | null /** * Stable registration preparation supplies a generation-fenced persistence callback. diff --git a/apps/sim/lib/workflows/search-replace/indexer-selector-context.test.ts b/apps/sim/lib/workflows/search-replace/indexer-selector-context.test.ts new file mode 100644 index 00000000000..9d9c1e57516 --- /dev/null +++ b/apps/sim/lib/workflows/search-replace/indexer-selector-context.test.ts @@ -0,0 +1,56 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('@/tools/params', () => ({ + formatParameterLabel: (id: string) => id, + getToolIdForOperation: () => 'test_list', + getSubBlocksForToolInput: () => null, + getToolParametersConfig: () => ({ + userInputParameters: [ + { + id: 'credential', + type: 'string', + required: true, + visibility: 'user-only', + uiComponent: { + type: 'short-input', + canonicalParamId: 'oauthCredential', + }, + }, + { + id: 'resourceId', + type: 'string', + required: true, + visibility: 'user-only', + uiComponent: { + type: 'dropdown', + selectorKey: 'gmail.labels', + dependsOn: ['credential'], + }, + }, + ], + }), +})) + +import { getToolInputParamConfigs } from '@/lib/workflows/search-replace/indexer' + +describe('tool-input selector fallback context', () => { + it('includes sibling display parameters in selector context', () => { + const configs = getToolInputParamConfigs({ + tool: { + type: 'test', + operation: 'list', + params: { + credential: 'credential-1', + resourceId: 'resource-1', + }, + }, + }) + + expect(configs.find((config) => config.paramId === 'resourceId')?.selectorContext).toEqual({ + oauthCredential: 'credential-1', + }) + }) +}) diff --git a/apps/sim/lib/workflows/search-replace/indexer.ts b/apps/sim/lib/workflows/search-replace/indexer.ts index 192e5f5295d..a8b17490057 100644 --- a/apps/sim/lib/workflows/search-replace/indexer.ts +++ b/apps/sim/lib/workflows/search-replace/indexer.ts @@ -768,28 +768,29 @@ export function getToolInputParamConfigs({ if (!toolParams && !subBlocksResult) return genericFallback() if (!subBlocksResult?.subBlocks.length) { - const fallbackCanonicalIndex = buildCanonicalIndex([]) - return displayParams + const fallbackConfigs = displayParams .filter((param) => isVisibleToolParameter(param, values)) - .map((param) => { - const config = buildToolInputSearchConfig(param) - return { - paramId: param.id, - authoritative: true, - config, - value: parseToolParamValue(toolParamValues[param.id], config.type), - selectorContext: - config.selectorKey || config.dependsOn - ? buildSelectorContext({ - subBlockConfig: config, - subBlockValues: values, - contextConfigs: [config], - canonicalIndex: fallbackCanonicalIndex, - canonicalModes: scopedCanonicalModes, - }) - : undefined, - } - }) + .map((param) => ({ param, config: buildToolInputSearchConfig(param) })) + const contextConfigs = fallbackConfigs.map(({ config }) => config) + const fallbackCanonicalIndex = buildCanonicalIndex(contextConfigs) + return fallbackConfigs.map(({ param, config }) => { + return { + paramId: param.id, + authoritative: true, + config, + value: parseToolParamValue(toolParamValues[param.id], config.type), + selectorContext: + config.selectorKey || config.dependsOn + ? buildSelectorContext({ + subBlockConfig: config, + subBlockValues: values, + contextConfigs, + canonicalIndex: fallbackCanonicalIndex, + canonicalModes: scopedCanonicalModes, + }) + : undefined, + } + }) } // canonical-index-unscoped: a nested tool's params are always the action surface From f78fc4eab1c9fb4fa3ef3aef23df182ed7afd323 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Fri, 28 Aug 2026 10:41:23 -0700 Subject: [PATCH 07/13] fix(selectors): address Cubic rerun findings --- .agents/skills/add-block/SKILL.md | 6 +- apps/sim/app/api/webhooks/route.test.ts | 2 + apps/sim/app/api/webhooks/route.ts | 1 + .../components/dropdown/dropdown.test.tsx | 24 +++++- .../components/dropdown/dropdown.tsx | 2 +- apps/sim/lib/imap/connection.server.test.ts | 33 ++++++++ apps/sim/lib/imap/connection.server.ts | 14 ++-- .../server/providers/confluence.test.ts | 81 +++++++++++++++++++ .../selectors/server/providers/confluence.ts | 26 +++--- .../sim/lib/selectors/server/sanitize.test.ts | 39 +++++++++ apps/sim/lib/selectors/server/sanitize.ts | 8 +- apps/sim/lib/webhooks/providers/imap.test.ts | 58 ++++++++++++- apps/sim/lib/webhooks/providers/imap.ts | 28 ++++++- .../indexer-selector-context.test.ts | 34 +++++++- .../lib/workflows/search-replace/indexer.ts | 16 +++- 15 files changed, 333 insertions(+), 39 deletions(-) create mode 100644 apps/sim/lib/selectors/server/providers/confluence.test.ts diff --git a/.agents/skills/add-block/SKILL.md b/.agents/skills/add-block/SKILL.md index deaac90b92a..7cf6cd27ea8 100644 --- a/.agents/skills/add-block/SKILL.md +++ b/.agents/skills/add-block/SKILL.md @@ -1080,8 +1080,10 @@ execute through `selectors.execute`; never add a client provider module or selec `canonicalParamId: 'oauthCredential'` on the credential sub-block is the line people forget. The shared context builder projects only active `dependsOn` values and keys canonical pairs by their canonical id. Exact environment references such as `{{GMAIL_CREDENTIAL_ID}}` stay unresolved in the -browser and are resolved only by the authorized server executor. A credential field is also -recognized by its `oauth-input` type as a compatibility fallback. +browser and are resolved only by the authorized server executor. The builder does not infer a +nonstandard credential id from `type: 'oauth-input'`; give it +`canonicalParamId: 'oauthCredential'`, or declare an explicit manifest `sourceFields` alias when a +legacy source id must be retained. **`options` — everything else.** A static array, or a pure function of the block's own values for a list that narrows to a sibling's selection. No I/O. diff --git a/apps/sim/app/api/webhooks/route.test.ts b/apps/sim/app/api/webhooks/route.test.ts index 2e2fad78b4d..7f0d154b106 100644 --- a/apps/sim/app/api/webhooks/route.test.ts +++ b/apps/sim/app/api/webhooks/route.test.ts @@ -81,6 +81,7 @@ describe('POST /api/webhooks polling configuration', () => { blockId: 'block-1', path: 'imap-hook', provider: 'imap', + deploymentVersionId: 'deployment-1', providerConfig: { host: '{{IMAP_HOST}}', username: '{{IMAP_USERNAME}}', @@ -115,6 +116,7 @@ describe('POST /api/webhooks polling configuration', () => { requestId: 'mock-request-id', userId: 'actor-1', workspaceId: 'canonical-workspace', + deploymentVersionId: 'deployment-1', }) }) }) diff --git a/apps/sim/app/api/webhooks/route.ts b/apps/sim/app/api/webhooks/route.ts index d50941bf811..831a731a9cd 100644 --- a/apps/sim/app/api/webhooks/route.ts +++ b/apps/sim/app/api/webhooks/route.ts @@ -509,6 +509,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { userId, workspaceId: typeof workflowRecord.workspaceId === 'string' ? workflowRecord.workspaceId : null, + deploymentVersionId: savedWebhook.deploymentVersionId ?? null, }) if (!success) { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.test.tsx index 05fdfd8f796..0b335cdc0a6 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.test.tsx @@ -14,6 +14,7 @@ const { fetched } = vi.hoisted(() => ({ hasLoadedOptions: true, fetchError: null as string | null, hydratedOptions: [] as { id: string; label: string }[], + selectedValues: ['col_a', 'col_gone'] as string[], }, })) @@ -61,7 +62,7 @@ vi.mock( ) vi.mock( '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value', - () => ({ useSubBlockValue: () => [['col_a', 'col_gone'], () => {}] }) + () => ({ useSubBlockValue: () => [fetched.selectedValues, () => {}] }) ) vi.mock( '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/providers/active-search-target-provider', @@ -154,4 +155,25 @@ describe('Dropdown multi-select stale selections', () => { fetched.hydratedOptions = [] } }) + + it('preserves selected value order when hydrating multiple missing options', () => { + const previousOptions = fetched.options + const previousSelectedValues = fetched.selectedValues + fetched.options = [] + fetched.selectedValues = ['col_first', 'col_second'] + fetched.hydratedOptions = [ + { id: 'col_first', label: 'First column' }, + { id: 'col_second', label: 'Second column' }, + ] + try { + const html = render() + expect(html.indexOf('data-value="col_first"')).toBeLessThan( + html.indexOf('data-value="col_second"') + ) + } finally { + fetched.options = previousOptions + fetched.selectedValues = previousSelectedValues + fetched.hydratedOptions = [] + } + }) }) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx index 3c1f29a13e8..a8a8d7b146c 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx @@ -207,7 +207,7 @@ export const Dropdown = memo(function Dropdown({ } } - for (const option of hydratedOptions) { + for (const option of [...hydratedOptions].reverse()) { const alreadyPresent = opts.some((existing) => typeof existing === 'string' ? existing === option.id : existing.id === option.id ) diff --git a/apps/sim/lib/imap/connection.server.test.ts b/apps/sim/lib/imap/connection.server.test.ts index bac121fdebf..1411057ee52 100644 --- a/apps/sim/lib/imap/connection.server.test.ts +++ b/apps/sim/lib/imap/connection.server.test.ts @@ -90,6 +90,24 @@ describe('IMAP connection policy', () => { ) }) + it('preserves the legacy TLS defaults for nullable connection values', () => { + expect( + normalizeLiteralImapConnection({ + host: 'imap.example.com', + port: null, + secure: null, + username: 'mailbox-user', + password: 'literal-password', + }) + ).toEqual({ + host: 'imap.example.com', + port: 993, + secure: true, + username: 'mailbox-user', + password: 'literal-password', + }) + }) + it('resolves exact personal and visible shared references for the deployment actor', async () => { environmentUtilsMockFns.mockResolveEffectiveEnvironmentVariables.mockResolvedValue({ PERSONAL_PASSWORD: { @@ -182,6 +200,21 @@ describe('IMAP connection policy', () => { ) }) + it('rejects unresolved workflow references in literal IMAP connection fields', () => { + expect(() => + normalizeLiteralImapConnection({ + host: 'imap.example.com', + username: '', + password: 'literal-password', + }) + ).toThrowError( + expect.objectContaining>({ + name: 'ImapConnectionPolicyError', + code: 'context', + }) + ) + }) + it('reauthorizes requested references on every resolution and fails closed after revocation', async () => { environmentUtilsMockFns.mockResolveEffectiveEnvironmentVariables .mockResolvedValueOnce({ diff --git a/apps/sim/lib/imap/connection.server.ts b/apps/sim/lib/imap/connection.server.ts index 4c9ee63d66d..98a922b76dd 100644 --- a/apps/sim/lib/imap/connection.server.ts +++ b/apps/sim/lib/imap/connection.server.ts @@ -1,6 +1,7 @@ import { ImapFlow } from 'imapflow' import { validateDatabaseHost } from '@/lib/core/security/input-validation.server' import { resolveEffectiveEnvironmentVariables } from '@/lib/environment/utils' +import { containsReference } from '@/lib/workflows/sanitization/references' const EXACT_ENVIRONMENT_REFERENCE = /^\{\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*\}\}$/ @@ -27,8 +28,8 @@ export interface ResolvedImapConnection { password: string } -function containsTemplateDelimiter(value: string): boolean { - return value.includes('{{') || value.includes('}}') +function containsUnresolvedReference(value: string): boolean { + return value.includes('{{') || value.includes('}}') || containsReference(value) } export function hasImapEnvironmentReferences(input: ImapConnectionInput): boolean { @@ -41,9 +42,10 @@ function normalizeConnection(input: ImapConnectionInput): ResolvedImapConnection const host = typeof input.host === 'string' ? input.host.trim() : '' const username = typeof input.username === 'string' ? input.username : '' const password = typeof input.password === 'string' ? input.password : '' - const port = input.port === undefined || input.port === '' ? 993 : Number(input.port) + const port = + input.port === null || input.port === undefined || input.port === '' ? 993 : Number(input.port) const secure = - input.secure === undefined || input.secure === '' + input.secure === null || input.secure === undefined || input.secure === '' ? true : typeof input.secure === 'string' ? input.secure.toLowerCase() === 'true' @@ -77,7 +79,7 @@ export async function resolveImapConnectionForActor(input: { if (typeof value !== 'string') return [] const match = EXACT_ENVIRONMENT_REFERENCE.exec(value) if (match) return [match[1]] - if (containsTemplateDelimiter(value)) throw new ImapConnectionPolicyError('context') + if (containsUnresolvedReference(value)) throw new ImapConnectionPolicyError('context') return [] }) ), @@ -119,7 +121,7 @@ export function normalizeResolvedImapConnection( export function normalizeLiteralImapConnection(input: ImapConnectionInput): ResolvedImapConnection { for (const value of [input.host, input.port, input.secure, input.username, input.password]) { - if (typeof value === 'string' && containsTemplateDelimiter(value)) { + if (typeof value === 'string' && containsUnresolvedReference(value)) { throw new ImapConnectionPolicyError('context') } } diff --git a/apps/sim/lib/selectors/server/providers/confluence.test.ts b/apps/sim/lib/selectors/server/providers/confluence.test.ts new file mode 100644 index 00000000000..270ac161b0a --- /dev/null +++ b/apps/sim/lib/selectors/server/providers/confluence.test.ts @@ -0,0 +1,81 @@ +/** + * @vitest-environment node + */ +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockFetch, mockResolveCredentialBundle, mockResolveCloudId } = vi.hoisted(() => ({ + mockFetch: vi.fn(), + mockResolveCredentialBundle: vi.fn(), + mockResolveCloudId: vi.fn(), +})) + +vi.mock('@/lib/selectors/server/providers/credential-bundle', () => ({ + resolveSelectorCredentialBundle: mockResolveCredentialBundle, +})) + +vi.mock('@/lib/selectors/server/providers/atlassian', () => ({ + resolveSelectorAtlassianCloudId: mockResolveCloudId, +})) + +import { SelectorOptionsUnavailableError } from '@/lib/selectors/server/errors' +import { createSelectorProtectedValues } from '@/lib/selectors/server/protected-values' +import { confluenceSelectorAttachments } from '@/lib/selectors/server/providers/confluence' +import type { ExecuteServerSelectorArgs } from '@/lib/selectors/server/types' + +function pageDetailArgs(): ExecuteServerSelectorArgs { + return { + selectorKey: 'confluence.pages', + context: { oauthCredential: 'credential-1', domain: 'acme.atlassian.net' }, + request: { kind: 'detail', id: 'page-1' }, + scope: { kind: 'workspace', workspaceId: 'workspace-1' }, + workspaceId: 'workspace-1', + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + requesterUserId: 'user-1', + credential: { suppliedId: 'credential-1' }, + references: new Map(), + protectedValues: createSelectorProtectedValues(), + } +} + +describe('Confluence server selector adapters', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.stubGlobal('fetch', mockFetch) + mockResolveCredentialBundle.mockResolvedValue({ accessToken: 'server-only-token' }) + mockResolveCloudId.mockResolvedValue('cloud-1') + }) + + afterAll(() => vi.unstubAllGlobals()) + + it('hydrates page details through the bounded provider reader without requesting page bodies', async () => { + mockFetch.mockResolvedValueOnce( + new Response(JSON.stringify({ id: 'page-1', title: 'Architecture' }), { status: 200 }) + ) + + await expect( + confluenceSelectorAttachments['confluence.pages'].execute(pageDetailArgs()) + ).resolves.toEqual({ + kind: 'detail', + item: { id: 'page-1', label: 'Architecture' }, + }) + + const requestedUrl = String(mockFetch.mock.calls[0]?.[0]) + expect(requestedUrl).toBe( + 'https://api.atlassian.com/ex/confluence/cloud-1/wiki/api/v2/pages/page-1' + ) + expect(requestedUrl).not.toContain('body-format') + }) + + it('rejects an oversized page detail response before parsing it', async () => { + mockFetch.mockResolvedValueOnce( + new Response('{}', { + status: 200, + headers: { 'content-length': String(16 * 1024 * 1024 + 1) }, + }) + ) + + await expect( + confluenceSelectorAttachments['confluence.pages'].execute(pageDetailArgs()) + ).rejects.toBeInstanceOf(SelectorOptionsUnavailableError) + }) +}) diff --git a/apps/sim/lib/selectors/server/providers/confluence.ts b/apps/sim/lib/selectors/server/providers/confluence.ts index 96cddf41d03..0e653270d30 100644 --- a/apps/sim/lib/selectors/server/providers/confluence.ts +++ b/apps/sim/lib/selectors/server/providers/confluence.ts @@ -13,7 +13,6 @@ import { listSelectorResult, type ServerSelectorAttachmentMap, } from '@/lib/selectors/server/types' -import { fetchConfluencePage } from '@/tools/confluence/client' type ConfluenceSelectorKey = Extract @@ -160,23 +159,16 @@ async function executePages(args: ExecuteServerSelectorArgs) { if (!/^[A-Za-z0-9_-]{1,255}$/.test(pageId)) { throw new SelectorContextUnavailableError() } - let response: Response - try { - response = await fetchConfluencePage({ - ...auth, - pageId, + const page = await fetchProviderJson( + `https://api.atlassian.com/ex/confluence/${auth.cloudId}/wiki/api/v2/pages/${pageId}`, + { + headers: { + Accept: 'application/json', + Authorization: `Bearer ${auth.accessToken}`, + }, signal: args.signal, - }) - } catch { - throw new SelectorOptionsUnavailableError() - } - if (!response.ok) throw new SelectorOptionsUnavailableError() - let page: ConfluencePage - try { - page = (await response.json()) as ConfluencePage - } catch { - throw new SelectorOptionsUnavailableError() - } + } + ) if (!page.id || !page.title) throw new SelectorOptionsUnavailableError() return detailSelectorResult({ id: page.id, label: page.title }) } diff --git a/apps/sim/lib/selectors/server/sanitize.test.ts b/apps/sim/lib/selectors/server/sanitize.test.ts index 2211bb5d11d..26dfecf1e07 100644 --- a/apps/sim/lib/selectors/server/sanitize.test.ts +++ b/apps/sim/lib/selectors/server/sanitize.test.ts @@ -141,6 +141,45 @@ describe('sanitizeSelectorResult', () => { ).toThrow(SelectorOptionsUnavailableError) }) + it('rejects protected plaintext in metadata keys without applying the detail exemption', () => { + const protectedValues = createSelectorProtectedValues() + protectedValues.add('resolved-id') + + expect(() => + sanitizeSelectorResult( + { + kind: 'detail', + item: { + id: 'resolved-id', + label: 'resolved-id', + meta: { 'prefix-resolved-id-suffix': null }, + }, + }, + protectedValues, + { allowedDetailExactProtectedValue: 'resolved-id' } + ) + ).toThrow(SelectorOptionsUnavailableError) + }) + + it('preserves allowed metadata keys that shadow object prototype properties', () => { + const meta = Object.create(null) as Record + meta.__proto__ = null + + const result = sanitizeSelectorResult( + { + kind: 'list', + items: [{ id: 'resource-1', label: 'Resource one', meta }], + }, + createSelectorProtectedValues() + ) + + expect(result.kind).toBe('list') + if (result.kind !== 'list') throw new Error('Expected list selector result') + expect(Object.hasOwn(result.items[0].meta ?? {}, '__proto__')).toBe(true) + expect(result.items[0].meta?.__proto__).toBeNull() + expect(JSON.stringify(result.items[0].meta)).toBe('{"__proto__":null}') + }) + it('rejects metadata strings larger than the response contract permits', () => { expect(() => sanitizeSelectorResult( diff --git a/apps/sim/lib/selectors/server/sanitize.ts b/apps/sim/lib/selectors/server/sanitize.ts index aa751d8fa4d..ef48cb16c7a 100644 --- a/apps/sim/lib/selectors/server/sanitize.ts +++ b/apps/sim/lib/selectors/server/sanitize.ts @@ -44,6 +44,7 @@ function sanitizeMeta( const meta: SafeOptionMeta = {} for (const [key, entry] of entries) { if (!key || key.length > 128) throw new SelectorOptionsUnavailableError() + if (protectedValues.contains(key)) throw new SelectorOptionsUnavailableError() if ( entry !== null && typeof entry !== 'string' && @@ -61,7 +62,12 @@ function sanitizeMeta( throw new SelectorOptionsUnavailableError() } } - meta[key] = entry as SafeOptionMetaValue + Object.defineProperty(meta, key, { + value: entry as SafeOptionMetaValue, + enumerable: true, + configurable: true, + writable: true, + }) } return meta } diff --git a/apps/sim/lib/webhooks/providers/imap.test.ts b/apps/sim/lib/webhooks/providers/imap.test.ts index 424ace3dccc..d4637169334 100644 --- a/apps/sim/lib/webhooks/providers/imap.test.ts +++ b/apps/sim/lib/webhooks/providers/imap.test.ts @@ -6,6 +6,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const { mockClose, mockCreateSecureImapClient, + mockDbLimit, + mockDbSelect, mockDbUpdate, mockHasImapEnvironmentReferences, mockLogger, @@ -14,6 +16,8 @@ const { } = vi.hoisted(() => ({ mockClose: vi.fn(), mockCreateSecureImapClient: vi.fn(), + mockDbLimit: vi.fn(), + mockDbSelect: vi.fn(), mockDbUpdate: vi.fn(), mockHasImapEnvironmentReferences: vi.fn(), mockLogger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, @@ -22,7 +26,7 @@ const { })) vi.mock('@sim/db', () => ({ - db: { update: mockDbUpdate }, + db: { select: mockDbSelect, update: mockDbUpdate }, })) vi.mock('@sim/logger', () => ({ @@ -68,6 +72,12 @@ describe('IMAP polling deployment policy', () => { password: 'resolved-password', }) mockCreateSecureImapClient.mockResolvedValue({ close: mockClose }) + mockDbLimit.mockResolvedValue([{ createdBy: 'deployment-actor' }]) + mockDbSelect.mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ limit: mockDbLimit }), + }), + }) }) it('validates references JIT but persists the unresolved reference expressions', async () => { @@ -86,7 +96,7 @@ describe('IMAP polling deployment policy', () => { expect(mockResolveImapConnectionForActor).toHaveBeenCalledWith({ connection: referenceConfig, - actorUserId: 'actor-1', + actorUserId: 'deployment-actor', workspaceId: 'workspace-1', }) expect(mockCreateSecureImapClient).toHaveBeenCalledWith( @@ -102,6 +112,50 @@ describe('IMAP polling deployment policy', () => { expect(mockDbUpdate).not.toHaveBeenCalled() }) + it('rejects reference-backed legacy setup without a deployment actor', async () => { + await expect( + imapHandler.configurePolling!({ + webhook: { id: 'webhook-1', providerConfig: referenceConfig }, + requestId: 'request-1', + userId: 'actor-1', + workspaceId: 'workspace-1', + deploymentVersionId: null, + }) + ).resolves.toBe(false) + + expect(mockDbSelect).not.toHaveBeenCalled() + expect(mockResolveImapConnectionForActor).not.toHaveBeenCalled() + expect(mockCreateSecureImapClient).not.toHaveBeenCalled() + }) + + it('persists legacy defaults when nullable port and secure values are supplied', async () => { + const persistProviderConfig = vi.fn().mockResolvedValue(true) + + await expect( + imapHandler.configurePolling!({ + webhook: { + id: 'webhook-1', + providerConfig: { + host: 'imap.example.com', + port: null, + secure: null, + username: 'literal-user', + password: 'literal-password', + }, + }, + requestId: 'request-1', + userId: 'actor-1', + workspaceId: 'workspace-1', + persistProviderConfig, + }) + ).resolves.toBe(true) + + expect(persistProviderConfig).toHaveBeenCalledWith( + expect.objectContaining({ port: '993', secure: true }) + ) + expect(mockDbSelect).not.toHaveBeenCalled() + }) + it('fails closed without logging raw connection errors or authentication values', async () => { mockCreateSecureImapClient.mockRejectedValue( new Error('provider echoed literal-user and literal-password') diff --git a/apps/sim/lib/webhooks/providers/imap.ts b/apps/sim/lib/webhooks/providers/imap.ts index 1b6483b124c..c4f3e12a158 100644 --- a/apps/sim/lib/webhooks/providers/imap.ts +++ b/apps/sim/lib/webhooks/providers/imap.ts @@ -1,5 +1,5 @@ import { db } from '@sim/db' -import { webhook } from '@sim/db/schema' +import { webhook, workflowDeploymentVersion } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { eq } from 'drizzle-orm' import { @@ -47,6 +47,7 @@ export const imapHandler: WebhookProviderHandler = { requestId, userId, workspaceId, + deploymentVersionId, persistProviderConfig, }: PollingConfigContext) { logger.info(`[${requestId}] Setting up IMAP polling for webhook ${webhookData.id}`) @@ -70,10 +71,25 @@ export const imapHandler: WebhookProviderHandler = { secure?: boolean } const hasReferences = hasImapEnvironmentReferences(connection) + let deploymentActorUserId = userId + if (hasReferences) { + if (!deploymentVersionId) { + throw new Error('Referenced IMAP authentication requires redeployment') + } + const [deployment] = await db + .select({ createdBy: workflowDeploymentVersion.createdBy }) + .from(workflowDeploymentVersion) + .where(eq(workflowDeploymentVersion.id, deploymentVersionId)) + .limit(1) + if (!deployment?.createdBy) { + throw new Error('Referenced IMAP authentication requires redeployment') + } + deploymentActorUserId = deployment.createdBy + } const resolved = hasReferences ? await resolveImapConnectionForActor({ connection, - actorUserId: userId, + actorUserId: deploymentActorUserId, workspaceId, }) : normalizeLiteralImapConnection(connection) @@ -83,11 +99,15 @@ export const imapHandler: WebhookProviderHandler = { const configuredProviderConfig = { ...providerConfig, port: - providerConfig.port === undefined || providerConfig.port === '' + providerConfig.port === null || + providerConfig.port === undefined || + providerConfig.port === '' ? '993' : providerConfig.port, secure: - providerConfig.secure === undefined || providerConfig.secure === '' + providerConfig.secure === null || + providerConfig.secure === undefined || + providerConfig.secure === '' ? true : providerConfig.secure, mailbox: providerConfig.mailbox || 'INBOX', diff --git a/apps/sim/lib/workflows/search-replace/indexer-selector-context.test.ts b/apps/sim/lib/workflows/search-replace/indexer-selector-context.test.ts index 9d9c1e57516..5b2f93345fd 100644 --- a/apps/sim/lib/workflows/search-replace/indexer-selector-context.test.ts +++ b/apps/sim/lib/workflows/search-replace/indexer-selector-context.test.ts @@ -1,12 +1,16 @@ /** * @vitest-environment node */ -import { describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { getSubBlocksForToolInput } = vi.hoisted(() => ({ + getSubBlocksForToolInput: vi.fn(), +})) vi.mock('@/tools/params', () => ({ formatParameterLabel: (id: string) => id, getToolIdForOperation: () => 'test_list', - getSubBlocksForToolInput: () => null, + getSubBlocksForToolInput, getToolParametersConfig: () => ({ userInputParameters: [ { @@ -37,6 +41,10 @@ vi.mock('@/tools/params', () => ({ import { getToolInputParamConfigs } from '@/lib/workflows/search-replace/indexer' describe('tool-input selector fallback context', () => { + beforeEach(() => { + getSubBlocksForToolInput.mockReturnValue(null) + }) + it('includes sibling display parameters in selector context', () => { const configs = getToolInputParamConfigs({ tool: { @@ -53,4 +61,26 @@ describe('tool-input selector fallback context', () => { oauthCredential: 'credential-1', }) }) + + it('includes sibling display parameters when tool sub-blocks also exist', () => { + getSubBlocksForToolInput.mockReturnValue({ + subBlocks: [{ id: 'message', title: 'Message', type: 'short-input' }], + }) + + const configs = getToolInputParamConfigs({ + tool: { + type: 'test', + operation: 'list', + params: { + credential: 'credential-1', + resourceId: 'resource-1', + message: 'hello', + }, + }, + }) + + expect(configs.find((config) => config.paramId === 'resourceId')?.selectorContext).toEqual({ + oauthCredential: 'credential-1', + }) + }) }) diff --git a/apps/sim/lib/workflows/search-replace/indexer.ts b/apps/sim/lib/workflows/search-replace/indexer.ts index a8b17490057..c3c0f59cace 100644 --- a/apps/sim/lib/workflows/search-replace/indexer.ts +++ b/apps/sim/lib/workflows/search-replace/indexer.ts @@ -807,6 +807,8 @@ export function getToolInputParamConfigs({ }) ) const allToolSubBlocks = blockConfig?.subBlocks ?? subBlocksResult.subBlocks + const displayParamConfigs = displayParams.map((param) => buildToolInputSearchConfig(param)) + const displayConfigById = new Map(displayParamConfigs.map((config) => [config.id, config])) const getDependentValuePaths = (changedSubBlockId: string): WorkflowSearchValuePath[] => getTransitiveSubBlockDependents(allToolSubBlocks, [changedSubBlockId]).map((clear) => [ 'params', @@ -828,6 +830,14 @@ export function getToolInputParamConfigs({ return ids }) ) + const toolSubBlockIds = new Set(allToolSubBlocks.map((config) => config.id)) + const combinedContextConfigs = [ + ...allToolSubBlocks, + ...displayParamConfigs.filter( + (config) => !coveredParamIds.has(config.id) && !toolSubBlockIds.has(config.id) + ), + ] + const combinedCanonicalIndex = buildCanonicalIndex(combinedContextConfigs) const subBlockParams = visibleSubBlocks.map((config) => ({ paramId: config.id, @@ -849,7 +859,7 @@ export function getToolInputParamConfigs({ const uncoveredParams = displayParams .filter((param) => !coveredParamIds.has(param.id) && isVisibleToolParameter(param, values)) .map((param) => { - const config = buildToolInputSearchConfig(param) + const config = displayConfigById.get(param.id) ?? buildToolInputSearchConfig(param) return { paramId: param.id, authoritative: true, @@ -860,8 +870,8 @@ export function getToolInputParamConfigs({ ? buildSelectorContext({ subBlockConfig: config, subBlockValues: values, - contextConfigs: allToolSubBlocks, - canonicalIndex: toolCanonicalIndex, + contextConfigs: combinedContextConfigs, + canonicalIndex: combinedCanonicalIndex, canonicalModes: scopedCanonicalModes, }) : undefined, From 316c2f07bdac61c79bd85766c3931f8e3f93e9fe Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Fri, 28 Aug 2026 11:11:21 -0700 Subject: [PATCH 08/13] fix(selectors): close final Cubic review gaps --- .agents/skills/add-block/SKILL.md | 5 +++-- .agents/skills/add-selector/SKILL.md | 7 ++++-- apps/sim/app/api/webhooks/route.test.ts | 22 +++++++++++++++++-- apps/sim/app/api/webhooks/route.ts | 11 ++++++++++ .../server/providers/confluence.test.ts | 20 +++++++++++++++++ .../selectors/server/providers/confluence.ts | 1 + .../sim/lib/selectors/server/sanitize.test.ts | 16 ++++++++++++++ apps/sim/lib/selectors/server/sanitize.ts | 5 ++++- 8 files changed, 80 insertions(+), 7 deletions(-) diff --git a/.agents/skills/add-block/SKILL.md b/.agents/skills/add-block/SKILL.md index 7cf6cd27ea8..f650aac6b36 100644 --- a/.agents/skills/add-block/SKILL.md +++ b/.agents/skills/add-block/SKILL.md @@ -1066,8 +1066,9 @@ After creating the block, you MUST validate it against every tool it references: A sub-block gets its choices from exactly one of two places. There is no third. **`selectorKey` — every remote list.** Use the `add-selector` skill to add browser-safe metadata in -`apps/sim/lib/selectors/manifest.ts` and a server attachment under -`apps/sim/lib/selectors/server/providers/`. Point the sub-block at that key. All remote selectors +`apps/sim/lib/selectors/manifest.ts`. Attach `provider-server` selectors under +`apps/sim/lib/selectors/server/providers/` and `internal-server` selectors in +`apps/sim/lib/selectors/server/internal.ts`. Point the sub-block at that key. All remote selectors execute through `selectors.execute`; never add a client provider module or selector-only fetch route. ```ts diff --git a/.agents/skills/add-selector/SKILL.md b/.agents/skills/add-selector/SKILL.md index 876ea4a446f..adce93065f6 100644 --- a/.agents/skills/add-selector/SKILL.md +++ b/.agents/skills/add-selector/SKILL.md @@ -51,8 +51,11 @@ explicitly on each relevant manifest entry. Never send a full block or connector ## Add the server attachment -Add the service's attachment map under `apps/sim/lib/selectors/server/providers/` and include it in -the exhaustive server registry. A provider attachment declares: +For `provider-server`, add the service's attachment map under +`apps/sim/lib/selectors/server/providers/` and include it in the exhaustive server registry. For +`internal-server`, add the attachment in `apps/sim/lib/selectors/server/internal.ts`. Local keys use +the exhaustive browser-safe registry in `apps/sim/lib/selectors/client/local.ts` and never enter the +server registry. A provider attachment declares: - Credential policy, including the exact context field and trusted `serviceIds`. - Destination policy: `fixed`, `credential-bound`, or `user-controlled`. diff --git a/apps/sim/app/api/webhooks/route.test.ts b/apps/sim/app/api/webhooks/route.test.ts index 7f0d154b106..dd398bc7515 100644 --- a/apps/sim/app/api/webhooks/route.test.ts +++ b/apps/sim/app/api/webhooks/route.test.ts @@ -90,10 +90,25 @@ describe('POST /api/webhooks polling configuration', () => { isActive: true, } queueTableRows(workflow, [ - { id: 'workflow-1', userId: 'owner-1', workspaceId: 'canonical-workspace' }, + { + id: 'workflow-1', + userId: 'owner-1', + workspaceId: 'canonical-workspace', + deploymentVersionId: 'deployment-1', + }, ]) queueTableRows(webhook, []) - dbChainMockFns.returning.mockResolvedValueOnce([savedWebhook]) + dbChainMockFns.returning.mockImplementationOnce(async () => { + const insertedValues = dbChainMockFns.values.mock.calls.at(-1)?.[0] as { + deploymentVersionId?: string | null + } + return [ + { + ...savedWebhook, + deploymentVersionId: insertedValues.deploymentVersionId ?? null, + }, + ] + }) const response = await POST( createMockRequest( @@ -111,6 +126,9 @@ describe('POST /api/webhooks polling configuration', () => { ) expect(response.status).toBe(201) + expect(dbChainMockFns.values).toHaveBeenCalledWith( + expect.objectContaining({ deploymentVersionId: 'deployment-1' }) + ) expect(mocks.configurePolling).toHaveBeenCalledWith({ webhook: savedWebhook, requestId: 'mock-request-id', diff --git a/apps/sim/app/api/webhooks/route.ts b/apps/sim/app/api/webhooks/route.ts index 831a731a9cd..1a88351cb49 100644 --- a/apps/sim/app/api/webhooks/route.ts +++ b/apps/sim/app/api/webhooks/route.ts @@ -267,8 +267,16 @@ export const POST = withRouteHandler(async (request: NextRequest) => { id: workflow.id, userId: workflow.userId, workspaceId: workflow.workspaceId, + deploymentVersionId: workflowDeploymentVersion.id, }) .from(workflow) + .leftJoin( + workflowDeploymentVersion, + and( + eq(workflowDeploymentVersion.workflowId, workflow.id), + eq(workflowDeploymentVersion.isActive, true) + ) + ) .where(eq(workflow.id, workflowId)) .limit(1) @@ -455,6 +463,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { .values({ id: webhookId, workflowId, + ...(provider === 'imap' + ? { deploymentVersionId: workflowRecord.deploymentVersionId ?? null } + : {}), blockId, path: finalPath, provider, diff --git a/apps/sim/lib/selectors/server/providers/confluence.test.ts b/apps/sim/lib/selectors/server/providers/confluence.test.ts index 270ac161b0a..492ec88cee6 100644 --- a/apps/sim/lib/selectors/server/providers/confluence.test.ts +++ b/apps/sim/lib/selectors/server/providers/confluence.test.ts @@ -37,6 +37,15 @@ function pageDetailArgs(): ExecuteServerSelectorArgs { } } +function spaceDetailArgs(signal?: AbortSignal): ExecuteServerSelectorArgs { + return { + ...pageDetailArgs(), + selectorKey: 'confluence.spaces', + request: { kind: 'detail', id: 'ENG' }, + signal, + } +} + describe('Confluence server selector adapters', () => { beforeEach(() => { vi.clearAllMocks() @@ -78,4 +87,15 @@ describe('Confluence server selector adapters', () => { confluenceSelectorAttachments['confluence.pages'].execute(pageDetailArgs()) ).rejects.toBeInstanceOf(SelectorOptionsUnavailableError) }) + + it('preserves caller cancellation while hydrating space details', async () => { + const controller = new AbortController() + const abortError = new DOMException('The operation was aborted', 'AbortError') + controller.abort(abortError) + mockFetch.mockRejectedValue(abortError) + + await expect( + confluenceSelectorAttachments['confluence.spaces'].execute(spaceDetailArgs(controller.signal)) + ).rejects.toBe(abortError) + }) }) diff --git a/apps/sim/lib/selectors/server/providers/confluence.ts b/apps/sim/lib/selectors/server/providers/confluence.ts index 0e653270d30..0290800f9c6 100644 --- a/apps/sim/lib/selectors/server/providers/confluence.ts +++ b/apps/sim/lib/selectors/server/providers/confluence.ts @@ -111,6 +111,7 @@ async function executeSpaces(args: ExecuteServerSelectorArgs) { requestSpaces({ ...auth, params: paramsFor('current'), signal: args.signal }), requestSpaces({ ...auth, params: paramsFor('archived'), signal: args.signal }), ]) + args.signal?.throwIfAborted() if (current.status === 'rejected' && archived.status === 'rejected') { throw new SelectorOptionsUnavailableError() } diff --git a/apps/sim/lib/selectors/server/sanitize.test.ts b/apps/sim/lib/selectors/server/sanitize.test.ts index 26dfecf1e07..338280ae512 100644 --- a/apps/sim/lib/selectors/server/sanitize.test.ts +++ b/apps/sim/lib/selectors/server/sanitize.test.ts @@ -161,6 +161,22 @@ describe('sanitizeSelectorResult', () => { ).toThrow(SelectorOptionsUnavailableError) }) + it('rejects protected numeric metadata without applying the detail exemption', () => { + const protectedValues = createSelectorProtectedValues() + protectedValues.add('1234') + + expect(() => + sanitizeSelectorResult( + { + kind: 'detail', + item: { id: '1234', label: '1234', meta: { resourceId: 1234 } }, + }, + protectedValues, + { allowedDetailExactProtectedValue: '1234' } + ) + ).toThrow(SelectorOptionsUnavailableError) + }) + it('preserves allowed metadata keys that shadow object prototype properties', () => { const meta = Object.create(null) as Record meta.__proto__ = null diff --git a/apps/sim/lib/selectors/server/sanitize.ts b/apps/sim/lib/selectors/server/sanitize.ts index ef48cb16c7a..e7d4ee5daaa 100644 --- a/apps/sim/lib/selectors/server/sanitize.ts +++ b/apps/sim/lib/selectors/server/sanitize.ts @@ -53,7 +53,10 @@ function sanitizeMeta( ) { throw new SelectorOptionsUnavailableError() } - if (typeof entry === 'number' && !Number.isFinite(entry)) { + if ( + typeof entry === 'number' && + (!Number.isFinite(entry) || protectedValues.contains(String(entry))) + ) { throw new SelectorOptionsUnavailableError() } if (typeof entry === 'string') { From 5558e19ee2ad5e249b32b121bdd84dafdfed8c60 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Fri, 28 Aug 2026 11:45:04 -0700 Subject: [PATCH 09/13] fix(selectors): close final review follow-ups --- .agents/skills/validate-integration/SKILL.md | 2 +- apps/sim/app/api/webhooks/route.test.ts | 69 ++++++++++++++++++++ apps/sim/app/api/webhooks/route.ts | 3 + 3 files changed, 73 insertions(+), 1 deletion(-) diff --git a/.agents/skills/validate-integration/SKILL.md b/.agents/skills/validate-integration/SKILL.md index 62f1b22d8f8..541f9e37846 100644 --- a/.agents/skills/validate-integration/SKILL.md +++ b/.agents/skills/validate-integration/SKILL.md @@ -38,7 +38,7 @@ packages/deployment-config/src/service-account-providers.generated.ts # Generate packages/deployment-config/src/service-account-metadata.ts # Handwritten deployment policy ``` -If the block or its triggers use a `selectorKey`, also apply the `validate-selector` skill and read +If the block, its triggers, or connector fields use a `selectorKey`, also apply the `validate-selector` skill and read the key's entry in `apps/sim/lib/selectors/manifest.ts`, its server attachment and provider listing primitive, and the shared context builder. There is no client provider selector registry. diff --git a/apps/sim/app/api/webhooks/route.test.ts b/apps/sim/app/api/webhooks/route.test.ts index dd398bc7515..ee273eba8cb 100644 --- a/apps/sim/app/api/webhooks/route.test.ts +++ b/apps/sim/app/api/webhooks/route.test.ts @@ -137,4 +137,73 @@ describe('POST /api/webhooks polling configuration', () => { deploymentVersionId: 'deployment-1', }) }) + + it('repairs an existing IMAP webhook deployment binding before polling setup', async () => { + const existingWebhook = { + id: 'webhook-1', + workflowId: 'workflow-1', + blockId: 'block-1', + path: 'imap-hook', + provider: 'imap', + deploymentVersionId: null, + providerConfig: { + host: '{{IMAP_HOST}}', + username: '{{IMAP_USERNAME}}', + password: '{{IMAP_PASSWORD}}', + }, + isActive: true, + } + queueTableRows(workflow, [ + { + id: 'workflow-1', + userId: 'owner-1', + workspaceId: 'canonical-workspace', + deploymentVersionId: 'deployment-1', + }, + ]) + queueTableRows(webhook, [{ id: existingWebhook.id }]) + queueTableRows(webhook, [existingWebhook]) + dbChainMockFns.returning.mockImplementationOnce(async () => { + const updatedValues = dbChainMockFns.set.mock.calls.at(-1)?.[0] as { + deploymentVersionId?: string | null + } + return [ + { + ...existingWebhook, + deploymentVersionId: updatedValues.deploymentVersionId ?? null, + }, + ] + }) + + const response = await POST( + createMockRequest( + 'POST', + { + workflowId: 'workflow-1', + blockId: 'block-1', + path: 'imap-hook', + provider: 'imap', + providerConfig: existingWebhook.providerConfig, + }, + {}, + 'http://localhost:3000/api/webhooks' + ) + ) + + const repairedWebhook = { + ...existingWebhook, + deploymentVersionId: 'deployment-1', + } + expect(response.status).toBe(200) + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ deploymentVersionId: 'deployment-1' }) + ) + expect(mocks.configurePolling).toHaveBeenCalledWith({ + webhook: repairedWebhook, + requestId: 'mock-request-id', + userId: 'actor-1', + workspaceId: 'canonical-workspace', + deploymentVersionId: 'deployment-1', + }) + }) }) diff --git a/apps/sim/app/api/webhooks/route.ts b/apps/sim/app/api/webhooks/route.ts index 1a88351cb49..98d5035200c 100644 --- a/apps/sim/app/api/webhooks/route.ts +++ b/apps/sim/app/api/webhooks/route.ts @@ -443,6 +443,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { .set({ blockId, provider, + ...(provider === 'imap' + ? { deploymentVersionId: workflowRecord.deploymentVersionId ?? null } + : {}), providerConfig: configToSave, isActive: true, updatedAt: new Date(), From d78e5673a79c386dc749c216adda7e6bdc1a67e8 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Fri, 28 Aug 2026 12:02:23 -0700 Subject: [PATCH 10/13] fix(imap): restore deployment binding on rollback --- apps/sim/app/api/webhooks/route.test.ts | 64 +++++++++++++++++++++++++ apps/sim/app/api/webhooks/route.ts | 1 + 2 files changed, 65 insertions(+) diff --git a/apps/sim/app/api/webhooks/route.test.ts b/apps/sim/app/api/webhooks/route.test.ts index ee273eba8cb..3ea49de614d 100644 --- a/apps/sim/app/api/webhooks/route.test.ts +++ b/apps/sim/app/api/webhooks/route.test.ts @@ -206,4 +206,68 @@ describe('POST /api/webhooks polling configuration', () => { deploymentVersionId: 'deployment-1', }) }) + + it('restores the previous IMAP deployment binding when polling setup fails', async () => { + const existingWebhook = { + id: 'webhook-1', + workflowId: 'workflow-1', + blockId: 'block-1', + path: 'imap-hook', + provider: 'imap', + deploymentVersionId: null, + providerConfig: { + host: '{{IMAP_HOST}}', + username: '{{IMAP_USERNAME}}', + password: '{{IMAP_PASSWORD}}', + }, + isActive: true, + } + queueTableRows(workflow, [ + { + id: 'workflow-1', + userId: 'owner-1', + workspaceId: 'canonical-workspace', + deploymentVersionId: 'deployment-1', + }, + ]) + queueTableRows(webhook, [{ id: existingWebhook.id }]) + queueTableRows(webhook, [existingWebhook]) + dbChainMockFns.returning.mockImplementationOnce(async () => { + const updatedValues = dbChainMockFns.set.mock.calls.at(-1)?.[0] as { + deploymentVersionId?: string | null + } + return [ + { + ...existingWebhook, + deploymentVersionId: updatedValues.deploymentVersionId ?? null, + }, + ] + }) + mocks.configurePolling.mockResolvedValue(false) + + const response = await POST( + createMockRequest( + 'POST', + { + workflowId: 'workflow-1', + blockId: 'block-1', + path: 'imap-hook', + provider: 'imap', + providerConfig: existingWebhook.providerConfig, + }, + {}, + 'http://localhost:3000/api/webhooks' + ) + ) + + expect(response.status).toBe(500) + expect(dbChainMockFns.set).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ deploymentVersionId: 'deployment-1' }) + ) + expect(dbChainMockFns.set).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ deploymentVersionId: null }) + ) + }) }) diff --git a/apps/sim/app/api/webhooks/route.ts b/apps/sim/app/api/webhooks/route.ts index 98d5035200c..5ae68f3f2c4 100644 --- a/apps/sim/app/api/webhooks/route.ts +++ b/apps/sim/app/api/webhooks/route.ts @@ -43,6 +43,7 @@ async function revertSavedWebhook( .update(webhook) .set({ workflowId: existingWebhook.workflowId, + deploymentVersionId: existingWebhook.deploymentVersionId, blockId: existingWebhook.blockId, path: existingWebhook.path, provider: existingWebhook.provider, From a5ddb7f8794a4510038636c60d294c5876227356 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Fri, 28 Aug 2026 12:35:38 -0700 Subject: [PATCH 11/13] fix(imap): scope webhook updates to active deployment --- apps/sim/app/api/webhooks/route.test.ts | 85 +++++++++++++++++++++++-- apps/sim/app/api/webhooks/route.ts | 9 ++- 2 files changed, 89 insertions(+), 5 deletions(-) diff --git a/apps/sim/app/api/webhooks/route.test.ts b/apps/sim/app/api/webhooks/route.test.ts index 3ea49de614d..0f8322795de 100644 --- a/apps/sim/app/api/webhooks/route.test.ts +++ b/apps/sim/app/api/webhooks/route.test.ts @@ -7,6 +7,7 @@ import { authMockFns, createMockRequest, dbChainMockFns, + flattenMockConditions, posthogServerMock, queueTableRows, resetDbChainMock, @@ -138,14 +139,14 @@ describe('POST /api/webhooks polling configuration', () => { }) }) - it('repairs an existing IMAP webhook deployment binding before polling setup', async () => { + it('updates only the current-deployment IMAP webhook before polling setup', async () => { const existingWebhook = { id: 'webhook-1', workflowId: 'workflow-1', blockId: 'block-1', path: 'imap-hook', provider: 'imap', - deploymentVersionId: null, + deploymentVersionId: 'deployment-1', providerConfig: { host: '{{IMAP_HOST}}', username: '{{IMAP_USERNAME}}', @@ -195,6 +196,16 @@ describe('POST /api/webhooks polling configuration', () => { deploymentVersionId: 'deployment-1', } expect(response.status).toBe(200) + const pathLookupConditions = dbChainMockFns.where.mock.calls + .map(([condition]) => flattenMockConditions(condition)) + .find((conditions) => + conditions.some((condition) => condition.type === 'eq' && condition.right === 'imap-hook') + ) + expect(pathLookupConditions).toContainEqual({ + type: 'eq', + left: 'webhook.deploymentVersionId', + right: 'deployment-1', + }) expect(dbChainMockFns.set).toHaveBeenCalledWith( expect.objectContaining({ deploymentVersionId: 'deployment-1' }) ) @@ -207,6 +218,72 @@ describe('POST /api/webhooks polling configuration', () => { }) }) + it('creates an active IMAP webhook instead of rebinding a historical row', async () => { + const savedWebhook = { + id: 'webhook-active', + workflowId: 'workflow-1', + blockId: 'block-1', + path: 'imap-hook', + provider: 'imap', + deploymentVersionId: 'deployment-1', + providerConfig: { + host: '{{IMAP_HOST}}', + username: '{{IMAP_USERNAME}}', + password: '{{IMAP_PASSWORD}}', + }, + isActive: true, + } + queueTableRows(workflow, [ + { + id: 'workflow-1', + userId: 'owner-1', + workspaceId: 'canonical-workspace', + deploymentVersionId: 'deployment-1', + }, + ]) + // A historical row may exist, but the current-deployment lookup correctly returns none. + queueTableRows(webhook, []) + dbChainMockFns.returning.mockImplementationOnce(async () => { + const insertedValues = dbChainMockFns.values.mock.calls.at(-1)?.[0] as { + deploymentVersionId?: string | null + } + return [ + { + ...savedWebhook, + deploymentVersionId: insertedValues.deploymentVersionId ?? null, + }, + ] + }) + + const response = await POST( + createMockRequest( + 'POST', + { + workflowId: 'workflow-1', + blockId: 'block-1', + path: 'imap-hook', + provider: 'imap', + providerConfig: savedWebhook.providerConfig, + }, + {}, + 'http://localhost:3000/api/webhooks' + ) + ) + + expect(response.status).toBe(201) + expect(dbChainMockFns.set).not.toHaveBeenCalled() + expect(dbChainMockFns.values).toHaveBeenCalledWith( + expect.objectContaining({ deploymentVersionId: 'deployment-1' }) + ) + expect(mocks.configurePolling).toHaveBeenCalledWith({ + webhook: savedWebhook, + requestId: 'mock-request-id', + userId: 'actor-1', + workspaceId: 'canonical-workspace', + deploymentVersionId: 'deployment-1', + }) + }) + it('restores the previous IMAP deployment binding when polling setup fails', async () => { const existingWebhook = { id: 'webhook-1', @@ -214,7 +291,7 @@ describe('POST /api/webhooks polling configuration', () => { blockId: 'block-1', path: 'imap-hook', provider: 'imap', - deploymentVersionId: null, + deploymentVersionId: 'deployment-1', providerConfig: { host: '{{IMAP_HOST}}', username: '{{IMAP_USERNAME}}', @@ -267,7 +344,7 @@ describe('POST /api/webhooks polling configuration', () => { ) expect(dbChainMockFns.set).toHaveBeenNthCalledWith( 2, - expect.objectContaining({ deploymentVersionId: null }) + expect.objectContaining({ deploymentVersionId: 'deployment-1' }) ) }) }) diff --git a/apps/sim/app/api/webhooks/route.ts b/apps/sim/app/api/webhooks/route.ts index 5ae68f3f2c4..5c785c2bc88 100644 --- a/apps/sim/app/api/webhooks/route.ts +++ b/apps/sim/app/api/webhooks/route.ts @@ -345,6 +345,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } + const imapDeploymentCondition = + provider === 'imap' + ? workflowRecord.deploymentVersionId + ? eq(webhook.deploymentVersionId, workflowRecord.deploymentVersionId) + : isNull(webhook.deploymentVersionId) + : undefined const ownExisting = await db .select({ id: webhook.id }) .from(webhook) @@ -352,7 +358,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => { and( eq(webhook.path, finalPath), eq(webhook.workflowId, workflowId), - isNull(webhook.archivedAt) + isNull(webhook.archivedAt), + imapDeploymentCondition ) ) .limit(1) From c4365b47ef0838d07d096960df47e0578dacc181 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Fri, 28 Aug 2026 13:49:10 -0700 Subject: [PATCH 12/13] test(selectors): consolidate migration regression coverage --- .../selector-combobox.test.tsx | 120 ++---------------- .../hooks/use-fetched-options.test.tsx | 27 ++-- apps/sim/lib/imap/selector-policy.test.ts | 58 --------- .../indexer-selector-context.test.ts | 34 ++--- 4 files changed, 29 insertions(+), 210 deletions(-) delete mode 100644 apps/sim/lib/imap/selector-policy.test.ts diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox.test.tsx index 43a50ee14bb..8ae42d72c2f 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox.test.tsx @@ -95,31 +95,14 @@ beforeEach(() => { }) describe('SelectorCombobox label hydration', () => { - it.each([ - { - state: 'preview', - isPreview: true, - disabled: false, - previewValue: 'preview-label', - detailId: 'preview-label', - }, - { - state: 'disabled', - isPreview: false, - disabled: true, - previewValue: undefined, - detailId: 'stored-label', - }, - ])('keeps the list disabled but hydrates the selected value when $state', (state) => { + it('renders a hydrated selected label while disabled without enabling the list', () => { const html = renderToStaticMarkup( ) @@ -130,72 +113,14 @@ describe('SelectorCombobox label hydration', () => { ) expect(mockUseSelectorOptionDetail).toHaveBeenCalledWith( 'jira.issues', - expect.objectContaining({ detailId: state.detailId, enabled: true }) + expect.objectContaining({ detailId: 'stored-label', enabled: true }) ) }) - it.each([ - { - state: 'preview', - isPreview: true, - disabled: false, - previewValue: ['preview-label', '{{SHARED_LABEL}}', ''], - detailIds: ['preview-label', '{{SHARED_LABEL}}'], - }, - { - state: 'disabled', - isPreview: false, - disabled: true, - previewValue: undefined, - detailIds: ['stored-one', 'stored-two'], - }, - ])('hydrates selected multi-values without enabling the list when $state', (state) => { - if (!state.isPreview) selectorState.storeValue = ['stored-one', 'stored-two'] - - const html = renderToStaticMarkup( - - ) - - for (const id of state.detailIds) expect(html).toContain(`Hydrated ${id}`) - if (state.isPreview) expect(html).toContain('<Block.output>') - expect(mockUseSelectorOptions).toHaveBeenCalledWith( - 'jira.issues', - expect.objectContaining({ enabled: false }) - ) - expect(mockUseSelectorOptionDetails).toHaveBeenCalledWith( - 'jira.issues', - expect.objectContaining({ detailIds: state.detailIds, enabled: true }) - ) - }) - - it.each([ - { - state: 'preview', - isPreview: true, - disabled: false, - previewValue: ['preview-label', '{{SHARED_LABEL}}', ''], - listedValues: ['preview-label'], - }, - { - state: 'disabled', - isPreview: false, - disabled: true, - previewValue: undefined, - listedValues: ['stored-one', 'stored-two'], - }, - ])('uses a search-free list to hydrate no-detail multi-values when $state', (state) => { - if (!state.isPreview) selectorState.storeValue = ['stored-one', 'stored-two'] + it('renders preview labels from a search-free list when detail lookup is unsupported', () => { + const previewValue = ['preview-label', '{{SHARED_LABEL}}', ''] mockUseSelectorOptions.mockReturnValue({ - data: state.listedValues.map((id) => ({ id, label: `Listed ${id}` })), + data: [{ id: 'preview-label', label: 'Listed preview-label' }], isLoading: false, hasMore: false, error: null, @@ -207,18 +132,15 @@ describe('SelectorCombobox label hydration', () => { subBlock={{ id: 'labels', title: 'Labels', type: 'combobox' }} selectorKey='gmail.labels' selectorContext={{ workspaceId: 'workspace-1', oauthCredential: 'credential-1' }} - isPreview={state.isPreview} - disabled={state.disabled} - previewValue={state.previewValue} + isPreview + previewValue={previewValue} multiSelect /> ) - for (const id of state.listedValues) expect(html).toContain(`Listed ${id}`) - if (state.isPreview) { - expect(html).toContain('{{SHARED_LABEL}}') - expect(html).toContain('<Block.output>') - } + expect(html).toContain('Listed preview-label') + expect(html).toContain('{{SHARED_LABEL}}') + expect(html).toContain('<Block.output>') expect(mockUseSelectorOptions).toHaveBeenCalledWith( 'gmail.labels', expect.objectContaining({ enabled: true, search: undefined }) @@ -228,24 +150,4 @@ describe('SelectorCombobox label hydration', () => { expect.objectContaining({ enabled: false }) ) }) - - it('does not detail-hydrate a runtime reference', () => { - selectorState.storeValue = '' - - const html = renderToStaticMarkup( - - ) - - expect(html).toContain('<Block.output>') - expect(mockUseSelectorOptionDetail).toHaveBeenCalledWith( - 'gmail.labels', - expect.objectContaining({ detailId: undefined, enabled: false }) - ) - }) }) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-fetched-options.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-fetched-options.test.tsx index ee8964dd78a..ca20d4becd6 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-fetched-options.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-fetched-options.test.tsx @@ -53,18 +53,15 @@ beforeEach(() => { }) describe('useFetchedOptions label hydration', () => { - it.each([ - { state: 'preview', isPreview: true, disabled: false }, - { state: 'disabled', isPreview: false, disabled: true }, - ])('keeps the list disabled but hydrates the selected value when $state', (state) => { + it('hydrates a selected value while a disabled control keeps list interaction off', () => { function Probe() { const result = useFetchedOptions({ blockId: 'block-1', subBlockId: 'label', dependsOnFields: ['credential'], selectorKey: 'jira.issues', - isPreview: state.isPreview, - disabled: state.disabled, + isPreview: false, + disabled: true, valueToHydrate: 'issue-1', localOptions: [], }) @@ -82,18 +79,15 @@ describe('useFetchedOptions label hydration', () => { ) }) - it.each([ - { state: 'preview', isPreview: true, disabled: false }, - { state: 'disabled', isPreview: false, disabled: true }, - ])('hydrates selected multi-values without enabling the list when $state', (state) => { + it('hydrates only eligible multi-values while preview keeps list interaction off', () => { function Probe() { const result = useFetchedOptions({ blockId: 'block-1', subBlockId: 'labels', dependsOnFields: ['credential'], selectorKey: 'jira.issues', - isPreview: state.isPreview, - disabled: state.disabled, + isPreview: true, + disabled: false, valueToHydrate: undefined, valuesToHydrate: ['label-1', '{{SHARED_LABEL}}', '', 'local-label'], localOptions: [{ id: 'local-label' }], @@ -117,10 +111,7 @@ describe('useFetchedOptions label hydration', () => { ) }) - it.each([ - { state: 'preview', isPreview: true, disabled: false }, - { state: 'disabled', isPreview: false, disabled: true }, - ])('uses a search-free list to hydrate no-detail multi-values when $state', (state) => { + it('uses a search-free list to hydrate a no-detail selector while disabled', () => { mockUseSelectorOptions.mockReturnValue({ data: [{ id: 'label-1', label: 'Primary label' }], isLoading: false, @@ -135,8 +126,8 @@ describe('useFetchedOptions label hydration', () => { subBlockId: 'labels', dependsOnFields: ['credential'], selectorKey: 'gmail.labels', - isPreview: state.isPreview, - disabled: state.disabled, + isPreview: false, + disabled: true, valueToHydrate: undefined, valuesToHydrate: ['label-1', '{{SHARED_LABEL}}', ''], localOptions: [], diff --git a/apps/sim/lib/imap/selector-policy.test.ts b/apps/sim/lib/imap/selector-policy.test.ts deleted file mode 100644 index 81bdd21b687..00000000000 --- a/apps/sim/lib/imap/selector-policy.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -/** - * @vitest-environment node - */ -import { describe, expect, it, vi } from 'vitest' - -const { mockListImapMailboxes, mockNormalizeLiteralImapConnection } = vi.hoisted(() => ({ - mockListImapMailboxes: vi.fn(), - mockNormalizeLiteralImapConnection: vi.fn(), -})) - -vi.mock('@/lib/imap/connection.server', () => ({ - listImapMailboxes: mockListImapMailboxes, - normalizeLiteralImapConnection: mockNormalizeLiteralImapConnection, -})) - -import { SelectorConnectionUnavailableError } from '@/lib/selectors/server/errors' -import { createSelectorProtectedValues } from '@/lib/selectors/server/protected-values' -import { imapSelectorAttachments } from '@/lib/selectors/server/providers/imap' -import type { ExecuteServerSelectorArgs } from '@/lib/selectors/server/types' - -function selectorArgs(): ExecuteServerSelectorArgs { - return { - selectorKey: 'imap.mailboxes', - context: { - host: 'imap.example.com', - username: 'resolved-use-only-user', - password: 'resolved-use-only-password', - }, - request: { kind: 'list' }, - scope: { kind: 'workflow', workflowId: 'workflow-1' }, - workspaceId: 'workspace-1', - principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, - requesterUserId: 'user-1', - references: new Map([ - [ - 'password', - { - field: 'password', - name: 'SHARED_IMAP_PASSWORD', - scope: 'workspace', - visible: false, - }, - ], - ]), - protectedValues: createSelectorProtectedValues(), - } -} - -describe('IMAP selector policy', () => { - it('rejects hidden shared authentication before normalizing or connecting', async () => { - await expect( - imapSelectorAttachments['imap.mailboxes'].execute(selectorArgs()) - ).rejects.toBeInstanceOf(SelectorConnectionUnavailableError) - - expect(mockNormalizeLiteralImapConnection).not.toHaveBeenCalled() - expect(mockListImapMailboxes).not.toHaveBeenCalled() - }) -}) diff --git a/apps/sim/lib/workflows/search-replace/indexer-selector-context.test.ts b/apps/sim/lib/workflows/search-replace/indexer-selector-context.test.ts index 5b2f93345fd..9200811ef8a 100644 --- a/apps/sim/lib/workflows/search-replace/indexer-selector-context.test.ts +++ b/apps/sim/lib/workflows/search-replace/indexer-selector-context.test.ts @@ -41,32 +41,16 @@ vi.mock('@/tools/params', () => ({ import { getToolInputParamConfigs } from '@/lib/workflows/search-replace/indexer' describe('tool-input selector fallback context', () => { - beforeEach(() => { - getSubBlocksForToolInput.mockReturnValue(null) - }) - - it('includes sibling display parameters in selector context', () => { - const configs = getToolInputParamConfigs({ - tool: { - type: 'test', - operation: 'list', - params: { - credential: 'credential-1', - resourceId: 'resource-1', - }, - }, - }) - - expect(configs.find((config) => config.paramId === 'resourceId')?.selectorContext).toEqual({ - oauthCredential: 'credential-1', - }) - }) - - it('includes sibling display parameters when tool sub-blocks also exist', () => { - getSubBlocksForToolInput.mockReturnValue({ - subBlocks: [{ id: 'message', title: 'Message', type: 'short-input' }], - }) + beforeEach(() => getSubBlocksForToolInput.mockReset()) + it.each([ + ['without generated sub-blocks', null], + [ + 'with generated sub-blocks', + { subBlocks: [{ id: 'message', title: 'Message', type: 'short-input' }] }, + ], + ])('includes sibling display parameters $0', (_state, subBlocksResult) => { + getSubBlocksForToolInput.mockReturnValue(subBlocksResult) const configs = getToolInputParamConfigs({ tool: { type: 'test', From 845acaaf743a253e97069c4d70315462356e7ac0 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Fri, 28 Aug 2026 16:52:10 -0700 Subject: [PATCH 13/13] refactor(selectors): isolate unified selector contract --- .../cloudwatch/describe-log-groups/route.ts | 4 +- .../cloudwatch/describe-log-streams/route.ts | 4 +- .../app/api/tools/confluence/page/route.ts | 4 +- .../blocks/jira_service_management.test.ts | 2 +- .../lib/api/contracts/selectors/harmonic.ts | 21 ------ .../sim/lib/api/contracts/selectors/shared.ts | 70 ------------------- .../{selectors => tools}/cloudwatch.ts | 45 +++++------- .../{selectors => tools}/confluence.ts | 26 +++---- .../{selectors => tools}/jsm.test.ts | 2 +- .../api/contracts/{selectors => tools}/jsm.ts | 13 +++- .../lib/internal/cloudwatch/execute-tool.ts | 12 ++-- .../sim/lib/internal/cloudwatch/operations.ts | 12 ++-- .../lib/internal/confluence/execute-tool.ts | 10 +-- .../sim/lib/internal/confluence/operations.ts | 2 +- apps/sim/lib/internal/jsm/assets.ts | 2 +- apps/sim/lib/internal/jsm/execute-tool.ts | 2 +- apps/sim/lib/internal/jsm/forms.ts | 2 +- apps/sim/lib/internal/jsm/service-desk.ts | 2 +- .../selectors/server/providers/harmonic.ts | 24 +++++-- apps/sim/tools/snowflake/selector-kinds.ts | 9 ++- scripts/check-api-contract-routes.ts | 6 +- 21 files changed, 99 insertions(+), 175 deletions(-) delete mode 100644 apps/sim/lib/api/contracts/selectors/harmonic.ts delete mode 100644 apps/sim/lib/api/contracts/selectors/shared.ts rename apps/sim/lib/api/contracts/{selectors => tools}/cloudwatch.ts (64%) rename apps/sim/lib/api/contracts/{selectors => tools}/confluence.ts (97%) rename apps/sim/lib/api/contracts/{selectors => tools}/jsm.test.ts (99%) rename apps/sim/lib/api/contracts/{selectors => tools}/jsm.ts (98%) diff --git a/apps/sim/app/api/tools/cloudwatch/describe-log-groups/route.ts b/apps/sim/app/api/tools/cloudwatch/describe-log-groups/route.ts index 3911351f96e..e116dc1ded1 100644 --- a/apps/sim/app/api/tools/cloudwatch/describe-log-groups/route.ts +++ b/apps/sim/app/api/tools/cloudwatch/describe-log-groups/route.ts @@ -1,5 +1,5 @@ import { createLogger } from '@sim/logger' -import { cloudwatchLogGroupsSelectorContract } from '@/lib/api/contracts/selectors/cloudwatch' +import { cloudwatchLogGroupsContract } from '@/lib/api/contracts/tools/cloudwatch' import { parseToolRequest } from '@/lib/api/server' import { createCloudWatchHttpRoute } from '@/lib/internal/cloudwatch/http-route' import { executeCloudwatchDescribeLogGroups } from '@/lib/internal/cloudwatch/operations' @@ -9,7 +9,7 @@ const logger = createLogger('CloudWatchDescribeLogGroups') export const POST = createCloudWatchHttpRoute({ logger, parse: (request) => - parseToolRequest(cloudwatchLogGroupsSelectorContract, request, { + parseToolRequest(cloudwatchLogGroupsContract, request, { errorFormat: 'firstError', logger, }), diff --git a/apps/sim/app/api/tools/cloudwatch/describe-log-streams/route.ts b/apps/sim/app/api/tools/cloudwatch/describe-log-streams/route.ts index 3cbde9e151d..dab604bed6d 100644 --- a/apps/sim/app/api/tools/cloudwatch/describe-log-streams/route.ts +++ b/apps/sim/app/api/tools/cloudwatch/describe-log-streams/route.ts @@ -1,5 +1,5 @@ import { createLogger } from '@sim/logger' -import { cloudwatchLogStreamsSelectorContract } from '@/lib/api/contracts/selectors/cloudwatch' +import { cloudwatchLogStreamsContract } from '@/lib/api/contracts/tools/cloudwatch' import { parseToolRequest } from '@/lib/api/server' import { createCloudWatchHttpRoute } from '@/lib/internal/cloudwatch/http-route' import { executeCloudwatchDescribeLogStreams } from '@/lib/internal/cloudwatch/operations' @@ -9,7 +9,7 @@ const logger = createLogger('CloudWatchDescribeLogStreams') export const POST = createCloudWatchHttpRoute({ logger, parse: (request) => - parseToolRequest(cloudwatchLogStreamsSelectorContract, request, { + parseToolRequest(cloudwatchLogStreamsContract, request, { errorFormat: 'firstError', logger, }), diff --git a/apps/sim/app/api/tools/confluence/page/route.ts b/apps/sim/app/api/tools/confluence/page/route.ts index fa0f77eea52..1a688dd20a3 100644 --- a/apps/sim/app/api/tools/confluence/page/route.ts +++ b/apps/sim/app/api/tools/confluence/page/route.ts @@ -1,5 +1,5 @@ import { createLogger } from '@sim/logger' -import { confluencePageSelectorContract } from '@/lib/api/contracts/selectors/confluence' +import { confluencePageContract } from '@/lib/api/contracts/tools/confluence' import { parseRequest } from '@/lib/api/server' import { createConfluenceHttpRoute } from '@/lib/internal/confluence/http-route' import { executeConfluenceRetrievePage } from '@/lib/internal/confluence/operations' @@ -10,6 +10,6 @@ const logger = createLogger('ConfluencePageAPI') export const POST = createConfluenceHttpRoute({ logger, - parse: (request) => parseRequest(confluencePageSelectorContract, request, {}), + parse: (request) => parseRequest(confluencePageContract, request, {}), execute: executeConfluenceRetrievePage, }) diff --git a/apps/sim/blocks/blocks/jira_service_management.test.ts b/apps/sim/blocks/blocks/jira_service_management.test.ts index 89195c9505c..a38ade0c853 100644 --- a/apps/sim/blocks/blocks/jira_service_management.test.ts +++ b/apps/sim/blocks/blocks/jira_service_management.test.ts @@ -14,7 +14,7 @@ import { jsmRequestTypesToolBodySchema, jsmServiceDeskScopedBodySchema, jsmServiceDesksBodySchema, -} from '@/lib/api/contracts/selectors/jsm' +} from '@/lib/api/contracts/tools/jsm' import { JiraServiceManagementBlock } from '@/blocks/blocks/jira_service_management' import { jsmGetApprovalsTool, diff --git a/apps/sim/lib/api/contracts/selectors/harmonic.ts b/apps/sim/lib/api/contracts/selectors/harmonic.ts deleted file mode 100644 index 4a5ead8398a..00000000000 --- a/apps/sim/lib/api/contracts/selectors/harmonic.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { z } from 'zod' - -export const HARMONIC_SAVED_SEARCH_SELECTOR_MAX_OPTIONS = 500 - -const harmonicSavedSearchUrnSchema = z - .string() - .trim() - .min(1) - .max(512) - .regex(/^urn:harmonic:saved_search:[^\s]+$/, 'Invalid Harmonic saved-search URN') -const harmonicSavedSearchNameSchema = z.string().trim().min(1).max(1_000) - -/** Validates the documented fields consumed from a PERSONS saved-search row. */ -export const harmonicPeopleSavedSearchProviderSchema = z - .object({ - id: z.number().int().safe(), - entity_urn: harmonicSavedSearchUrnSchema, - name: harmonicSavedSearchNameSchema, - type: z.literal('PERSONS'), - }) - .passthrough() diff --git a/apps/sim/lib/api/contracts/selectors/shared.ts b/apps/sim/lib/api/contracts/selectors/shared.ts deleted file mode 100644 index 55e0e62b223..00000000000 --- a/apps/sim/lib/api/contracts/selectors/shared.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { z } from 'zod' -import { defineRouteContract } from '@/lib/api/contracts/types' - -export const optionalString = z.string().optional() - -/** - * Accepts `string | null | undefined` on the wire and outputs `string | - * undefined` (null collapsed to undefined). Used for fields like `workflowId` - * where the wire is permissive but server-side handlers expect an optional - * string. - */ -export const nullableOptionalString = z - .string() - .nullish() - .transform((value) => value ?? undefined) - -export const credentialWorkflowBodySchema = z.object({ - credential: z.string().min(1), - workflowId: nullableOptionalString, -}) - -export const credentialWorkflowDomainBodySchema = credentialWorkflowBodySchema.extend({ - domain: z.string().min(1), -}) - -export const credentialWorkflowImpersonateBodySchema = credentialWorkflowBodySchema.extend({ - impersonateEmail: optionalString, -}) - -export const credentialIdQuerySchema = z.object({ - credentialId: z - .string({ error: 'Credential ID is required' }) - .min(1, 'Credential ID is required'), -}) - -export const credentialIdQueryWithSearchSchema = credentialIdQuerySchema.extend({ - query: optionalString, -}) - -export const idNameSchema = z.object({ id: z.string(), name: z.string() }).passthrough() -export const idTitleSchema = z.object({ id: z.string(), title: z.string() }).passthrough() -export const idDisplayNameSchema = z - .object({ id: z.string(), displayName: z.string() }) - .passthrough() -export const fileOptionSchema = z.object({ id: z.string(), name: z.string() }).passthrough() -export const folderOptionSchema = z.object({ id: z.string(), name: z.string() }).passthrough() - -export const definePostSelector = ( - path: string, - body: TBody, - response: TResponse -) => - defineRouteContract({ - method: 'POST', - path, - body, - response: { mode: 'json', schema: response }, - }) - -export const defineGetSelector = ( - path: string, - query: TQuery, - response: TResponse -) => - defineRouteContract({ - method: 'GET', - path, - query, - response: { mode: 'json', schema: response }, - }) diff --git a/apps/sim/lib/api/contracts/selectors/cloudwatch.ts b/apps/sim/lib/api/contracts/tools/cloudwatch.ts similarity index 64% rename from apps/sim/lib/api/contracts/selectors/cloudwatch.ts rename to apps/sim/lib/api/contracts/tools/cloudwatch.ts index 3facb1559f9..53fd09305d5 100644 --- a/apps/sim/lib/api/contracts/selectors/cloudwatch.ts +++ b/apps/sim/lib/api/contracts/tools/cloudwatch.ts @@ -1,14 +1,23 @@ import { z } from 'zod' -import { definePostSelector, optionalString } from '@/lib/api/contracts/selectors/shared' -import type { - ContractBody, - ContractBodyInput, - ContractJsonResponse, -} from '@/lib/api/contracts/types' +import type { ContractBody } from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' import { validateAwsRegion } from '@/lib/core/security/input-validation' const cloudwatchLogGroupSchema = z.object({ logGroupName: z.string() }).passthrough() const cloudwatchLogStreamSchema = z.object({ logStreamName: z.string() }).passthrough() +const optionalString = z.string().optional() + +const definePostToolContract = ( + path: string, + body: TBody, + response: TResponse +) => + defineRouteContract({ + method: 'POST', + path, + body, + response: { mode: 'json', schema: response }, + }) /** * AWS region with format validation. Matches the route-level check via @@ -42,7 +51,7 @@ export const cloudwatchLogStreamsBodySchema = cloudwatchLogGroupsBodySchema.exte logGroupName: z.string().min(1, 'Log group name is required'), }) -export const cloudwatchLogGroupsSelectorContract = definePostSelector( +export const cloudwatchLogGroupsContract = definePostToolContract( '/api/tools/cloudwatch/describe-log-groups', cloudwatchLogGroupsBodySchema, z @@ -53,7 +62,7 @@ export const cloudwatchLogGroupsSelectorContract = definePostSelector( .passthrough() ) -export const cloudwatchLogStreamsSelectorContract = definePostSelector( +export const cloudwatchLogStreamsContract = definePostToolContract( '/api/tools/cloudwatch/describe-log-streams', cloudwatchLogStreamsBodySchema, z @@ -64,21 +73,5 @@ export const cloudwatchLogStreamsSelectorContract = definePostSelector( .passthrough() ) -export type CloudwatchLogGroupsSelectorResponse = ContractJsonResponse< - typeof cloudwatchLogGroupsSelectorContract -> -export type CloudwatchLogStreamsSelectorResponse = ContractJsonResponse< - typeof cloudwatchLogStreamsSelectorContract -> -export type CloudwatchLogGroupsSelectorRequest = ContractBodyInput< - typeof cloudwatchLogGroupsSelectorContract -> -export type CloudwatchLogGroupsSelectorBody = ContractBody< - typeof cloudwatchLogGroupsSelectorContract -> -export type CloudwatchLogStreamsSelectorRequest = ContractBodyInput< - typeof cloudwatchLogStreamsSelectorContract -> -export type CloudwatchLogStreamsSelectorBody = ContractBody< - typeof cloudwatchLogStreamsSelectorContract -> +export type CloudwatchLogGroupsBody = ContractBody +export type CloudwatchLogStreamsBody = ContractBody diff --git a/apps/sim/lib/api/contracts/selectors/confluence.ts b/apps/sim/lib/api/contracts/tools/confluence.ts similarity index 97% rename from apps/sim/lib/api/contracts/selectors/confluence.ts rename to apps/sim/lib/api/contracts/tools/confluence.ts index 69e8465c3a4..482f7e67e3e 100644 --- a/apps/sim/lib/api/contracts/selectors/confluence.ts +++ b/apps/sim/lib/api/contracts/tools/confluence.ts @@ -1,9 +1,10 @@ import { z } from 'zod' -import { definePostSelector, optionalString } from '@/lib/api/contracts/selectors/shared' -import type { ContractBody, ContractJsonResponse, ContractQuery } from '@/lib/api/contracts/types' +import type { ContractBody, ContractQuery } from '@/lib/api/contracts/types' import { defineRouteContract } from '@/lib/api/contracts/types' import { validateAlphanumericId } from '@/lib/core/security/input-validation' +const optionalString = z.string().optional() + /** * Refines a `pageId` field to match Confluence's alphanumeric format * (max 255 chars). Used as a `superRefine` so multiple @@ -342,16 +343,20 @@ const defineConfluenceGetContract = (path: string, que }, }) -export const confluencePageSelectorContract = definePostSelector( - '/api/tools/confluence/page', - confluencePageBodySchema, - z.object({ id: z.string(), title: z.string() }).passthrough() -) +export const confluencePageContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/confluence/page', + body: confluencePageBodySchema, + response: { + mode: 'json', + schema: z.object({ id: z.string(), title: z.string() }).passthrough(), + }, +}) /** * Page update and delete have no contract because they have no route: the * `PUT`/`DELETE` handlers on `/api/tools/confluence/page` were retired when the - * tool moved in process, and the surviving selector `POST` on that path would + * tool moved in process, and the surviving compatibility `POST` on that path would * answer either verb with 405. `lib/internal/confluence/execute-tool.ts` * validates both against `confluenceUpdatePageBodySchema` / * `confluenceDeletePageBodySchema` directly. @@ -511,7 +516,7 @@ export const confluenceUserContract = defineConfluencePostContract( confluenceUserBodySchema ) -export type ConfluencePageBody = ContractBody +export type ConfluencePageBody = ContractBody export type ConfluenceDeleteAttachmentBody = ContractBody export type ConfluenceListAttachmentsQuery = ContractQuery export type ConfluenceListBlogPostsQuery = ContractQuery @@ -560,6 +565,3 @@ export type ConfluenceListSpacesQuery = ContractQuery export type ConfluenceUploadAttachmentBody = ContractBody export type ConfluenceUserBody = ContractBody -export type ConfluencePageSelectorResponse = ContractJsonResponse< - typeof confluencePageSelectorContract -> diff --git a/apps/sim/lib/api/contracts/selectors/jsm.test.ts b/apps/sim/lib/api/contracts/tools/jsm.test.ts similarity index 99% rename from apps/sim/lib/api/contracts/selectors/jsm.test.ts rename to apps/sim/lib/api/contracts/tools/jsm.test.ts index d241e125564..1178fb934cc 100644 --- a/apps/sim/lib/api/contracts/selectors/jsm.test.ts +++ b/apps/sim/lib/api/contracts/tools/jsm.test.ts @@ -13,7 +13,7 @@ import { jsmRequestTypesToolBodySchema, jsmServiceDeskScopedBodySchema, jsmServiceDesksBodySchema, -} from '@/lib/api/contracts/selectors/jsm' +} from '@/lib/api/contracts/tools/jsm' const credentials = { domain: 'example.atlassian.net', diff --git a/apps/sim/lib/api/contracts/selectors/jsm.ts b/apps/sim/lib/api/contracts/tools/jsm.ts similarity index 98% rename from apps/sim/lib/api/contracts/selectors/jsm.ts rename to apps/sim/lib/api/contracts/tools/jsm.ts index b2083ea96fb..da5d2b74442 100644 --- a/apps/sim/lib/api/contracts/selectors/jsm.ts +++ b/apps/sim/lib/api/contracts/tools/jsm.ts @@ -1,7 +1,7 @@ import { isRecordLike } from '@sim/utils/object' import { z } from 'zod' -import { definePostSelector } from '@/lib/api/contracts/selectors/shared' import type { ContractBody } from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' const jsmBaseBodySchema = z.object({ domain: z.string({ error: 'Domain is required' }).min(1, 'Domain is required'), @@ -279,7 +279,16 @@ export const jsmDeleteObjectBodySchema = jsmAssetsBaseBodySchema.extend({ }) export const defineJsmToolContract = (path: string, body: TBody) => - definePostSelector(path, body, z.unknown()) + defineRouteContract({ + method: 'POST', + path, + body, + response: { + mode: 'json', + // untyped-response: shared JSM helper covers distinct Atlassian payloads for every operation + schema: z.unknown(), + }, + }) export const jsmServiceDesksContract = defineJsmToolContract( '/api/tools/jsm/servicedesks', diff --git a/apps/sim/lib/internal/cloudwatch/execute-tool.ts b/apps/sim/lib/internal/cloudwatch/execute-tool.ts index 8a26ef1cef7..209d76e3e88 100644 --- a/apps/sim/lib/internal/cloudwatch/execute-tool.ts +++ b/apps/sim/lib/internal/cloudwatch/execute-tool.ts @@ -1,9 +1,5 @@ import { toError } from '@sim/utils/errors' import type { AnyApiRouteContract, ContractBody } from '@/lib/api/contracts' -import { - cloudwatchLogGroupsSelectorContract, - cloudwatchLogStreamsSelectorContract, -} from '@/lib/api/contracts/selectors/cloudwatch' import { awsCloudwatchDescribeAlarmHistoryContract } from '@/lib/api/contracts/tools/aws/cloudwatch-describe-alarm-history' import { awsCloudwatchDescribeAlarmsContract } from '@/lib/api/contracts/tools/aws/cloudwatch-describe-alarms' import { awsCloudwatchFilterLogEventsContract } from '@/lib/api/contracts/tools/aws/cloudwatch-filter-log-events' @@ -15,6 +11,10 @@ import { awsCloudwatchPutLogGroupRetentionContract } from '@/lib/api/contracts/t import { awsCloudwatchPutMetricDataContract } from '@/lib/api/contracts/tools/aws/cloudwatch-put-metric-data' import { awsCloudwatchQueryLogsContract } from '@/lib/api/contracts/tools/aws/cloudwatch-query-logs' import { awsCloudwatchUnmuteAlarmContract } from '@/lib/api/contracts/tools/aws/cloudwatch-unmute-alarm' +import { + cloudwatchLogGroupsContract, + cloudwatchLogStreamsContract, +} from '@/lib/api/contracts/tools/cloudwatch' import { CloudWatchInputError, executeCloudwatchDescribeAlarmHistory, @@ -81,7 +81,7 @@ export const executeCloudwatchTool: InternalToolOperationHandler = async ({ ) case 'cloudwatch_describe_log_groups': return executeOperation( - cloudwatchLogGroupsSelectorContract, + cloudwatchLogGroupsContract, input, executeCloudwatchDescribeLogGroups, 'Failed to describe CloudWatch log groups', @@ -89,7 +89,7 @@ export const executeCloudwatchTool: InternalToolOperationHandler = async ({ ) case 'cloudwatch_describe_log_streams': return executeOperation( - cloudwatchLogStreamsSelectorContract, + cloudwatchLogStreamsContract, input, executeCloudwatchDescribeLogStreams, 'Failed to describe CloudWatch log streams', diff --git a/apps/sim/lib/internal/cloudwatch/operations.ts b/apps/sim/lib/internal/cloudwatch/operations.ts index 51082581223..392fa0a5c1a 100644 --- a/apps/sim/lib/internal/cloudwatch/operations.ts +++ b/apps/sim/lib/internal/cloudwatch/operations.ts @@ -17,10 +17,6 @@ import { StartQueryCommand, } from '@aws-sdk/client-cloudwatch-logs' import { createLogger } from '@sim/logger' -import type { - CloudwatchLogGroupsSelectorBody, - CloudwatchLogStreamsSelectorBody, -} from '@/lib/api/contracts/selectors/cloudwatch' import type { AwsCloudwatchDescribeAlarmHistoryBody } from '@/lib/api/contracts/tools/aws/cloudwatch-describe-alarm-history' import type { AwsCloudwatchDescribeAlarmsBody } from '@/lib/api/contracts/tools/aws/cloudwatch-describe-alarms' import type { AwsCloudwatchFilterLogEventsBody } from '@/lib/api/contracts/tools/aws/cloudwatch-filter-log-events' @@ -32,6 +28,10 @@ import type { AwsCloudwatchPutLogGroupRetentionBody } from '@/lib/api/contracts/ import type { AwsCloudwatchPutMetricDataBody } from '@/lib/api/contracts/tools/aws/cloudwatch-put-metric-data' import type { AwsCloudwatchQueryLogsBody } from '@/lib/api/contracts/tools/aws/cloudwatch-query-logs' import type { AwsCloudwatchUnmuteAlarmBody } from '@/lib/api/contracts/tools/aws/cloudwatch-unmute-alarm' +import type { + CloudwatchLogGroupsBody, + CloudwatchLogStreamsBody, +} from '@/lib/api/contracts/tools/cloudwatch' import { createCloudWatchClient, createCloudWatchLogsClient, @@ -165,7 +165,7 @@ export async function executeCloudwatchDescribeAlarms( } export async function executeCloudwatchDescribeLogGroups( - input: CloudwatchLogGroupsSelectorBody, + input: CloudwatchLogGroupsBody, signal?: AbortSignal ) { const client = createCloudWatchLogsClient(input) @@ -220,7 +220,7 @@ export async function executeCloudwatchDescribeLogGroups( } export async function executeCloudwatchDescribeLogStreams( - input: CloudwatchLogStreamsSelectorBody, + input: CloudwatchLogStreamsBody, signal?: AbortSignal ) { const client = createCloudWatchLogsClient(input) diff --git a/apps/sim/lib/internal/confluence/execute-tool.ts b/apps/sim/lib/internal/confluence/execute-tool.ts index acc85ad3225..64c257aff4b 100644 --- a/apps/sim/lib/internal/confluence/execute-tool.ts +++ b/apps/sim/lib/internal/confluence/execute-tool.ts @@ -28,8 +28,8 @@ import { confluenceListSpacesContract, confluencePageAncestorsContract, confluencePageChildrenContract, + confluencePageContract, confluencePageDescendantsContract, - confluencePageSelectorContract, confluencePagesByLabelContract, confluencePageVersionsContract, confluenceSearchContract, @@ -46,7 +46,7 @@ import { confluenceUpdateSpaceContract, confluenceUploadAttachmentContract, confluenceUserContract, -} from '@/lib/api/contracts/selectors/confluence' +} from '@/lib/api/contracts/tools/confluence' import { ConfluenceOperationError } from '@/lib/internal/confluence/errors' import { type ConfluenceOperationContext, @@ -335,11 +335,7 @@ export const executeConfluenceTool: InternalToolOperationHandler = async (reques case 'confluence_list_spaces': return executeOperation(confluenceListSpacesContract, request, executeConfluenceListSpaces) case 'confluence_retrieve': - return executeOperation( - confluencePageSelectorContract, - request, - executeConfluenceRetrievePage - ) + return executeOperation(confluencePageContract, request, executeConfluenceRetrievePage) case 'confluence_search': return executeOperation(confluenceSearchContract, request, executeConfluenceSearch) case 'confluence_search_in_space': diff --git a/apps/sim/lib/internal/confluence/operations.ts b/apps/sim/lib/internal/confluence/operations.ts index e78940adccd..cb1cd4146a5 100644 --- a/apps/sim/lib/internal/confluence/operations.ts +++ b/apps/sim/lib/internal/confluence/operations.ts @@ -41,7 +41,7 @@ import type { ConfluenceUpdateSpaceBody, ConfluenceUploadAttachmentBody, ConfluenceUserBody, -} from '@/lib/api/contracts/selectors/confluence' +} from '@/lib/api/contracts/tools/confluence' import { validateAlphanumericId, validateNumericId, diff --git a/apps/sim/lib/internal/jsm/assets.ts b/apps/sim/lib/internal/jsm/assets.ts index 283cced6f08..d31fb4fb456 100644 --- a/apps/sim/lib/internal/jsm/assets.ts +++ b/apps/sim/lib/internal/jsm/assets.ts @@ -9,7 +9,7 @@ import type { jsmObjectTypeAttributesContract, jsmSearchObjectsAqlContract, jsmUpdateObjectContract, -} from '@/lib/api/contracts/selectors/jsm' +} from '@/lib/api/contracts/tools/jsm' import { asArray, createJsmAssetsClient } from '@/lib/internal/jsm/client' import { mapAssetObject } from '@/tools/jsm/utils' diff --git a/apps/sim/lib/internal/jsm/execute-tool.ts b/apps/sim/lib/internal/jsm/execute-tool.ts index 340bc738d61..41d1f957c60 100644 --- a/apps/sim/lib/internal/jsm/execute-tool.ts +++ b/apps/sim/lib/internal/jsm/execute-tool.ts @@ -39,7 +39,7 @@ import { jsmTransitionContract, jsmTransitionsContract, jsmUpdateObjectContract, -} from '@/lib/api/contracts/selectors/jsm' +} from '@/lib/api/contracts/tools/jsm' import { executeJsmCreateObject, executeJsmDeleteObject, diff --git a/apps/sim/lib/internal/jsm/forms.ts b/apps/sim/lib/internal/jsm/forms.ts index 9b7efd15d5c..5ef4c6324ee 100644 --- a/apps/sim/lib/internal/jsm/forms.ts +++ b/apps/sim/lib/internal/jsm/forms.ts @@ -12,7 +12,7 @@ import type { JsmReopenFormBody, JsmSaveFormAnswersBody, JsmSubmitFormBody, -} from '@/lib/api/contracts/selectors/jsm' +} from '@/lib/api/contracts/tools/jsm' import { validateJiraCloudId, validateJiraIssueKey } from '@/lib/core/security/input-validation' import { asArray, asObject, createJsmClient, nested } from '@/lib/internal/jsm/client' import { JsmOperationError } from '@/lib/internal/jsm/errors' diff --git a/apps/sim/lib/internal/jsm/service-desk.ts b/apps/sim/lib/internal/jsm/service-desk.ts index 4a6a158b718..1218e4436a4 100644 --- a/apps/sim/lib/internal/jsm/service-desk.ts +++ b/apps/sim/lib/internal/jsm/service-desk.ts @@ -15,7 +15,7 @@ import type { JsmSlaBody, JsmTransitionBody, JsmTransitionsBody, -} from '@/lib/api/contracts/selectors/jsm' +} from '@/lib/api/contracts/tools/jsm' import { validateAlphanumericId, validateEnum, diff --git a/apps/sim/lib/selectors/server/providers/harmonic.ts b/apps/sim/lib/selectors/server/providers/harmonic.ts index a6871bf4899..f55343682a0 100644 --- a/apps/sim/lib/selectors/server/providers/harmonic.ts +++ b/apps/sim/lib/selectors/server/providers/harmonic.ts @@ -1,8 +1,5 @@ import { isPlainRecord } from '@sim/utils/object' -import { - HARMONIC_SAVED_SEARCH_SELECTOR_MAX_OPTIONS, - harmonicPeopleSavedSearchProviderSchema, -} from '@/lib/api/contracts/selectors/harmonic' +import { z } from 'zod' import { readResponseJsonWithLimit } from '@/lib/core/utils/stream-limits' import type { ServerSelectorKey } from '@/lib/selectors/manifest' import { SelectorOptionsUnavailableError } from '@/lib/selectors/server/errors' @@ -17,10 +14,29 @@ import { type HarmonicSelectorKey = Extract const HARMONIC_URL = 'https://api.harmonic.ai/savedSearches' +const HARMONIC_SAVED_SEARCH_SELECTOR_MAX_OPTIONS = 500 const MAX_RESPONSE_BYTES = 1024 * 1024 const MAX_PROVIDER_ROWS = 2_000 const FETCH_TIMEOUT_MS = 10_000 +const harmonicSavedSearchUrnSchema = z + .string() + .trim() + .min(1) + .max(512) + .regex(/^urn:harmonic:saved_search:[^\s]+$/, 'Invalid Harmonic saved-search URN') +const harmonicSavedSearchNameSchema = z.string().trim().min(1).max(1_000) + +/** Validates the documented fields consumed from a PERSONS saved-search row. */ +const harmonicPeopleSavedSearchProviderSchema = z + .object({ + id: z.number().int().safe(), + entity_urn: harmonicSavedSearchUrnSchema, + name: harmonicSavedSearchNameSchema, + type: z.literal('PERSONS'), + }) + .passthrough() + interface SavedSearch { id: string urn: string diff --git a/apps/sim/tools/snowflake/selector-kinds.ts b/apps/sim/tools/snowflake/selector-kinds.ts index f1d632316ec..78c0df0c323 100644 --- a/apps/sim/tools/snowflake/selector-kinds.ts +++ b/apps/sim/tools/snowflake/selector-kinds.ts @@ -1,11 +1,10 @@ /** * Object kinds the editor's Snowflake pickers can enumerate. * - * A leaf module on purpose: the selector contract in - * `@/lib/api/contracts/selectors/snowflake` is imported by client code, and - * reading these from `@/tools/snowflake/sql` would pull the whole statement - * builder — and its transport dependencies — into the browser bundle for the - * sake of a seven-element array. + * A leaf module on purpose: selector metadata is shared with client code, and + * reading these values from `@/tools/snowflake/sql` would pull the whole + * statement builder — and its transport dependencies — into the browser bundle + * for the sake of a seven-element array. */ export const SNOWFLAKE_SELECTOR_KINDS = [ 'databases', diff --git a/scripts/check-api-contract-routes.ts b/scripts/check-api-contract-routes.ts index 7dd5e8b78dd..6ebfc193030 100644 --- a/scripts/check-api-contract-routes.ts +++ b/scripts/check-api-contract-routes.ts @@ -21,9 +21,9 @@ * behind a route. Scanning the source text instead would have to re-implement a * TypeScript lexer to know which braces are code and which sit inside a string, * template literal, regex or comment, and it could only ever see contracts whose - * `method`/`path` are inline literals — the 70-plus built through helpers like - * `definePostSelector(path, …)` would be invisible. Route files stay a static - * scan on purpose: importing one drags in `@sim/db`, auth and `next/server`, + * `method`/`path` are inline literals — helper-built contracts such as + * `defineJsmToolContract(path, …)` would be invisible. Route files stay a + * static scan on purpose: importing one drags in `@sim/db`, auth and `next/server`, * whereas contract modules are pure Zod. */ import { existsSync } from 'node:fs'