From 220da449c9cec6337eb7d8de48a2f52052b7d4f2 Mon Sep 17 00:00:00 2001 From: Waleed Date: Wed, 1 Jul 2026 22:50:26 -0700 Subject: [PATCH 01/28] fix(mothership): stop inlining full execution traces for the logs context (#5353) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(mothership): stop inlining full execution traces for the logs context Tagging a run via "Troubleshoot in Chat" (or any @-mention of a logs context) resolved through processExecutionLogFromDb, which materialized the ENTIRE execution trace (every block's input/output, nested tool-call spans) and inlined it directly into the prompt. For any non-trivial run this repeatedly blew the context window, forcing multiple compactions and eventually auto-stopping the agent before it could investigate anything. Every other context resolver in this file already avoids this by sending a lightweight pointer instead of a full inline dump (workflow/blocks/ workflow_block contexts point into the VFS). Logs contexts have no VFS materialization to point at, but the equivalent lightweight mechanism already exists as a tool: query_logs supports incremental disclosure (overview for timing/cost, full for a scoped block's input/output, or pattern to grep the trace) and is already registered for the mothership agent. Now processExecutionLogFromDb sends a compact summary (id, workflow, level, trigger, timing, cost) plus a note pointing the model at query_logs with the executionId, instead of materializing and embedding the trace. Also drops the now-unused executionData column from the select projection, so resolving a logs context no longer fetches a potentially large JSONB blob it never reads. * improvement(mothership): send a bounded block overview instead of a bare tool pointer Follow-up to the previous commit's fix (stop inlining full execution traces). A pure text pointer telling the model to call query_logs made the agent's very first useful action against a tagged run contingent on it noticing and correctly acting on prose in a JSON blob it may only skim — every sibling resolver in this file instead returns a deterministic mechanism (a VFS path) the model reads on demand. There's no VFS materialization for individual execution logs, but the same deterministic signal is available cheaply: toOverview() (the exact projection query_logs's own "overview" view already returns) walks the raw trace spans and produces a compact tree — block name/type/status/ timing/cost, no input or output — without touching large-value refs at all. The summary now includes that tree, so the model can see which block failed on the first turn, and the note narrows to what still requires a tool call: a block's actual input/output/error, or a grep. materializeExecutionData is still called, but it's a no-op for the common inline case (it only unwraps a top-level object-storage pointer for runs whose whole trace was offloaded as one blob) and was needed to reach traceSpans at all for those heavier runs — exactly the runs most worth an overview. A serialized-size cap (mirroring query-logs.ts's own truncation fallback, scaled down since this lands in the prompt unconditionally) drops the overview if a pathological span count pushes it over budget, falling back to the note alone. Extends the tests: the happy path now asserts the overview tree is present and that no raw input/output payload leaks into the serialized summary, plus a new test for the size-cap fallback. --- .../lib/copilot/chat/process-contents.test.ts | 189 ++++++++++++++++++ apps/sim/lib/copilot/chat/process-contents.ts | 46 ++++- 2 files changed, 227 insertions(+), 8 deletions(-) diff --git a/apps/sim/lib/copilot/chat/process-contents.test.ts b/apps/sim/lib/copilot/chat/process-contents.test.ts index 37aeaa2735c..e1d6ec21740 100644 --- a/apps/sim/lib/copilot/chat/process-contents.test.ts +++ b/apps/sim/lib/copilot/chat/process-contents.test.ts @@ -2,12 +2,18 @@ * @vitest-environment node */ +import { dbChainMock, dbChainMockFns, workflowAuthzMockFns } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { ChatContext } from '@/stores/panel' const { getSkillById } = vi.hoisted(() => ({ getSkillById: vi.fn() })) vi.mock('@/lib/workflows/skills/operations', () => ({ getSkillById })) +/** + * Overrides the global `@sim/db` mock: the logs-context tests below need + * controllable row data, which the stable `dbChainMockFns.limit` provides. + */ +vi.mock('@sim/db', () => dbChainMock) import { processContextsServer } from './process-contents' @@ -67,3 +73,186 @@ describe('processContextsServer - skill contexts', () => { expect(result).toEqual([]) }) }) + +describe('processContextsServer - logs contexts', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('resolves a tagged run to a compact summary with a block overview, never raw input/output', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([ + { + id: 'log-1', + workflowId: 'wf-1', + workspaceId: 'ws-1', + executionId: 'exec-1', + level: 'error', + trigger: 'manual', + startedAt: new Date('2026-01-01T00:00:00.000Z'), + endedAt: new Date('2026-01-01T00:00:01.000Z'), + totalDurationMs: 1000, + executionData: { + traceSpans: [ + { + id: 'span-1', + blockId: 'block-1', + name: 'Agent 1', + type: 'agent', + status: 'failed', + duration: 500, + input: { prompt: 'do the thing' }, + output: { error: '429 No active subscription' }, + }, + ], + }, + costTotal: '0.05', + workflowName: 'My Flow', + }, + ]) + workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValueOnce({ + allowed: true, + workflow: { workspaceId: 'ws-1' }, + }) + + const result = await processContextsServer( + [{ kind: 'logs', executionId: 'exec-1', label: 'My Flow' } as ChatContext], + 'user-1', + 'hello', + 'ws-1' + ) + + expect(result).toHaveLength(1) + expect(result[0].type).toBe('logs') + expect(result[0].tag).toBe('@My Flow') + + const summary = JSON.parse(result[0].content) + expect(summary).toMatchObject({ + executionId: 'exec-1', + workflowId: 'wf-1', + workflowName: 'My Flow', + level: 'error', + trigger: 'manual', + totalDurationMs: 1000, + cost: { total: 0.05 }, + overview: [ + { + id: 'span-1', + blockId: 'block-1', + name: 'Agent 1', + type: 'agent', + status: 'failed', + durationMs: 500, + }, + ], + }) + const serialized = JSON.stringify(summary) + expect(serialized).not.toContain('do the thing') + expect(serialized).not.toContain('429 No active subscription') + expect(summary.note).toContain('query_logs') + expect(summary.note).toContain('exec-1') + }) + + it('drops the overview (keeping the rest of the summary) when it exceeds the size cap', async () => { + const traceSpans = Array.from({ length: 2000 }, (_, i) => ({ + id: `span-${i}`, + blockId: `block-${i}`, + name: `Block ${i}`, + type: 'agent', + status: 'success', + duration: 10, + })) + dbChainMockFns.limit.mockResolvedValueOnce([ + { + id: 'log-1', + workflowId: 'wf-1', + workspaceId: 'ws-1', + executionId: 'exec-1', + level: 'error', + trigger: 'manual', + startedAt: new Date('2026-01-01T00:00:00.000Z'), + endedAt: null, + totalDurationMs: null, + executionData: { traceSpans }, + costTotal: null, + workflowName: 'My Flow', + }, + ]) + workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValueOnce({ + allowed: true, + workflow: { workspaceId: 'ws-1' }, + }) + + const result = await processContextsServer( + [{ kind: 'logs', executionId: 'exec-1', label: 'My Flow' } as ChatContext], + 'user-1', + 'hello', + 'ws-1' + ) + + const summary = JSON.parse(result[0].content) + expect(summary.overview).toBeUndefined() + expect(summary.executionId).toBe('exec-1') + expect(summary.note).toContain('query_logs') + }) + + it('drops a log context when the workflow is outside the current workspace', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([ + { + id: 'log-1', + workflowId: 'wf-1', + workspaceId: 'ws-other', + executionId: 'exec-1', + level: 'error', + trigger: 'manual', + startedAt: new Date('2026-01-01T00:00:00.000Z'), + endedAt: null, + totalDurationMs: null, + costTotal: null, + workflowName: 'My Flow', + }, + ]) + workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValueOnce({ + allowed: true, + workflow: { workspaceId: 'ws-other' }, + }) + + const result = await processContextsServer( + [{ kind: 'logs', executionId: 'exec-1', label: 'My Flow' } as ChatContext], + 'user-1', + 'hello', + 'ws-1' + ) + + expect(result).toEqual([]) + }) + + it('drops a log context the user is not authorized to read', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([ + { + id: 'log-1', + workflowId: 'wf-1', + workspaceId: 'ws-1', + executionId: 'exec-1', + level: 'error', + trigger: 'manual', + startedAt: new Date('2026-01-01T00:00:00.000Z'), + endedAt: null, + totalDurationMs: null, + costTotal: null, + workflowName: 'My Flow', + }, + ]) + workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValueOnce({ + allowed: false, + }) + + const result = await processContextsServer( + [{ kind: 'logs', executionId: 'exec-1', label: 'My Flow' } as ChatContext], + 'user-1', + 'hello', + 'ws-1' + ) + + expect(result).toEqual([]) + }) +}) diff --git a/apps/sim/lib/copilot/chat/process-contents.ts b/apps/sim/lib/copilot/chat/process-contents.ts index ef33580211c..a49ea07ccc0 100644 --- a/apps/sim/lib/copilot/chat/process-contents.ts +++ b/apps/sim/lib/copilot/chat/process-contents.ts @@ -6,6 +6,7 @@ import { getActiveWorkflowRecord, } from '@sim/platform-authz/workflow' import { and, eq, isNull, ne } from 'drizzle-orm' +import { QueryLogs } from '@/lib/copilot/generated/tool-catalog-v1' import { normalizeVfsSegment } from '@/lib/copilot/vfs/normalize-segment' import { buildVfsFolderPathMap, @@ -18,6 +19,8 @@ import { encodeVfsSegment, } from '@/lib/copilot/vfs/path-utils' import { getAllowedIntegrationsFromEnv } from '@/lib/core/config/env-flags' +import { toOverview } from '@/lib/logs/log-views' +import type { TraceSpan } from '@/lib/logs/types' import { getTableById } from '@/lib/table/service' import { getWorkspaceFileFolderPath } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' import { getWorkspaceFile } from '@/lib/uploads/contexts/workspace/workspace-file-manager' @@ -565,6 +568,31 @@ async function processWorkflowBlockFromDb( } } +/** + * Cap on the serialized summary (including the block overview tree) sent for + * a tagged run. `toOverview` already excludes every block's input/output, so + * this is a safety net against pathological span counts, not the primary + * defense — mirrors `MAX_FULL_RESULT_BYTES` in `query-logs.ts`, scaled down + * since this lands in the prompt unconditionally rather than behind an + * explicit tool call. + */ +const MAX_LOG_SUMMARY_BYTES = 64 * 1024 + +/** + * Resolve a tagged run to a compact summary instead of its full execution + * trace. A run's trace can carry every block's input/output plus nested + * tool-call spans, which is unbounded and would repeatedly blow the context + * window if inlined directly. The summary includes the block-level overview + * tree (name/type/status/timing/cost, no input or output — the same + * projection `query_logs`'s `overview` view returns) so the model can see + * which block failed without a round trip, and points it at `query_logs` for + * that block's actual input/output/error, or to grep the trace. + * + * `materializeExecutionData` only unwraps a top-level object-storage pointer, + * for runs whose whole trace was offloaded as one blob — a no-op for the + * common inline case. Individual span input/output stay as large-value refs; + * `toOverview` never resolves those. + */ async function processExecutionLogFromDb( executionId: string, userId: string | undefined, @@ -610,12 +638,14 @@ async function processExecutionLogFromDb( } } - // Heavy execution data may live in object storage; resolve the pointer. const { materializeExecutionData } = await import('@/lib/logs/execution/trace-store') const executionData = (await materializeExecutionData( log.executionData as Record | null, { workspaceId: log.workspaceId, workflowId: log.workflowId, executionId: log.executionId } - )) as any + )) as { traceSpans?: TraceSpan[] } | undefined + const overview = executionData?.traceSpans?.length + ? toOverview(executionData.traceSpans) + : undefined const summary = { id: log.id, @@ -627,13 +657,13 @@ async function processExecutionLogFromDb( endedAt: log.endedAt?.toISOString?.() || (log.endedAt ? String(log.endedAt) : null), totalDurationMs: log.totalDurationMs ?? null, workflowName: log.workflowName || '', - executionData: executionData - ? { - traceSpans: executionData.traceSpans || undefined, - errorDetails: executionData.errorDetails || undefined, - } - : undefined, cost: log.costTotal != null ? { total: Number(log.costTotal) } : undefined, + overview, + note: `For a block's input/output/error, or to grep the trace, call ${QueryLogs.id} with executionId: '${log.executionId}' — view: 'full' (scope with blockId or blockName), or pattern to grep.`, + } + + if (overview && JSON.stringify(summary).length > MAX_LOG_SUMMARY_BYTES) { + summary.overview = undefined } const content = JSON.stringify(summary) From 5553c449afaddd762883458a057c718df6bf52c2 Mon Sep 17 00:00:00 2001 From: Waleed Date: Thu, 2 Jul 2026 08:29:29 -0700 Subject: [PATCH 02/28] improvement(brex): fill validate-integration gaps against live API docs (#5362) - add wandConfig AI-autofill to expense/spend-limit filter subBlocks - surface is_ppro_enabled on get/list transfer tools - surface start_date/end_date/authorization_settings on list_spend_limits to match get_spend_limit --- apps/sim/blocks/blocks/brex.ts | 24 ++++++++++++++++++++++++ apps/sim/tools/brex/get_transfer.ts | 6 ++++++ apps/sim/tools/brex/list_spend_limits.ts | 8 ++++++++ apps/sim/tools/brex/list_transfers.ts | 5 +++++ apps/sim/tools/brex/types.ts | 5 +++++ 5 files changed, 48 insertions(+) diff --git a/apps/sim/blocks/blocks/brex.ts b/apps/sim/blocks/blocks/brex.ts index b8efbc74d70..ce7cd932563 100644 --- a/apps/sim/blocks/blocks/brex.ts +++ b/apps/sim/blocks/blocks/brex.ts @@ -215,6 +215,12 @@ export const BrexBlock: BlockConfig = { placeholder: 'Comma-separated user IDs to filter by', mode: 'advanced', condition: { field: 'operation', value: ['list_expenses', 'list_card_transactions'] }, + wandConfig: { + enabled: true, + prompt: + 'Generate a comma-separated list of Brex user IDs to filter by based on the description.\n\nReturn ONLY the comma-separated user IDs - no explanations, no extra text.', + placeholder: 'Describe which users to include...', + }, }, { id: 'statuses', @@ -223,6 +229,12 @@ export const BrexBlock: BlockConfig = { placeholder: 'e.g., APPROVED, SETTLED (comma-separated)', mode: 'advanced', condition: { field: 'operation', value: 'list_expenses' }, + wandConfig: { + enabled: true, + prompt: + 'Generate a comma-separated list of Brex expense statuses to filter by.\n\nValid statuses: DRAFT, SUBMITTED, APPROVED, OUT_OF_POLICY, VOID, CANCELED, SPLIT, SETTLED\n\nExamples:\n- "only settled expenses" -> SETTLED\n- "approved or settled" -> APPROVED,SETTLED\n- "expenses awaiting review" -> DRAFT,SUBMITTED\n\nReturn ONLY the comma-separated status values - no explanations, no extra text.', + placeholder: 'Describe which expense statuses to include...', + }, }, { id: 'paymentStatuses', @@ -231,6 +243,12 @@ export const BrexBlock: BlockConfig = { placeholder: 'e.g., CLEARED, REFUNDED (comma-separated)', mode: 'advanced', condition: { field: 'operation', value: 'list_expenses' }, + wandConfig: { + enabled: true, + prompt: + 'Generate a comma-separated list of Brex expense payment statuses to filter by.\n\nValid statuses: NOT_STARTED, PROCESSING, CANCELED, DECLINED, CLEARED, REFUNDING, REFUNDED, CASH_ADVANCE, CREDITED, AWAITING_PAYMENT, SCHEDULED\n\nExamples:\n- "only cleared payments" -> CLEARED\n- "refunded or refunding" -> REFUNDED,REFUNDING\n\nReturn ONLY the comma-separated status values - no explanations, no extra text.', + placeholder: 'Describe which payment statuses to include...', + }, }, { id: 'purchasedAtStart', @@ -284,6 +302,12 @@ export const BrexBlock: BlockConfig = { placeholder: 'Comma-separated user IDs to filter spend limits by member', mode: 'advanced', condition: { field: 'operation', value: 'list_spend_limits' }, + wandConfig: { + enabled: true, + prompt: + 'Generate a comma-separated list of Brex user IDs to filter spend limits by member based on the description.\n\nReturn ONLY the comma-separated user IDs - no explanations, no extra text.', + placeholder: 'Describe which spend limit members to include...', + }, }, { id: 'cursor', diff --git a/apps/sim/tools/brex/get_transfer.ts b/apps/sim/tools/brex/get_transfer.ts index dcbd9f0233c..3312f9088d2 100644 --- a/apps/sim/tools/brex/get_transfer.ts +++ b/apps/sim/tools/brex/get_transfer.ts @@ -50,6 +50,7 @@ export const brexGetTransferTool: ToolConfig | null } export interface BrexVendor { @@ -171,6 +174,7 @@ export interface BrexTransfer { created_at: string | null display_name: string | null external_memo: string | null + is_ppro_enabled: boolean | null } export interface BrexCard { @@ -551,6 +555,7 @@ export interface BrexGetTransferResponse extends ToolResponse { createdAt: string | null displayName: string | null externalMemo: string | null + isPproEnabled: boolean | null } } From 7fd89bca35ab76a0684bd2272f8124550b1fedc4 Mon Sep 17 00:00:00 2001 From: Waleed Date: Thu, 2 Jul 2026 09:06:12 -0700 Subject: [PATCH 03/28] fix(brex): surface isPproEnabled in transfer block outputs (#5373) Tool-level get_transfer/list_transfers already returned is_ppro_enabled (confirmed present on Brex's live Transfer schema), but the block's outputs map didn't declare it, so it was unreachable from the workflow UI. Adds it alongside the other transfer output fields. --- apps/sim/blocks/blocks/brex.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/sim/blocks/blocks/brex.ts b/apps/sim/blocks/blocks/brex.ts index ce7cd932563..6a5feefbb83 100644 --- a/apps/sim/blocks/blocks/brex.ts +++ b/apps/sim/blocks/blocks/brex.ts @@ -591,6 +591,10 @@ export const BrexBlock: BlockConfig = { createdAt: { type: 'string', description: 'Creation timestamp of the transfer' }, displayName: { type: 'string', description: 'Display name of the transfer' }, externalMemo: { type: 'string', description: 'External memo of the transfer' }, + isPproEnabled: { + type: 'boolean', + description: 'Whether Principal Protection (PPRO) is enabled for the transfer', + }, }, } From dabb856b9a854e1d365189a0800935e853fa95bf Mon Sep 17 00:00:00 2001 From: Waleed Date: Thu, 2 Jul 2026 09:48:20 -0700 Subject: [PATCH 04/28] fix(loops): align integration with live API docs, add suppression + get-template tools (#5358) * fix(loops): align integration with live API docs, add suppression + get-template tools - fix list_transactional_emails endpoint URL (was /transactional, now /transactional-emails) - fix response fields to match actual API schema (createdAt/updatedAt, not the never-existent lastUpdated) - add loops_check_contact_suppression, loops_remove_contact_suppression, loops_get_transactional_email tools - wire new tools into block operations, outputs, and registries - alphabetize tools/registry.ts loops entries * fix(loops): expose contactId output, fix stale tool description - add missing contactId block output for check_contact_suppression (Greptile P1) - fix list_transactional_emails description to mention createdAt (Greptile P2) * fix(loops): restore lastUpdated as backwards-compat alias on list_transactional_emails Final validation pass found that /api/v1/transactional (the endpoint this tool used before this PR) is a real, functional, deprecated Loops endpoint whose schema genuinely returns lastUpdated - it was not a broken/invented field. Migrating to /api/v1/transactional-emails is still correct (current endpoint, better error semantics), but dropping lastUpdated would break any existing workflow reading it from this block's output. Keep it as a deprecated alias of updatedAt alongside the new createdAt/updatedAt fields. * fix(loops): update id output description to cover get_transactional_email --- apps/sim/blocks/blocks/loops.ts | 102 +++++++++++++++-- .../tools/loops/check_contact_suppression.ts | 104 +++++++++++++++++ .../tools/loops/get_transactional_email.ts | 106 ++++++++++++++++++ apps/sim/tools/loops/index.ts | 3 + .../tools/loops/list_transactional_emails.ts | 17 ++- .../tools/loops/remove_contact_suppression.ts | 99 ++++++++++++++++ apps/sim/tools/loops/types.ts | 53 +++++++++ apps/sim/tools/registry.ts | 12 +- 8 files changed, 482 insertions(+), 14 deletions(-) create mode 100644 apps/sim/tools/loops/check_contact_suppression.ts create mode 100644 apps/sim/tools/loops/get_transactional_email.ts create mode 100644 apps/sim/tools/loops/remove_contact_suppression.ts diff --git a/apps/sim/blocks/blocks/loops.ts b/apps/sim/blocks/blocks/loops.ts index 76e27e48cb0..c71ce37e1d4 100644 --- a/apps/sim/blocks/blocks/loops.ts +++ b/apps/sim/blocks/blocks/loops.ts @@ -32,6 +32,9 @@ export const LoopsBlock: BlockConfig = { { label: 'List Transactional Emails', id: 'list_transactional_emails' }, { label: 'Create Contact Property', id: 'create_contact_property' }, { label: 'List Contact Properties', id: 'list_contact_properties' }, + { label: 'Check Contact Suppression', id: 'check_contact_suppression' }, + { label: 'Remove Contact Suppression', id: 'remove_contact_suppression' }, + { label: 'Get Transactional Email', id: 'get_transactional_email' }, ], value: () => 'create_contact', }, @@ -47,7 +50,7 @@ export const LoopsBlock: BlockConfig = { value: ['create_contact', 'send_transactional_email'], }, }, - // Optional email for update, find, delete, send event + // Optional email for update, find, delete, send event, suppression lookups { id: 'contactEmail', title: 'Email', @@ -55,7 +58,14 @@ export const LoopsBlock: BlockConfig = { placeholder: 'Enter email address', condition: { field: 'operation', - value: ['update_contact', 'find_contact', 'delete_contact', 'send_event'], + value: [ + 'update_contact', + 'find_contact', + 'delete_contact', + 'send_event', + 'check_contact_suppression', + 'remove_contact_suppression', + ], }, }, // User ID for operations that support it @@ -66,7 +76,14 @@ export const LoopsBlock: BlockConfig = { placeholder: 'Enter user ID', condition: { field: 'operation', - value: ['update_contact', 'find_contact', 'delete_contact', 'send_event'], + value: [ + 'update_contact', + 'find_contact', + 'delete_contact', + 'send_event', + 'check_contact_suppression', + 'remove_contact_suppression', + ], }, }, // Contact fields @@ -199,10 +216,13 @@ Return ONLY the JSON object - no explanations, no extra text.`, title: 'Transactional Email ID', type: 'short-input', placeholder: 'Enter template ID (e.g., clx...)', - required: { field: 'operation', value: 'send_transactional_email' }, + required: { + field: 'operation', + value: ['send_transactional_email', 'get_transactional_email'], + }, condition: { field: 'operation', - value: 'send_transactional_email', + value: ['send_transactional_email', 'get_transactional_email'], }, }, { @@ -426,6 +446,9 @@ Return ONLY the JSON object - no explanations, no extra text.`, 'loops_list_transactional_emails', 'loops_create_contact_property', 'loops_list_contact_properties', + 'loops_check_contact_suppression', + 'loops_remove_contact_suppression', + 'loops_get_transactional_email', ], config: { tool: (params) => `loops_${params.operation}`, @@ -497,6 +520,16 @@ Return ONLY the JSON object - no explanations, no extra text.`, case 'list_contact_properties': if (params.propertyFilter) result.list = params.propertyFilter break + + case 'check_contact_suppression': + case 'remove_contact_suppression': + if (params.contactEmail) result.email = params.contactEmail + if (params.userId) result.userId = params.userId + break + + case 'get_transactional_email': + result.transactionalId = params.transactionalId + break } return result @@ -531,7 +564,10 @@ Return ONLY the JSON object - no explanations, no extra text.`, }, outputs: { success: { type: 'boolean', description: 'Whether the operation succeeded' }, - id: { type: 'string', description: 'Contact ID (create/update operations)' }, + id: { + type: 'string', + description: 'Contact ID (create/update operations) or template ID (get transactional email)', + }, contacts: { type: 'json', description: @@ -544,7 +580,8 @@ Return ONLY the JSON object - no explanations, no extra text.`, }, transactionalEmails: { type: 'json', - description: 'Array of transactional email templates (id, name, lastUpdated, dataVariables)', + description: + 'Array of transactional email templates (id, name, createdAt, updatedAt, lastUpdated (deprecated alias of updatedAt), dataVariables)', }, pagination: { type: 'json', @@ -555,6 +592,50 @@ Return ONLY the JSON object - no explanations, no extra text.`, type: 'json', description: 'Array of contact properties (key, label, type)', }, + isSuppressed: { + type: 'boolean', + description: 'Whether the contact is on the suppression list (check suppression)', + }, + contactId: { + type: 'string', + description: 'The Loops-assigned contact ID (check suppression)', + }, + removalQuotaLimit: { + type: 'number', + description: 'Total suppression-removal quota for the team', + }, + removalQuotaRemaining: { + type: 'number', + description: 'Remaining suppression-removal quota for the team', + }, + name: { + type: 'string', + description: 'Transactional email template name (get transactional email)', + }, + draftEmailMessageId: { + type: 'string', + description: 'ID of the draft email message, if any (get transactional email)', + }, + publishedEmailMessageId: { + type: 'string', + description: 'ID of the published email message, if any (get transactional email)', + }, + transactionalGroupId: { + type: 'string', + description: 'ID of the transactional group, if any (get transactional email)', + }, + createdAt: { + type: 'string', + description: 'Creation timestamp (get transactional email)', + }, + updatedAt: { + type: 'string', + description: 'Last updated timestamp (get transactional email)', + }, + dataVariables: { + type: 'json', + description: 'Template data variable names (get transactional email)', + }, }, } @@ -655,5 +736,12 @@ export const LoopsBlockMeta = { content: '# Send Transactional Email\n\nDeliver a templated transactional email through Loops.\n\n## Steps\n1. Confirm the transactional email template ID to use.\n2. Build the data variables JSON to match the variable names in the template, such as name and a confirmation URL.\n3. Send Transactional Email with the recipient email, template ID, and data variables, attaching files if needed.\n\n## Output\nConfirmation of send success and the template ID and recipient used.', }, + { + name: 'manage-suppression-compliance', + description: + 'Check and clear Loops suppression status for a contact to keep deliverability and unsubscribe compliance in check.', + content: + '# Manage Suppression Compliance\n\nKeep Loops sending compliant and deliverable.\n\n## Steps\n1. Check Contact Suppression by email or user ID to see if the contact bounced, complained, or unsubscribed.\n2. If the contact should be re-enabled (e.g. a confirmed re-opt-in), Remove Contact Suppression for the same identifier, noting the remaining removal quota.\n3. Log the result so support and compliance workflows have an audit trail.\n\n## Output\nThe suppression status before and after the change, plus the remaining removal quota.', + }, ], } as const satisfies BlockMeta diff --git a/apps/sim/tools/loops/check_contact_suppression.ts b/apps/sim/tools/loops/check_contact_suppression.ts new file mode 100644 index 00000000000..bcc8aa922e0 --- /dev/null +++ b/apps/sim/tools/loops/check_contact_suppression.ts @@ -0,0 +1,104 @@ +import type { + LoopsCheckContactSuppressionParams, + LoopsCheckContactSuppressionResponse, +} from '@/tools/loops/types' +import type { ToolConfig } from '@/tools/types' + +export const loopsCheckContactSuppressionTool: ToolConfig< + LoopsCheckContactSuppressionParams, + LoopsCheckContactSuppressionResponse +> = { + id: 'loops_check_contact_suppression', + name: 'Loops Check Contact Suppression', + description: + 'Check whether a Loops contact is on the suppression list (bounced, complained, or unsubscribed) by email address or userId.', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Loops API key for authentication', + }, + email: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'The contact email address to check (at least one of email or userId is required)', + }, + userId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'The contact userId to check (at least one of email or userId is required)', + }, + }, + + request: { + url: (params) => { + if (!params.email && !params.userId) { + throw new Error('At least one of email or userId is required to check suppression status') + } + const base = 'https://app.loops.so/api/v1/contacts/suppression' + if (params.email) return `${base}?email=${encodeURIComponent(params.email.trim())}` + return `${base}?userId=${encodeURIComponent(params.userId!.trim())}` + }, + method: 'GET', + headers: (params) => ({ + Authorization: `Bearer ${params.apiKey}`, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (data.isSuppressed == null) { + return { + success: false, + output: { + contactId: null, + email: null, + userId: null, + isSuppressed: false, + removalQuotaLimit: null, + removalQuotaRemaining: null, + }, + error: data.message ?? 'Failed to check contact suppression status', + } + } + + return { + success: true, + output: { + contactId: (data.contact?.id as string) ?? null, + email: (data.contact?.email as string) ?? null, + userId: (data.contact?.userId as string) ?? null, + isSuppressed: (data.isSuppressed as boolean) ?? false, + removalQuotaLimit: (data.removalQuota?.limit as number) ?? null, + removalQuotaRemaining: (data.removalQuota?.remaining as number) ?? null, + }, + } + }, + + outputs: { + contactId: { type: 'string', description: 'The Loops-assigned contact ID', optional: true }, + email: { type: 'string', description: 'The contact email address', optional: true }, + userId: { type: 'string', description: 'The contact userId', optional: true }, + isSuppressed: { + type: 'boolean', + description: 'Whether the contact is on the suppression list', + }, + removalQuotaLimit: { + type: 'number', + description: 'Total suppression-removal quota for the team', + optional: true, + }, + removalQuotaRemaining: { + type: 'number', + description: 'Remaining suppression-removal quota for the team', + optional: true, + }, + }, +} diff --git a/apps/sim/tools/loops/get_transactional_email.ts b/apps/sim/tools/loops/get_transactional_email.ts new file mode 100644 index 00000000000..55fd75d6f16 --- /dev/null +++ b/apps/sim/tools/loops/get_transactional_email.ts @@ -0,0 +1,106 @@ +import type { + LoopsGetTransactionalEmailParams, + LoopsGetTransactionalEmailResponse, +} from '@/tools/loops/types' +import type { ToolConfig } from '@/tools/types' + +export const loopsGetTransactionalEmailTool: ToolConfig< + LoopsGetTransactionalEmailParams, + LoopsGetTransactionalEmailResponse +> = { + id: 'loops_get_transactional_email', + name: 'Loops Get Transactional Email', + description: + 'Retrieve a single transactional email template from your Loops account by its ID, including its data variables and draft/published message IDs.', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Loops API key for authentication', + }, + transactionalId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The ID of the transactional email template to retrieve', + }, + }, + + request: { + url: (params) => + `https://app.loops.so/api/v1/transactional-emails/${encodeURIComponent(params.transactionalId.trim())}`, + method: 'GET', + headers: (params) => ({ + Authorization: `Bearer ${params.apiKey}`, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!data.id) { + return { + success: false, + output: { + id: null, + name: null, + draftEmailMessageId: null, + publishedEmailMessageId: null, + transactionalGroupId: null, + createdAt: null, + updatedAt: null, + dataVariables: [], + }, + error: data.message ?? 'Failed to get transactional email', + } + } + + return { + success: true, + output: { + id: (data.id as string) ?? null, + name: (data.name as string) ?? null, + draftEmailMessageId: (data.draftEmailMessageId as string) ?? null, + publishedEmailMessageId: (data.publishedEmailMessageId as string) ?? null, + transactionalGroupId: (data.transactionalGroupId as string) ?? null, + createdAt: (data.createdAt as string) ?? null, + updatedAt: (data.updatedAt as string) ?? null, + dataVariables: (data.dataVariables as string[]) ?? [], + }, + } + }, + + outputs: { + id: { type: 'string', description: 'The transactional email template ID', optional: true }, + name: { type: 'string', description: 'The template name', optional: true }, + draftEmailMessageId: { + type: 'string', + description: 'ID of the draft email message, if any', + optional: true, + }, + publishedEmailMessageId: { + type: 'string', + description: 'ID of the published email message, if any', + optional: true, + }, + transactionalGroupId: { + type: 'string', + description: 'ID of the transactional group this template belongs to, if any', + optional: true, + }, + createdAt: { type: 'string', description: 'Creation timestamp (ISO 8601)', optional: true }, + updatedAt: { + type: 'string', + description: 'Last updated timestamp (ISO 8601)', + optional: true, + }, + dataVariables: { + type: 'array', + description: 'Template data variable names', + items: { type: 'string' }, + }, + }, +} diff --git a/apps/sim/tools/loops/index.ts b/apps/sim/tools/loops/index.ts index bad17376cf5..baa5480634c 100644 --- a/apps/sim/tools/loops/index.ts +++ b/apps/sim/tools/loops/index.ts @@ -1,10 +1,13 @@ +export { loopsCheckContactSuppressionTool } from '@/tools/loops/check_contact_suppression' export { loopsCreateContactTool } from '@/tools/loops/create_contact' export { loopsCreateContactPropertyTool } from '@/tools/loops/create_contact_property' export { loopsDeleteContactTool } from '@/tools/loops/delete_contact' export { loopsFindContactTool } from '@/tools/loops/find_contact' +export { loopsGetTransactionalEmailTool } from '@/tools/loops/get_transactional_email' export { loopsListContactPropertiesTool } from '@/tools/loops/list_contact_properties' export { loopsListMailingListsTool } from '@/tools/loops/list_mailing_lists' export { loopsListTransactionalEmailsTool } from '@/tools/loops/list_transactional_emails' +export { loopsRemoveContactSuppressionTool } from '@/tools/loops/remove_contact_suppression' export { loopsSendEventTool } from '@/tools/loops/send_event' export { loopsSendTransactionalEmailTool } from '@/tools/loops/send_transactional_email' export { loopsUpdateContactTool } from '@/tools/loops/update_contact' diff --git a/apps/sim/tools/loops/list_transactional_emails.ts b/apps/sim/tools/loops/list_transactional_emails.ts index bd36d373222..30703e87a31 100644 --- a/apps/sim/tools/loops/list_transactional_emails.ts +++ b/apps/sim/tools/loops/list_transactional_emails.ts @@ -11,7 +11,7 @@ export const loopsListTransactionalEmailsTool: ToolConfig< id: 'loops_list_transactional_emails', name: 'Loops List Transactional Emails', description: - 'Retrieve a list of published transactional email templates from your Loops account. Returns each template with its ID, name, last updated timestamp, and data variables.', + 'Retrieve a list of published transactional email templates from your Loops account. Returns each template with its ID, name, created/updated timestamps, and data variables.', version: '1.0.0', params: { @@ -37,7 +37,7 @@ export const loopsListTransactionalEmailsTool: ToolConfig< request: { url: (params) => { - const base = 'https://app.loops.so/api/v1/transactional' + const base = 'https://app.loops.so/api/v1/transactional-emails' const queryParams: string[] = [] if (params.perPage) queryParams.push(`perPage=${encodeURIComponent(params.perPage)}`) if (params.cursor) queryParams.push(`cursor=${encodeURIComponent(params.cursor)}`) @@ -78,7 +78,11 @@ export const loopsListTransactionalEmailsTool: ToolConfig< transactionalEmails: emails.map((email: Record) => ({ id: (email.id as string) ?? '', name: (email.name as string) ?? '', - lastUpdated: (email.lastUpdated as string) ?? '', + createdAt: (email.createdAt as string) ?? '', + updatedAt: (email.updatedAt as string) ?? '', + // Deprecated alias of updatedAt, kept for backwards compatibility with the old + // (now-removed) /api/v1/transactional list endpoint, which returned this field. + lastUpdated: (email.updatedAt as string) ?? '', dataVariables: (email.dataVariables as string[]) ?? [], })), pagination: { @@ -102,7 +106,12 @@ export const loopsListTransactionalEmailsTool: ToolConfig< properties: { id: { type: 'string', description: 'The transactional email template ID' }, name: { type: 'string', description: 'The template name' }, - lastUpdated: { type: 'string', description: 'Last updated timestamp' }, + createdAt: { type: 'string', description: 'Creation timestamp (ISO 8601)' }, + updatedAt: { type: 'string', description: 'Last updated timestamp (ISO 8601)' }, + lastUpdated: { + type: 'string', + description: 'Deprecated alias of updatedAt, kept for backwards compatibility', + }, dataVariables: { type: 'array', description: 'Template data variable names', diff --git a/apps/sim/tools/loops/remove_contact_suppression.ts b/apps/sim/tools/loops/remove_contact_suppression.ts new file mode 100644 index 00000000000..0198d3b13f7 --- /dev/null +++ b/apps/sim/tools/loops/remove_contact_suppression.ts @@ -0,0 +1,99 @@ +import type { + LoopsRemoveContactSuppressionParams, + LoopsRemoveContactSuppressionResponse, +} from '@/tools/loops/types' +import type { ToolConfig } from '@/tools/types' + +export const loopsRemoveContactSuppressionTool: ToolConfig< + LoopsRemoveContactSuppressionParams, + LoopsRemoveContactSuppressionResponse +> = { + id: 'loops_remove_contact_suppression', + name: 'Loops Remove Contact Suppression', + description: + 'Remove a Loops contact from the suppression list by email address or userId, allowing them to receive emails again. Subject to a team removal quota.', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Loops API key for authentication', + }, + email: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'The contact email address to remove from suppression (at least one of email or userId is required)', + }, + userId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'The contact userId to remove from suppression (at least one of email or userId is required)', + }, + }, + + request: { + url: (params) => { + if (!params.email && !params.userId) { + throw new Error('At least one of email or userId is required to remove suppression') + } + const base = 'https://app.loops.so/api/v1/contacts/suppression' + if (params.email) return `${base}?email=${encodeURIComponent(params.email.trim())}` + return `${base}?userId=${encodeURIComponent(params.userId!.trim())}` + }, + method: 'DELETE', + headers: (params) => ({ + Authorization: `Bearer ${params.apiKey}`, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!data.success) { + return { + success: false, + output: { + success: false, + message: data.message ?? 'Failed to remove contact suppression', + removalQuotaLimit: (data.removalQuota?.limit as number) ?? null, + removalQuotaRemaining: (data.removalQuota?.remaining as number) ?? null, + }, + error: data.message ?? 'Failed to remove contact suppression', + } + } + + return { + success: true, + output: { + success: true, + message: data.message ?? 'Contact removed from suppression list.', + removalQuotaLimit: (data.removalQuota?.limit as number) ?? null, + removalQuotaRemaining: (data.removalQuota?.remaining as number) ?? null, + }, + } + }, + + outputs: { + success: { + type: 'boolean', + description: 'Whether the contact was removed from suppression successfully', + }, + message: { type: 'string', description: 'Status message from the API', optional: true }, + removalQuotaLimit: { + type: 'number', + description: 'Total suppression-removal quota for the team', + optional: true, + }, + removalQuotaRemaining: { + type: 'number', + description: 'Remaining suppression-removal quota for the team', + optional: true, + }, + }, +} diff --git a/apps/sim/tools/loops/types.ts b/apps/sim/tools/loops/types.ts index da3f81d4c91..79b0e3626b6 100644 --- a/apps/sim/tools/loops/types.ts +++ b/apps/sim/tools/loops/types.ts @@ -70,6 +70,20 @@ export interface LoopsListContactPropertiesParams extends LoopsBaseParams { list?: string } +export interface LoopsCheckContactSuppressionParams extends LoopsBaseParams { + email?: string + userId?: string +} + +export interface LoopsRemoveContactSuppressionParams extends LoopsBaseParams { + email?: string + userId?: string +} + +export interface LoopsGetTransactionalEmailParams extends LoopsBaseParams { + transactionalId: string +} + interface LoopsContact { id: string email: string @@ -138,6 +152,9 @@ export interface LoopsListTransactionalEmailsResponse extends ToolResponse { transactionalEmails: { id: string name: string + createdAt: string + updatedAt: string + /** @deprecated Alias of updatedAt, kept for backwards compatibility */ lastUpdated: string dataVariables: string[] }[] @@ -168,6 +185,39 @@ export interface LoopsListContactPropertiesResponse extends ToolResponse { } } +export interface LoopsCheckContactSuppressionResponse extends ToolResponse { + output: { + contactId: string | null + email: string | null + userId: string | null + isSuppressed: boolean + removalQuotaLimit: number | null + removalQuotaRemaining: number | null + } +} + +export interface LoopsRemoveContactSuppressionResponse extends ToolResponse { + output: { + success: boolean + message: string | null + removalQuotaLimit: number | null + removalQuotaRemaining: number | null + } +} + +export interface LoopsGetTransactionalEmailResponse extends ToolResponse { + output: { + id: string | null + name: string | null + draftEmailMessageId: string | null + publishedEmailMessageId: string | null + transactionalGroupId: string | null + createdAt: string | null + updatedAt: string | null + dataVariables: string[] + } +} + export type LoopsResponse = | LoopsCreateContactResponse | LoopsUpdateContactResponse @@ -179,6 +229,9 @@ export type LoopsResponse = | LoopsListTransactionalEmailsResponse | LoopsCreateContactPropertyResponse | LoopsListContactPropertiesResponse + | LoopsCheckContactSuppressionResponse + | LoopsRemoveContactSuppressionResponse + | LoopsGetTransactionalEmailResponse export const LOOPS_CONTACT_OUTPUT_PROPERTIES = { id: { type: 'string' as const, description: 'Loops-assigned contact ID' }, diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index 2bb910c4eef..1e8114b93bc 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -2065,13 +2065,16 @@ import { logsQueryTool, } from '@/tools/logs' import { + loopsCheckContactSuppressionTool, loopsCreateContactPropertyTool, loopsCreateContactTool, loopsDeleteContactTool, loopsFindContactTool, + loopsGetTransactionalEmailTool, loopsListContactPropertiesTool, loopsListMailingListsTool, loopsListTransactionalEmailsTool, + loopsRemoveContactSuppressionTool, loopsSendEventTool, loopsSendTransactionalEmailTool, loopsUpdateContactTool, @@ -4633,16 +4636,19 @@ export const tools: Record = { logs_get: logsGetTool, logs_get_execution: logsGetExecutionTool, logs_get_run_details: logsGetRunDetailsTool, + loops_check_contact_suppression: loopsCheckContactSuppressionTool, loops_create_contact: loopsCreateContactTool, loops_create_contact_property: loopsCreateContactPropertyTool, - loops_update_contact: loopsUpdateContactTool, - loops_find_contact: loopsFindContactTool, loops_delete_contact: loopsDeleteContactTool, + loops_find_contact: loopsFindContactTool, + loops_get_transactional_email: loopsGetTransactionalEmailTool, loops_list_contact_properties: loopsListContactPropertiesTool, loops_list_mailing_lists: loopsListMailingListsTool, loops_list_transactional_emails: loopsListTransactionalEmailsTool, - loops_send_transactional_email: loopsSendTransactionalEmailTool, + loops_remove_contact_suppression: loopsRemoveContactSuppressionTool, loops_send_event: loopsSendEventTool, + loops_send_transactional_email: loopsSendTransactionalEmailTool, + loops_update_contact: loopsUpdateContactTool, luma_add_guests: lumaAddGuestsTool, luma_cancel_event: lumaCancelEventTool, luma_create_event: lumaCreateEventTool, From 02b1de4faf32d83d1ea208232eb41aef8f6a4db5 Mon Sep 17 00:00:00 2001 From: Waleed Date: Thu, 2 Jul 2026 09:49:24 -0700 Subject: [PATCH 05/28] fix(ahrefs): align tool coverage and outputs with the Ahrefs API v3 (#5367) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(ahrefs): align tool coverage and outputs with the Ahrefs API v3 - 5 of 8 tools were missing the required `select` param (API v3 rejects list-endpoint requests without it) and 3 sent a `date` param the endpoint doesn't accept - all list endpoints sent an `offset` param that doesn't exist on any Ahrefs v3 site-explorer endpoint (only `limit` is supported) - domain_rating and backlinks_stats read response fields at the wrong nesting level and always returned zeros; several other tools mapped output fields to column names the API doesn't return (position, url, traffic, backlinks, dofollow_backlinks, http_code) instead of the real ones (best_position, best_position_url, sum_traffic, links_to_target, dofollow_links, http_code_target) - keyword_overview used the wrong endpoint param entirely (`keyword` instead of `keywords`) so it always returned empty - added ahrefs_metrics (site-explorer/metrics) and ahrefs_organic_competitors (site-explorer/organic-competitors) tools - fixed docsLink to point at docs.sim.ai instead of ahrefs.com * fix(ahrefs): default metrics country to us like every other tool Greptile and Cursor Bugbot both flagged that ahrefs_metrics omitted the country when unset, unlike every other country-accepting Ahrefs tool, which silently returns global data instead of US data on direct tool calls. * fix(ahrefs): default history to all_time on backlinks and referring domains Cursor Bugbot flagged that history was only sent when explicitly set, even though the block UI defaults it to all_time — same class of gap as the metrics.ts country fix, applied for direct tool-call consistency. * feat(ahrefs): expose search intent flags on keyword overview Adds the real intents field (informational, navigational, commercial, transactional, branded, local) from keywords-explorer/overview, which was verified valid against the live API docs but not yet surfaced. --- apps/sim/blocks/blocks/ahrefs.ts | 474 +++++++++---------- apps/sim/tools/ahrefs/backlinks.ts | 32 +- apps/sim/tools/ahrefs/backlinks_stats.ts | 39 +- apps/sim/tools/ahrefs/broken_backlinks.ts | 35 +- apps/sim/tools/ahrefs/domain_rating.ts | 5 +- apps/sim/tools/ahrefs/index.ts | 6 + apps/sim/tools/ahrefs/keyword_overview.ts | 54 ++- apps/sim/tools/ahrefs/metrics.ts | 116 +++++ apps/sim/tools/ahrefs/organic_competitors.ts | 138 ++++++ apps/sim/tools/ahrefs/organic_keywords.ts | 40 +- apps/sim/tools/ahrefs/referring_domains.ts | 51 +- apps/sim/tools/ahrefs/top_pages.ts | 53 +-- apps/sim/tools/ahrefs/types.ts | 124 +++-- apps/sim/tools/registry.ts | 12 +- 14 files changed, 741 insertions(+), 438 deletions(-) create mode 100644 apps/sim/tools/ahrefs/metrics.ts create mode 100644 apps/sim/tools/ahrefs/organic_competitors.ts diff --git a/apps/sim/blocks/blocks/ahrefs.ts b/apps/sim/blocks/blocks/ahrefs.ts index 80e56aa7d6b..cd5b5e22347 100644 --- a/apps/sim/blocks/blocks/ahrefs.ts +++ b/apps/sim/blocks/blocks/ahrefs.ts @@ -3,6 +3,45 @@ import type { BlockConfig, BlockMeta } from '@/blocks/types' import { AuthMode, IntegrationType } from '@/blocks/types' import type { AhrefsResponse } from '@/tools/ahrefs/types' +const COUNTRY_OPTIONS = [ + { label: 'United States', id: 'us' }, + { label: 'United Kingdom', id: 'gb' }, + { label: 'Germany', id: 'de' }, + { label: 'France', id: 'fr' }, + { label: 'Spain', id: 'es' }, + { label: 'Italy', id: 'it' }, + { label: 'Canada', id: 'ca' }, + { label: 'Australia', id: 'au' }, + { label: 'Japan', id: 'jp' }, + { label: 'Brazil', id: 'br' }, + { label: 'India', id: 'in' }, + { label: 'Netherlands', id: 'nl' }, + { label: 'Poland', id: 'pl' }, + { label: 'Russia', id: 'ru' }, + { label: 'Mexico', id: 'mx' }, +] + +const MODE_OPTIONS = [ + { label: 'Domain (entire domain)', id: 'domain' }, + { label: 'Prefix (URL prefix)', id: 'prefix' }, + { label: 'Subdomains (include all)', id: 'subdomains' }, + { label: 'Exact (exact URL)', id: 'exact' }, +] + +const DATE_WAND_CONFIG = { + enabled: true, + prompt: `Generate a date in YYYY-MM-DD format based on the user's description. +Examples: +- "today" -> Current date in YYYY-MM-DD format +- "yesterday" -> Yesterday's date in YYYY-MM-DD format +- "last week" -> Date 7 days ago in YYYY-MM-DD format +- "beginning of this month" -> First day of current month in YYYY-MM-DD format + +Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, no extra text.`, + placeholder: 'Describe the date (e.g., "yesterday", "last week", "start of month")...', + generationType: 'timestamp' as const, +} + export const AhrefsBlock: BlockConfig = { type: 'ahrefs', name: 'Ahrefs', @@ -10,7 +49,7 @@ export const AhrefsBlock: BlockConfig = { authMode: AuthMode.ApiKey, longDescription: 'Integrate Ahrefs SEO tools into your workflow. Analyze domain ratings, backlinks, organic keywords, top pages, and more. Requires an Ahrefs Enterprise plan with API access.', - docsLink: 'https://docs.ahrefs.com/docs/api/reference/introduction', + docsLink: 'https://docs.sim.ai/integrations/ahrefs', category: 'tools', integrationType: IntegrationType.Analytics, bgColor: '#FFFFFF', @@ -22,13 +61,15 @@ export const AhrefsBlock: BlockConfig = { type: 'dropdown', options: [ { label: 'Domain Rating', id: 'ahrefs_domain_rating' }, + { label: 'Metrics Overview', id: 'ahrefs_metrics' }, { label: 'Backlinks', id: 'ahrefs_backlinks' }, { label: 'Backlinks Stats', id: 'ahrefs_backlinks_stats' }, { label: 'Referring Domains', id: 'ahrefs_referring_domains' }, + { label: 'Broken Backlinks', id: 'ahrefs_broken_backlinks' }, { label: 'Organic Keywords', id: 'ahrefs_organic_keywords' }, + { label: 'Organic Competitors', id: 'ahrefs_organic_competitors' }, { label: 'Top Pages', id: 'ahrefs_top_pages' }, { label: 'Keyword Overview', id: 'ahrefs_keyword_overview' }, - { label: 'Broken Backlinks', id: 'ahrefs_broken_backlinks' }, ], value: () => 'ahrefs_domain_rating', }, @@ -48,79 +89,81 @@ export const AhrefsBlock: BlockConfig = { placeholder: 'YYYY-MM-DD (defaults to today)', condition: { field: 'operation', value: 'ahrefs_domain_rating' }, mode: 'advanced', - wandConfig: { - enabled: true, - prompt: `Generate a date in YYYY-MM-DD format based on the user's description. -Examples: -- "today" -> Current date in YYYY-MM-DD format -- "yesterday" -> Yesterday's date in YYYY-MM-DD format -- "last week" -> Date 7 days ago in YYYY-MM-DD format -- "beginning of this month" -> First day of current month in YYYY-MM-DD format - -Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, no extra text.`, - placeholder: 'Describe the date (e.g., "yesterday", "last week", "start of month")...', - generationType: 'timestamp', - }, + wandConfig: DATE_WAND_CONFIG, }, - // Backlinks operation inputs + // Metrics operation inputs { id: 'target', title: 'Target Domain/URL', type: 'short-input', - placeholder: 'example.com or https://example.com/page', - condition: { field: 'operation', value: 'ahrefs_backlinks' }, + placeholder: 'example.com', + condition: { field: 'operation', value: 'ahrefs_metrics' }, required: true, }, + { + id: 'country', + title: 'Country', + type: 'dropdown', + options: COUNTRY_OPTIONS, + value: () => 'us', + condition: { field: 'operation', value: 'ahrefs_metrics' }, + mode: 'advanced', + }, { id: 'mode', title: 'Analysis Mode', type: 'dropdown', - options: [ - { label: 'Domain (entire domain)', id: 'domain' }, - { label: 'Prefix (URL prefix)', id: 'prefix' }, - { label: 'Subdomains (include all)', id: 'subdomains' }, - { label: 'Exact (exact URL)', id: 'exact' }, - ], + options: MODE_OPTIONS, value: () => 'domain', - condition: { field: 'operation', value: 'ahrefs_backlinks' }, + condition: { field: 'operation', value: 'ahrefs_metrics' }, mode: 'advanced', }, { - id: 'limit', - title: 'Limit', + id: 'date', + title: 'Date', type: 'short-input', - placeholder: '100', - condition: { field: 'operation', value: 'ahrefs_backlinks' }, + placeholder: 'YYYY-MM-DD (defaults to today)', + condition: { field: 'operation', value: 'ahrefs_metrics' }, mode: 'advanced', + wandConfig: DATE_WAND_CONFIG, }, + // Backlinks operation inputs { - id: 'offset', - title: 'Offset', + id: 'target', + title: 'Target Domain/URL', type: 'short-input', - placeholder: '0', + placeholder: 'example.com or https://example.com/page', + condition: { field: 'operation', value: 'ahrefs_backlinks' }, + required: true, + }, + { + id: 'mode', + title: 'Analysis Mode', + type: 'dropdown', + options: MODE_OPTIONS, + value: () => 'domain', condition: { field: 'operation', value: 'ahrefs_backlinks' }, mode: 'advanced', }, { - id: 'date', - title: 'Date', + id: 'history', + title: 'History', + type: 'dropdown', + options: [ + { label: 'All time (includes lost backlinks)', id: 'all_time' }, + { label: 'Live only', id: 'live' }, + ], + value: () => 'all_time', + condition: { field: 'operation', value: 'ahrefs_backlinks' }, + mode: 'advanced', + }, + { + id: 'limit', + title: 'Limit', type: 'short-input', - placeholder: 'YYYY-MM-DD (defaults to today)', + placeholder: '1000', condition: { field: 'operation', value: 'ahrefs_backlinks' }, mode: 'advanced', - wandConfig: { - enabled: true, - prompt: `Generate a date in YYYY-MM-DD format based on the user's description. -Examples: -- "today" -> Current date in YYYY-MM-DD format -- "yesterday" -> Yesterday's date in YYYY-MM-DD format -- "last week" -> Date 7 days ago in YYYY-MM-DD format -- "beginning of this month" -> First day of current month in YYYY-MM-DD format - -Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, no extra text.`, - placeholder: 'Describe the date (e.g., "yesterday", "last week", "start of month")...', - generationType: 'timestamp', - }, }, // Backlinks Stats operation inputs { @@ -135,12 +178,7 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n id: 'mode', title: 'Analysis Mode', type: 'dropdown', - options: [ - { label: 'Domain (entire domain)', id: 'domain' }, - { label: 'Prefix (URL prefix)', id: 'prefix' }, - { label: 'Subdomains (include all)', id: 'subdomains' }, - { label: 'Exact (exact URL)', id: 'exact' }, - ], + options: MODE_OPTIONS, value: () => 'domain', condition: { field: 'operation', value: 'ahrefs_backlinks_stats' }, mode: 'advanced', @@ -152,19 +190,7 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n placeholder: 'YYYY-MM-DD (defaults to today)', condition: { field: 'operation', value: 'ahrefs_backlinks_stats' }, mode: 'advanced', - wandConfig: { - enabled: true, - prompt: `Generate a date in YYYY-MM-DD format based on the user's description. -Examples: -- "today" -> Current date in YYYY-MM-DD format -- "yesterday" -> Yesterday's date in YYYY-MM-DD format -- "last week" -> Date 7 days ago in YYYY-MM-DD format -- "beginning of this month" -> First day of current month in YYYY-MM-DD format - -Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, no extra text.`, - placeholder: 'Describe the date (e.g., "yesterday", "last week", "start of month")...', - generationType: 'timestamp', - }, + wandConfig: DATE_WAND_CONFIG, }, // Referring Domains operation inputs { @@ -179,13 +205,20 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n id: 'mode', title: 'Analysis Mode', type: 'dropdown', + options: MODE_OPTIONS, + value: () => 'domain', + condition: { field: 'operation', value: 'ahrefs_referring_domains' }, + mode: 'advanced', + }, + { + id: 'history', + title: 'History', + type: 'dropdown', options: [ - { label: 'Domain (entire domain)', id: 'domain' }, - { label: 'Prefix (URL prefix)', id: 'prefix' }, - { label: 'Subdomains (include all)', id: 'subdomains' }, - { label: 'Exact (exact URL)', id: 'exact' }, + { label: 'All time (includes lost domains)', id: 'all_time' }, + { label: 'Live only', id: 'live' }, ], - value: () => 'domain', + value: () => 'all_time', condition: { field: 'operation', value: 'ahrefs_referring_domains' }, mode: 'advanced', }, @@ -193,38 +226,35 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n id: 'limit', title: 'Limit', type: 'short-input', - placeholder: '100', + placeholder: '1000', condition: { field: 'operation', value: 'ahrefs_referring_domains' }, mode: 'advanced', }, + // Broken Backlinks operation inputs { - id: 'offset', - title: 'Offset', + id: 'target', + title: 'Target Domain/URL', type: 'short-input', - placeholder: '0', - condition: { field: 'operation', value: 'ahrefs_referring_domains' }, + placeholder: 'example.com', + condition: { field: 'operation', value: 'ahrefs_broken_backlinks' }, + required: true, + }, + { + id: 'mode', + title: 'Analysis Mode', + type: 'dropdown', + options: MODE_OPTIONS, + value: () => 'domain', + condition: { field: 'operation', value: 'ahrefs_broken_backlinks' }, mode: 'advanced', }, { - id: 'date', - title: 'Date', + id: 'limit', + title: 'Limit', type: 'short-input', - placeholder: 'YYYY-MM-DD (defaults to today)', - condition: { field: 'operation', value: 'ahrefs_referring_domains' }, + placeholder: '1000', + condition: { field: 'operation', value: 'ahrefs_broken_backlinks' }, mode: 'advanced', - wandConfig: { - enabled: true, - prompt: `Generate a date in YYYY-MM-DD format based on the user's description. -Examples: -- "today" -> Current date in YYYY-MM-DD format -- "yesterday" -> Yesterday's date in YYYY-MM-DD format -- "last week" -> Date 7 days ago in YYYY-MM-DD format -- "beginning of this month" -> First day of current month in YYYY-MM-DD format - -Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, no extra text.`, - placeholder: 'Describe the date (e.g., "yesterday", "last week", "start of month")...', - generationType: 'timestamp', - }, }, // Organic Keywords operation inputs { @@ -239,23 +269,7 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n id: 'country', title: 'Country', type: 'dropdown', - options: [ - { label: 'United States', id: 'us' }, - { label: 'United Kingdom', id: 'gb' }, - { label: 'Germany', id: 'de' }, - { label: 'France', id: 'fr' }, - { label: 'Spain', id: 'es' }, - { label: 'Italy', id: 'it' }, - { label: 'Canada', id: 'ca' }, - { label: 'Australia', id: 'au' }, - { label: 'Japan', id: 'jp' }, - { label: 'Brazil', id: 'br' }, - { label: 'India', id: 'in' }, - { label: 'Netherlands', id: 'nl' }, - { label: 'Poland', id: 'pl' }, - { label: 'Russia', id: 'ru' }, - { label: 'Mexico', id: 'mx' }, - ], + options: COUNTRY_OPTIONS, value: () => 'us', condition: { field: 'operation', value: 'ahrefs_organic_keywords' }, mode: 'advanced', @@ -264,12 +278,7 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n id: 'mode', title: 'Analysis Mode', type: 'dropdown', - options: [ - { label: 'Domain (entire domain)', id: 'domain' }, - { label: 'Prefix (URL prefix)', id: 'prefix' }, - { label: 'Subdomains (include all)', id: 'subdomains' }, - { label: 'Exact (exact URL)', id: 'exact' }, - ], + options: MODE_OPTIONS, value: () => 'domain', condition: { field: 'operation', value: 'ahrefs_organic_keywords' }, mode: 'advanced', @@ -278,15 +287,7 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n id: 'limit', title: 'Limit', type: 'short-input', - placeholder: '100', - condition: { field: 'operation', value: 'ahrefs_organic_keywords' }, - mode: 'advanced', - }, - { - id: 'offset', - title: 'Offset', - type: 'short-input', - placeholder: '0', + placeholder: '1000', condition: { field: 'operation', value: 'ahrefs_organic_keywords' }, mode: 'advanced', }, @@ -297,81 +298,41 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n placeholder: 'YYYY-MM-DD (defaults to today)', condition: { field: 'operation', value: 'ahrefs_organic_keywords' }, mode: 'advanced', - wandConfig: { - enabled: true, - prompt: `Generate a date in YYYY-MM-DD format based on the user's description. -Examples: -- "today" -> Current date in YYYY-MM-DD format -- "yesterday" -> Yesterday's date in YYYY-MM-DD format -- "last week" -> Date 7 days ago in YYYY-MM-DD format -- "beginning of this month" -> First day of current month in YYYY-MM-DD format - -Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, no extra text.`, - placeholder: 'Describe the date (e.g., "yesterday", "last week", "start of month")...', - generationType: 'timestamp', - }, + wandConfig: DATE_WAND_CONFIG, }, - // Top Pages operation inputs + // Organic Competitors operation inputs { id: 'target', - title: 'Target Domain', + title: 'Target Domain/URL', type: 'short-input', placeholder: 'example.com', - condition: { field: 'operation', value: 'ahrefs_top_pages' }, + condition: { field: 'operation', value: 'ahrefs_organic_competitors' }, required: true, }, { id: 'country', title: 'Country', type: 'dropdown', - options: [ - { label: 'United States', id: 'us' }, - { label: 'United Kingdom', id: 'gb' }, - { label: 'Germany', id: 'de' }, - { label: 'France', id: 'fr' }, - { label: 'Spain', id: 'es' }, - { label: 'Italy', id: 'it' }, - { label: 'Canada', id: 'ca' }, - { label: 'Australia', id: 'au' }, - { label: 'Japan', id: 'jp' }, - { label: 'Brazil', id: 'br' }, - { label: 'India', id: 'in' }, - { label: 'Netherlands', id: 'nl' }, - { label: 'Poland', id: 'pl' }, - { label: 'Russia', id: 'ru' }, - { label: 'Mexico', id: 'mx' }, - ], + options: COUNTRY_OPTIONS, value: () => 'us', - condition: { field: 'operation', value: 'ahrefs_top_pages' }, + condition: { field: 'operation', value: 'ahrefs_organic_competitors' }, mode: 'advanced', }, { id: 'mode', title: 'Analysis Mode', type: 'dropdown', - options: [ - { label: 'Domain (entire domain)', id: 'domain' }, - { label: 'Prefix (URL prefix)', id: 'prefix' }, - { label: 'Subdomains (include all)', id: 'subdomains' }, - ], + options: MODE_OPTIONS, value: () => 'domain', - condition: { field: 'operation', value: 'ahrefs_top_pages' }, + condition: { field: 'operation', value: 'ahrefs_organic_competitors' }, mode: 'advanced', }, { id: 'limit', title: 'Limit', type: 'short-input', - placeholder: '100', - condition: { field: 'operation', value: 'ahrefs_top_pages' }, - mode: 'advanced', - }, - { - id: 'offset', - title: 'Offset', - type: 'short-input', - placeholder: '0', - condition: { field: 'operation', value: 'ahrefs_top_pages' }, + placeholder: '1000', + condition: { field: 'operation', value: 'ahrefs_organic_competitors' }, mode: 'advanced', }, { @@ -379,65 +340,28 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n title: 'Date', type: 'short-input', placeholder: 'YYYY-MM-DD (defaults to today)', - condition: { field: 'operation', value: 'ahrefs_top_pages' }, + condition: { field: 'operation', value: 'ahrefs_organic_competitors' }, mode: 'advanced', - wandConfig: { - enabled: true, - prompt: `Generate a date in YYYY-MM-DD format based on the user's description. -Examples: -- "today" -> Current date in YYYY-MM-DD format -- "yesterday" -> Yesterday's date in YYYY-MM-DD format -- "last week" -> Date 7 days ago in YYYY-MM-DD format -- "beginning of this month" -> First day of current month in YYYY-MM-DD format - -Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, no extra text.`, - placeholder: 'Describe the date (e.g., "yesterday", "last week", "start of month")...', - generationType: 'timestamp', - }, + wandConfig: DATE_WAND_CONFIG, }, - // Keyword Overview operation inputs + // Top Pages operation inputs { - id: 'keyword', - title: 'Keyword', + id: 'target', + title: 'Target Domain', type: 'short-input', - placeholder: 'Enter keyword to analyze', - condition: { field: 'operation', value: 'ahrefs_keyword_overview' }, + placeholder: 'example.com', + condition: { field: 'operation', value: 'ahrefs_top_pages' }, required: true, }, { id: 'country', title: 'Country', type: 'dropdown', - options: [ - { label: 'United States', id: 'us' }, - { label: 'United Kingdom', id: 'gb' }, - { label: 'Germany', id: 'de' }, - { label: 'France', id: 'fr' }, - { label: 'Spain', id: 'es' }, - { label: 'Italy', id: 'it' }, - { label: 'Canada', id: 'ca' }, - { label: 'Australia', id: 'au' }, - { label: 'Japan', id: 'jp' }, - { label: 'Brazil', id: 'br' }, - { label: 'India', id: 'in' }, - { label: 'Netherlands', id: 'nl' }, - { label: 'Poland', id: 'pl' }, - { label: 'Russia', id: 'ru' }, - { label: 'Mexico', id: 'mx' }, - ], + options: COUNTRY_OPTIONS, value: () => 'us', - condition: { field: 'operation', value: 'ahrefs_keyword_overview' }, + condition: { field: 'operation', value: 'ahrefs_top_pages' }, mode: 'advanced', }, - // Broken Backlinks operation inputs - { - id: 'target', - title: 'Target Domain/URL', - type: 'short-input', - placeholder: 'example.com', - condition: { field: 'operation', value: 'ahrefs_broken_backlinks' }, - required: true, - }, { id: 'mode', title: 'Analysis Mode', @@ -446,48 +370,45 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n { label: 'Domain (entire domain)', id: 'domain' }, { label: 'Prefix (URL prefix)', id: 'prefix' }, { label: 'Subdomains (include all)', id: 'subdomains' }, - { label: 'Exact (exact URL)', id: 'exact' }, ], value: () => 'domain', - condition: { field: 'operation', value: 'ahrefs_broken_backlinks' }, + condition: { field: 'operation', value: 'ahrefs_top_pages' }, mode: 'advanced', }, { id: 'limit', title: 'Limit', type: 'short-input', - placeholder: '100', - condition: { field: 'operation', value: 'ahrefs_broken_backlinks' }, + placeholder: '1000', + condition: { field: 'operation', value: 'ahrefs_top_pages' }, mode: 'advanced', }, { - id: 'offset', - title: 'Offset', + id: 'date', + title: 'Date', type: 'short-input', - placeholder: '0', - condition: { field: 'operation', value: 'ahrefs_broken_backlinks' }, + placeholder: 'YYYY-MM-DD (defaults to today)', + condition: { field: 'operation', value: 'ahrefs_top_pages' }, mode: 'advanced', + wandConfig: DATE_WAND_CONFIG, }, + // Keyword Overview operation inputs { - id: 'date', - title: 'Date', + id: 'keyword', + title: 'Keyword', type: 'short-input', - placeholder: 'YYYY-MM-DD (defaults to today)', - condition: { field: 'operation', value: 'ahrefs_broken_backlinks' }, + placeholder: 'Enter keyword to analyze', + condition: { field: 'operation', value: 'ahrefs_keyword_overview' }, + required: true, + }, + { + id: 'country', + title: 'Country', + type: 'dropdown', + options: COUNTRY_OPTIONS, + value: () => 'us', + condition: { field: 'operation', value: 'ahrefs_keyword_overview' }, mode: 'advanced', - wandConfig: { - enabled: true, - prompt: `Generate a date in YYYY-MM-DD format based on the user's description. -Examples: -- "today" -> Current date in YYYY-MM-DD format -- "yesterday" -> Yesterday's date in YYYY-MM-DD format -- "last week" -> Date 7 days ago in YYYY-MM-DD format -- "beginning of this month" -> First day of current month in YYYY-MM-DD format - -Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, no extra text.`, - placeholder: 'Describe the date (e.g., "yesterday", "last week", "start of month")...', - generationType: 'timestamp', - }, }, // API Key (common to all operations) { @@ -502,33 +423,39 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n tools: { access: [ 'ahrefs_domain_rating', + 'ahrefs_metrics', 'ahrefs_backlinks', 'ahrefs_backlinks_stats', 'ahrefs_referring_domains', + 'ahrefs_broken_backlinks', 'ahrefs_organic_keywords', + 'ahrefs_organic_competitors', 'ahrefs_top_pages', 'ahrefs_keyword_overview', - 'ahrefs_broken_backlinks', ], config: { tool: (params) => { switch (params.operation) { case 'ahrefs_domain_rating': return 'ahrefs_domain_rating' + case 'ahrefs_metrics': + return 'ahrefs_metrics' case 'ahrefs_backlinks': return 'ahrefs_backlinks' case 'ahrefs_backlinks_stats': return 'ahrefs_backlinks_stats' case 'ahrefs_referring_domains': return 'ahrefs_referring_domains' + case 'ahrefs_broken_backlinks': + return 'ahrefs_broken_backlinks' case 'ahrefs_organic_keywords': return 'ahrefs_organic_keywords' + case 'ahrefs_organic_competitors': + return 'ahrefs_organic_competitors' case 'ahrefs_top_pages': return 'ahrefs_top_pages' case 'ahrefs_keyword_overview': return 'ahrefs_keyword_overview' - case 'ahrefs_broken_backlinks': - return 'ahrefs_broken_backlinks' default: return 'ahrefs_domain_rating' } @@ -536,7 +463,6 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n params: (params) => { const result: Record = {} if (params.limit) result.limit = Number(params.limit) - if (params.offset) result.offset = Number(params.offset) return result }, }, @@ -549,27 +475,46 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n mode: { type: 'string', description: 'Analysis mode (domain, prefix, subdomains, exact)' }, country: { type: 'string', description: 'Country code for geo-specific data' }, date: { type: 'string', description: 'Date for historical data in YYYY-MM-DD format' }, + history: { + type: 'string', + description: 'Historical scope for backlink-profile endpoints (all_time, live)', + }, limit: { type: 'number', description: 'Maximum number of results to return' }, - offset: { type: 'number', description: 'Number of results to skip for pagination' }, }, outputs: { // Domain Rating output domainRating: { type: 'number', description: 'Domain Rating score (0-100)' }, ahrefsRank: { type: 'number', description: 'Ahrefs Rank (global ranking)' }, + // Metrics output + metrics: { + type: 'json', + description: + 'Organic and paid search overview (organicTraffic, organicKeywords, organicKeywordsTop3, organicCost, paidTraffic, paidKeywords, paidPages, paidCost)', + }, // Backlinks output backlinks: { type: 'json', description: 'List of backlinks' }, // Backlinks Stats output - stats: { type: 'json', description: 'Backlink statistics' }, + stats: { + type: 'json', + description: + 'Backlink and referring domain totals (liveBacklinks, liveReferringDomains, allTimeBacklinks, allTimeReferringDomains)', + }, // Referring Domains output referringDomains: { type: 'json', description: 'List of referring domains' }, + // Broken Backlinks output + brokenBacklinks: { type: 'json', description: 'List of broken backlinks' }, // Organic Keywords output keywords: { type: 'json', description: 'List of organic keywords' }, + // Organic Competitors output + competitors: { type: 'json', description: 'List of organic search competitors' }, // Top Pages output pages: { type: 'json', description: 'List of top pages' }, // Keyword Overview output - overview: { type: 'json', description: 'Keyword metrics overview' }, - // Broken Backlinks output - brokenBacklinks: { type: 'json', description: 'List of broken backlinks' }, + overview: { + type: 'json', + description: + 'Keyword metrics overview, including search intent flags (informational, navigational, commercial, transactional, branded, local)', + }, }, } @@ -615,6 +560,16 @@ export const AhrefsBlockMeta = { category: 'marketing', tags: ['marketing', 'automation'], }, + { + icon: AhrefsIcon, + title: 'Ahrefs organic competitor finder', + prompt: + 'Build a monthly workflow that pulls Ahrefs organic competitors for my domain, cross-references them against my tracked competitor list, and posts newly surfaced competitors to Slack for review.', + modules: ['scheduled', 'agent', 'workflows'], + category: 'marketing', + tags: ['marketing', 'research'], + alsoIntegrations: ['slack'], + }, { icon: AhrefsIcon, title: 'Ahrefs + Similarweb growth scoreboard', @@ -668,5 +623,12 @@ export const AhrefsBlockMeta = { content: '# Track Organic Rankings\n\nReport how a domain is ranking in organic search using Ahrefs.\n\n## Steps\n1. Pull the organic keywords report for the target domain.\n2. Identify the top-ranking keywords and their positions.\n3. Compare against a prior snapshot if available to find gains and losses.\n\n## Output\nA summary of top organic keywords, notable position gains and drops, and pages that may need attention.', }, + { + name: 'find-organic-competitors', + description: + 'Use Ahrefs organic competitors data to identify sites competing for the same search traffic.', + content: + '# Find Organic Competitors\n\nSurface who actually competes with a domain in organic search using Ahrefs.\n\n## Steps\n1. Run an organic competitors report for the target domain.\n2. Rank results by common keyword overlap and competitor traffic.\n3. Cross-reference against the known competitor list to flag new entrants.\n\n## Output\nA ranked list of organic competitors with keyword overlap and traffic, highlighting any that are not yet being tracked.', + }, ], } as const satisfies BlockMeta diff --git a/apps/sim/tools/ahrefs/backlinks.ts b/apps/sim/tools/ahrefs/backlinks.ts index e5ce1551280..783cc09110b 100644 --- a/apps/sim/tools/ahrefs/backlinks.ts +++ b/apps/sim/tools/ahrefs/backlinks.ts @@ -1,6 +1,9 @@ import type { AhrefsBacklinksParams, AhrefsBacklinksResponse } from '@/tools/ahrefs/types' import type { ToolConfig } from '@/tools/types' +const SELECT_FIELDS = + 'url_from,url_to,anchor,domain_rating_source,is_dofollow,first_seen,last_visited' + export const backlinksTool: ToolConfig = { id: 'ahrefs_backlinks', name: 'Ahrefs Backlinks', @@ -21,25 +24,20 @@ export const backlinksTool: ToolConfig { - const url = new URL('https://api.ahrefs.com/v3/site-explorer/backlinks') + const url = new URL('https://api.ahrefs.com/v3/site-explorer/all-backlinks') url.searchParams.set('target', params.target) - // Date is required - default to today if not provided - const date = params.date || new Date().toISOString().split('T')[0] - url.searchParams.set('date', date) + url.searchParams.set('select', SELECT_FIELDS) if (params.mode) url.searchParams.set('mode', params.mode) + url.searchParams.set('history', params.history || 'all_time') if (params.limit) url.searchParams.set('limit', String(params.limit)) - if (params.offset) url.searchParams.set('offset', String(params.offset)) return url.toString() }, method: 'GET', @@ -79,8 +75,8 @@ export const backlinksTool: ToolConfig { const url = new URL('https://api.ahrefs.com/v3/site-explorer/broken-backlinks') url.searchParams.set('target', params.target) - // Date is required - default to today if not provided - const date = params.date || new Date().toISOString().split('T')[0] - url.searchParams.set('date', date) + url.searchParams.set('select', SELECT_FIELDS) if (params.mode) url.searchParams.set('mode', params.mode) if (params.limit) url.searchParams.set('limit', String(params.limit)) - if (params.offset) url.searchParams.set('offset', String(params.offset)) return url.toString() }, method: 'GET', @@ -81,12 +68,12 @@ export const brokenBacklinksTool: ToolConfig< throw new Error(data.error?.message || data.error || 'Failed to get broken backlinks') } - const brokenBacklinks = (data.backlinks || data.broken_backlinks || []).map((link: any) => ({ + const brokenBacklinks = (data.backlinks || []).map((link: any) => ({ urlFrom: link.url_from || '', urlTo: link.url_to || '', - httpCode: link.http_code ?? link.status_code ?? 404, + httpCode: link.http_code_target ?? null, anchor: link.anchor || '', - domainRatingSource: link.domain_rating_source ?? link.domain_rating ?? 0, + domainRatingSource: link.domain_rating_source ?? 0, })) return { @@ -109,7 +96,11 @@ export const brokenBacklinksTool: ToolConfig< description: 'The URL of the page containing the broken link', }, urlTo: { type: 'string', description: 'The broken URL being linked to' }, - httpCode: { type: 'number', description: 'HTTP status code (e.g., 404, 410)' }, + httpCode: { + type: 'number', + description: 'HTTP status code of the broken target URL (e.g., 404, 410)', + optional: true, + }, anchor: { type: 'string', description: 'The anchor text of the link' }, domainRatingSource: { type: 'number', diff --git a/apps/sim/tools/ahrefs/domain_rating.ts b/apps/sim/tools/ahrefs/domain_rating.ts index 75066ae0c5d..3b3378c9079 100644 --- a/apps/sim/tools/ahrefs/domain_rating.ts +++ b/apps/sim/tools/ahrefs/domain_rating.ts @@ -55,8 +55,8 @@ export const domainRatingTool: ToolConfig { const url = new URL('https://api.ahrefs.com/v3/keywords-explorer/overview') - url.searchParams.set('keyword', params.keyword) + url.searchParams.set('keywords', params.keyword) url.searchParams.set('country', params.country || 'us') + url.searchParams.set('select', SELECT_FIELDS) return url.toString() }, method: 'GET', @@ -56,18 +60,21 @@ export const keywordOverviewTool: ToolConfig< throw new Error(data.error?.message || data.error || 'Failed to get keyword overview') } + const result = (data.keywords || [])[0] || {} + return { success: true, output: { overview: { - keyword: data.keyword || '', - searchVolume: data.volume ?? 0, - keywordDifficulty: data.keyword_difficulty ?? data.difficulty ?? 0, - cpc: data.cpc ?? 0, - clicks: data.clicks ?? 0, - clicksPercentage: data.clicks_percentage ?? 0, - parentTopic: data.parent_topic || '', - trafficPotential: data.traffic_potential ?? 0, + keyword: result.keyword || '', + searchVolume: result.volume ?? 0, + keywordDifficulty: result.difficulty ?? null, + cpc: result.cpc ?? null, + clicks: result.clicks ?? null, + clicksPercentage: result.searches_pct_clicks_organic_only ?? null, + parentTopic: result.parent_topic ?? null, + trafficPotential: result.traffic_potential ?? null, + intents: result.intents ?? null, }, }, } @@ -83,17 +90,38 @@ export const keywordOverviewTool: ToolConfig< keywordDifficulty: { type: 'number', description: 'Keyword difficulty score (0-100)', + optional: true, }, - cpc: { type: 'number', description: 'Cost per click in USD' }, - clicks: { type: 'number', description: 'Estimated clicks per month' }, + cpc: { type: 'number', description: 'Cost per click in USD', optional: true }, + clicks: { type: 'number', description: 'Estimated clicks per month', optional: true }, clicksPercentage: { type: 'number', - description: 'Percentage of searches that result in clicks', + description: 'Percentage of searches that result in an organic click', + optional: true, + }, + parentTopic: { + type: 'string', + description: 'The parent topic for this keyword', + optional: true, }, - parentTopic: { type: 'string', description: 'The parent topic for this keyword' }, trafficPotential: { type: 'number', description: 'Estimated traffic potential if ranking #1', + optional: true, + }, + intents: { + type: 'object', + description: + 'Search intent flags (informational, navigational, commercial, transactional, branded, local)', + optional: true, + properties: { + informational: { type: 'boolean', description: 'Query seeks information' }, + navigational: { type: 'boolean', description: 'Query seeks a specific site or page' }, + commercial: { type: 'boolean', description: 'Query researches a purchase decision' }, + transactional: { type: 'boolean', description: 'Query intends to complete a purchase' }, + branded: { type: 'boolean', description: 'Query references a specific brand' }, + local: { type: 'boolean', description: 'Query seeks local results' }, + }, }, }, }, diff --git a/apps/sim/tools/ahrefs/metrics.ts b/apps/sim/tools/ahrefs/metrics.ts new file mode 100644 index 00000000000..99184349d74 --- /dev/null +++ b/apps/sim/tools/ahrefs/metrics.ts @@ -0,0 +1,116 @@ +import type { AhrefsMetricsParams, AhrefsMetricsResponse } from '@/tools/ahrefs/types' +import type { ToolConfig } from '@/tools/types' + +export const metricsTool: ToolConfig = { + id: 'ahrefs_metrics', + name: 'Ahrefs Metrics', + description: + 'Get a one-call organic and paid search overview for a target domain or URL: organic traffic, organic keywords, paid traffic, paid keywords, and estimated traffic cost.', + version: '1.0.0', + + params: { + target: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The target domain or URL to analyze. Example: "example.com"', + }, + country: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Country code for traffic data. Example: "us", "gb", "de"', + }, + mode: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match). Example: "domain"', + }, + date: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Date to report metrics on, in YYYY-MM-DD format (defaults to today)', + }, + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Ahrefs API Key', + }, + }, + + request: { + url: (params) => { + const url = new URL('https://api.ahrefs.com/v3/site-explorer/metrics') + url.searchParams.set('target', params.target) + // Date is required - default to today if not provided + const date = params.date || new Date().toISOString().split('T')[0] + url.searchParams.set('date', date) + url.searchParams.set('country', params.country || 'us') + if (params.mode) url.searchParams.set('mode', params.mode) + return url.toString() + }, + method: 'GET', + headers: (params) => ({ + Accept: 'application/json', + Authorization: `Bearer ${params.apiKey}`, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error?.message || data.error || 'Failed to get metrics') + } + + const metrics = data.metrics || {} + + return { + success: true, + output: { + metrics: { + organicTraffic: metrics.org_traffic ?? 0, + organicKeywords: metrics.org_keywords ?? 0, + organicKeywordsTop3: metrics.org_keywords_1_3 ?? 0, + organicCost: metrics.org_cost ?? null, + paidTraffic: metrics.paid_traffic ?? 0, + paidKeywords: metrics.paid_keywords ?? 0, + paidPages: metrics.paid_pages ?? 0, + paidCost: metrics.paid_cost ?? null, + }, + }, + } + }, + + outputs: { + metrics: { + type: 'object', + description: 'Organic and paid search overview', + properties: { + organicTraffic: { type: 'number', description: 'Estimated monthly organic traffic' }, + organicKeywords: { type: 'number', description: 'Number of organic keywords ranked' }, + organicKeywordsTop3: { + type: 'number', + description: 'Number of organic keywords ranking in positions 1-3', + }, + organicCost: { + type: 'number', + description: 'Estimated monthly cost to replicate organic traffic via ads (USD)', + optional: true, + }, + paidTraffic: { type: 'number', description: 'Estimated monthly paid search traffic' }, + paidKeywords: { type: 'number', description: 'Number of paid keywords targeted' }, + paidPages: { type: 'number', description: 'Number of pages receiving paid traffic' }, + paidCost: { + type: 'number', + description: 'Estimated monthly paid search spend (USD)', + optional: true, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/ahrefs/organic_competitors.ts b/apps/sim/tools/ahrefs/organic_competitors.ts new file mode 100644 index 00000000000..e304f567d0e --- /dev/null +++ b/apps/sim/tools/ahrefs/organic_competitors.ts @@ -0,0 +1,138 @@ +import type { + AhrefsOrganicCompetitorsParams, + AhrefsOrganicCompetitorsResponse, +} from '@/tools/ahrefs/types' +import type { ToolConfig } from '@/tools/types' + +const SELECT_FIELDS = + 'competitor_domain,domain_rating,keywords_common,keywords_target,keywords_competitor,traffic' + +export const organicCompetitorsTool: ToolConfig< + AhrefsOrganicCompetitorsParams, + AhrefsOrganicCompetitorsResponse +> = { + id: 'ahrefs_organic_competitors', + name: 'Ahrefs Organic Competitors', + description: + 'Get domains that compete with a target domain or URL for the same organic keywords, ranked by keyword overlap.', + version: '1.0.0', + + params: { + target: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The target domain or URL to analyze. Example: "example.com"', + }, + country: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Country code for search results. Example: "us", "gb", "de" (default: "us")', + }, + mode: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match). Example: "domain"', + }, + date: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Date to report metrics on, in YYYY-MM-DD format (defaults to today)', + }, + limit: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Maximum number of results to return. Example: 50 (default: 1000)', + }, + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Ahrefs API Key', + }, + }, + + request: { + url: (params) => { + const url = new URL('https://api.ahrefs.com/v3/site-explorer/organic-competitors') + url.searchParams.set('target', params.target) + url.searchParams.set('country', params.country || 'us') + url.searchParams.set('select', SELECT_FIELDS) + // Date is required - default to today if not provided + const date = params.date || new Date().toISOString().split('T')[0] + url.searchParams.set('date', date) + if (params.mode) url.searchParams.set('mode', params.mode) + if (params.limit) url.searchParams.set('limit', String(params.limit)) + return url.toString() + }, + method: 'GET', + headers: (params) => ({ + Accept: 'application/json', + Authorization: `Bearer ${params.apiKey}`, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error?.message || data.error || 'Failed to get organic competitors') + } + + const competitors = (data.competitors || []).map((competitor: any) => ({ + domain: competitor.competitor_domain ?? null, + domainRating: competitor.domain_rating ?? 0, + commonKeywords: competitor.keywords_common ?? 0, + targetKeywords: competitor.keywords_target ?? 0, + competitorKeywords: competitor.keywords_competitor ?? 0, + traffic: competitor.traffic ?? null, + })) + + return { + success: true, + output: { + competitors, + }, + } + }, + + outputs: { + competitors: { + type: 'array', + description: 'List of organic search competitors ranked by keyword overlap', + items: { + type: 'object', + properties: { + domain: { + type: 'string', + description: 'The competitor domain', + optional: true, + }, + domainRating: { type: 'number', description: 'Domain Rating of the competitor' }, + commonKeywords: { + type: 'number', + description: 'Number of keywords the competitor and target both rank for', + }, + targetKeywords: { + type: 'number', + description: 'Number of keywords the target ranks for', + }, + competitorKeywords: { + type: 'number', + description: 'Number of keywords the competitor ranks for', + }, + traffic: { + type: 'number', + description: 'Estimated monthly organic traffic for the competitor', + optional: true, + }, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/ahrefs/organic_keywords.ts b/apps/sim/tools/ahrefs/organic_keywords.ts index 3565b901c8b..73ff0e0a4f2 100644 --- a/apps/sim/tools/ahrefs/organic_keywords.ts +++ b/apps/sim/tools/ahrefs/organic_keywords.ts @@ -4,6 +4,9 @@ import type { } from '@/tools/ahrefs/types' import type { ToolConfig } from '@/tools/types' +const SELECT_FIELDS = + 'keyword,volume,best_position,best_position_url,sum_traffic,keyword_difficulty' + export const organicKeywordsTool: ToolConfig< AhrefsOrganicKeywordsParams, AhrefsOrganicKeywordsResponse @@ -33,25 +36,19 @@ export const organicKeywordsTool: ToolConfig< required: false, visibility: 'user-or-llm', description: - 'Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains), exact (exact URL match). Example: "domain"', + 'Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match). Example: "domain"', }, date: { type: 'string', required: false, visibility: 'user-only', - description: 'Date for historical data in YYYY-MM-DD format (defaults to today)', + description: 'Date to report metrics on, in YYYY-MM-DD format (defaults to today)', }, limit: { type: 'number', required: false, visibility: 'user-or-llm', - description: 'Maximum number of results to return. Example: 50 (default: 100)', - }, - offset: { - type: 'number', - required: false, - visibility: 'user-or-llm', - description: 'Number of results to skip for pagination. Example: 100', + description: 'Maximum number of results to return. Example: 50 (default: 1000)', }, apiKey: { type: 'string', @@ -66,12 +63,12 @@ export const organicKeywordsTool: ToolConfig< const url = new URL('https://api.ahrefs.com/v3/site-explorer/organic-keywords') url.searchParams.set('target', params.target) url.searchParams.set('country', params.country || 'us') + url.searchParams.set('select', SELECT_FIELDS) // Date is required - default to today if not provided const date = params.date || new Date().toISOString().split('T')[0] url.searchParams.set('date', date) if (params.mode) url.searchParams.set('mode', params.mode) if (params.limit) url.searchParams.set('limit', String(params.limit)) - if (params.offset) url.searchParams.set('offset', String(params.offset)) return url.toString() }, method: 'GET', @@ -88,13 +85,13 @@ export const organicKeywordsTool: ToolConfig< throw new Error(data.error?.message || data.error || 'Failed to get organic keywords') } - const keywords = (data.keywords || data.organic_keywords || []).map((kw: any) => ({ + const keywords = (data.keywords || []).map((kw: any) => ({ keyword: kw.keyword || '', volume: kw.volume ?? 0, - position: kw.position ?? 0, - url: kw.url || '', - traffic: kw.traffic ?? 0, - keywordDifficulty: kw.keyword_difficulty ?? kw.difficulty ?? 0, + position: kw.best_position ?? null, + url: kw.best_position_url ?? null, + traffic: kw.sum_traffic ?? 0, + keywordDifficulty: kw.keyword_difficulty ?? null, })) return { @@ -114,12 +111,21 @@ export const organicKeywordsTool: ToolConfig< properties: { keyword: { type: 'string', description: 'The keyword' }, volume: { type: 'number', description: 'Monthly search volume' }, - position: { type: 'number', description: 'Current ranking position' }, - url: { type: 'string', description: 'The URL that ranks for this keyword' }, + position: { + type: 'number', + description: 'Best ranking position for this keyword', + optional: true, + }, + url: { + type: 'string', + description: 'The URL that ranks at the best position for this keyword', + optional: true, + }, traffic: { type: 'number', description: 'Estimated monthly organic traffic' }, keywordDifficulty: { type: 'number', description: 'Keyword difficulty score (0-100)', + optional: true, }, }, }, diff --git a/apps/sim/tools/ahrefs/referring_domains.ts b/apps/sim/tools/ahrefs/referring_domains.ts index 87a21367c52..f6120d1a4bf 100644 --- a/apps/sim/tools/ahrefs/referring_domains.ts +++ b/apps/sim/tools/ahrefs/referring_domains.ts @@ -4,6 +4,8 @@ import type { } from '@/tools/ahrefs/types' import type { ToolConfig } from '@/tools/types' +const SELECT_FIELDS = 'domain,domain_rating,links_to_target,dofollow_links,first_seen,last_seen' + export const referringDomainsTool: ToolConfig< AhrefsReferringDomainsParams, AhrefsReferringDomainsResponse @@ -27,25 +29,20 @@ export const referringDomainsTool: ToolConfig< required: false, visibility: 'user-or-llm', description: - 'Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains), exact (exact URL match). Example: "domain"', + 'Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match). Example: "domain"', }, - date: { + history: { type: 'string', required: false, - visibility: 'user-only', - description: 'Date for historical data in YYYY-MM-DD format (defaults to today)', - }, - limit: { - type: 'number', - required: false, visibility: 'user-or-llm', - description: 'Maximum number of results to return. Example: 50 (default: 100)', + description: + 'Historical scope: "live" (currently live), "all_time" (default, includes lost domains), or "since:YYYY-MM-DD" (domains found since a date).', }, - offset: { + limit: { type: 'number', required: false, visibility: 'user-or-llm', - description: 'Number of results to skip for pagination. Example: 100', + description: 'Maximum number of results to return. Example: 50 (default: 1000)', }, apiKey: { type: 'string', @@ -59,12 +56,10 @@ export const referringDomainsTool: ToolConfig< url: (params) => { const url = new URL('https://api.ahrefs.com/v3/site-explorer/refdomains') url.searchParams.set('target', params.target) - // Date is required - default to today if not provided - const date = params.date || new Date().toISOString().split('T')[0] - url.searchParams.set('date', date) + url.searchParams.set('select', SELECT_FIELDS) if (params.mode) url.searchParams.set('mode', params.mode) + url.searchParams.set('history', params.history || 'all_time') if (params.limit) url.searchParams.set('limit', String(params.limit)) - if (params.offset) url.searchParams.set('offset', String(params.offset)) return url.toString() }, method: 'GET', @@ -81,16 +76,14 @@ export const referringDomainsTool: ToolConfig< throw new Error(data.error?.message || data.error || 'Failed to get referring domains') } - const referringDomains = (data.refdomains || data.referring_domains || []).map( - (domain: any) => ({ - domain: domain.domain || domain.refdomain || '', - domainRating: domain.domain_rating ?? 0, - backlinks: domain.backlinks ?? 0, - dofollowBacklinks: domain.dofollow_backlinks ?? domain.dofollow ?? 0, - firstSeen: domain.first_seen || '', - lastVisited: domain.last_visited || '', - }) - ) + const referringDomains = (data.refdomains || []).map((domain: any) => ({ + domain: domain.domain || '', + domainRating: domain.domain_rating ?? 0, + backlinks: domain.links_to_target ?? 0, + dofollowBacklinks: domain.dofollow_links ?? 0, + firstSeen: domain.first_seen || '', + lastVisited: domain.last_seen ?? null, + })) return { success: true, @@ -111,14 +104,18 @@ export const referringDomainsTool: ToolConfig< domainRating: { type: 'number', description: 'Domain Rating of the referring domain' }, backlinks: { type: 'number', - description: 'Total number of backlinks from this domain', + description: 'Total number of backlinks from this domain to the target', }, dofollowBacklinks: { type: 'number', description: 'Number of dofollow backlinks from this domain', }, firstSeen: { type: 'string', description: 'When the domain was first seen linking' }, - lastVisited: { type: 'string', description: 'When the domain was last checked' }, + lastVisited: { + type: 'string', + description: 'When the domain was last seen linking (null if never re-crawled)', + optional: true, + }, }, }, }, diff --git a/apps/sim/tools/ahrefs/top_pages.ts b/apps/sim/tools/ahrefs/top_pages.ts index bb48d8cf0d0..9feb05c40b6 100644 --- a/apps/sim/tools/ahrefs/top_pages.ts +++ b/apps/sim/tools/ahrefs/top_pages.ts @@ -1,6 +1,8 @@ import type { AhrefsTopPagesParams, AhrefsTopPagesResponse } from '@/tools/ahrefs/types' import type { ToolConfig } from '@/tools/types' +const SELECT_FIELDS = 'url,sum_traffic,keywords,top_keyword,value' + export const topPagesTool: ToolConfig = { id: 'ahrefs_top_pages', name: 'Ahrefs Top Pages', @@ -26,32 +28,19 @@ export const topPagesTool: ToolConfig ({ - url: page.url || '', - traffic: page.traffic ?? 0, - keywords: page.keywords ?? page.keyword_count ?? 0, - topKeyword: page.top_keyword || '', - value: page.value ?? page.traffic_value ?? 0, + const pages = (data.pages || []).map((page: any) => ({ + url: page.url ?? null, + traffic: page.sum_traffic ?? 0, + keywords: page.keywords ?? null, + topKeyword: page.top_keyword ?? null, + value: page.value ?? null, })) return { @@ -114,14 +100,23 @@ export const topPagesTool: ToolConfig = { attio_update_record: attioUpdateRecordTool, attio_update_task: attioUpdateTaskTool, attio_update_webhook: attioUpdateWebhookTool, - ahrefs_domain_rating: ahrefsDomainRatingTool, ahrefs_backlinks: ahrefsBacklinksTool, ahrefs_backlinks_stats: ahrefsBacklinksStatsTool, - ahrefs_referring_domains: ahrefsReferringDomainsTool, + ahrefs_broken_backlinks: ahrefsBrokenBacklinksTool, + ahrefs_domain_rating: ahrefsDomainRatingTool, + ahrefs_keyword_overview: ahrefsKeywordOverviewTool, + ahrefs_metrics: ahrefsMetricsTool, + ahrefs_organic_competitors: ahrefsOrganicCompetitorsTool, ahrefs_organic_keywords: ahrefsOrganicKeywordsTool, + ahrefs_referring_domains: ahrefsReferringDomainsTool, ahrefs_top_pages: ahrefsTopPagesTool, - ahrefs_keyword_overview: ahrefsKeywordOverviewTool, - ahrefs_broken_backlinks: ahrefsBrokenBacklinksTool, apify_run_actor_sync: apifyRunActorSyncTool, apify_run_actor_async: apifyRunActorAsyncTool, apify_run_task: apifyRunTaskTool, From e7c9a67194b64fb2703fb7fcd6c930670b570ad1 Mon Sep 17 00:00:00 2001 From: Waleed Date: Thu, 2 Jul 2026 10:02:36 -0700 Subject: [PATCH 06/28] fix(algolia): tighten tools.config, add geo/facet search + task-status tool; icon/color tweaks (#5356) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(algolia): fix serialization-time param mutation, add geo/facet search, task status tool - move all tools.config coercion/remapping out of tool() into a proper params() function so dynamic block references aren't destroyed before variable resolution - wire facets and getRankingInfo into the search tool so those documented outputs are actually reachable - add geo-search (aroundLatLng/aroundRadius/insideBoundingBox/insidePolygon) to search and browse_records, matching delete_by_filter - fix aroundRadius param type (string, not number, since it accepts "all") - sync batch_operations description with the real action set (delete, clear) - consolidate list_indices pagination into the shared page/hitsPerPage fields instead of duplicate listPage/listHitsPerPage - add algolia_get_task_status tool so workflows can poll a taskID instead of guessing when a write is applied - trim indexName/objectID/destination before building request URLs - add ranking-tuning and index-snapshot skills to AlgoliaBlockMeta fix(dropcontact): swap icon to the official wordmark's teal swirl mark, bgColor to match chore(grafana): bgColor to white to match brand tile convention * fix(algolia): register subblock-id migration for listPage/listHitsPerPage CI's subblock-id stability check correctly flagged that consolidating list_indices pagination into the shared page/hitsPerPage fields would silently drop values from already-deployed workflows. Add the rename mapping so existing saved state migrates instead of being lost. * fix(algolia): remove fabricated pendingTask field from get_task_status Algolia's Get Task Status response (additionalProperties: false) only returns `status` (published | notPublished) — pendingTask belongs to the List Indices response, not this endpoint. Drop it from the tool's output, response type, and block outputs rather than inventing data. * fix(algolia): coerce getRankingInfo/createIfNotExists/forwardToReplicas from real booleans, not just strings A wired boolean (e.g. true) failed the `=== 'true'` string-only checks and silently flipped to the wrong value. Add a toBool helper that accepts both the dropdown's string values and a genuine boolean passed in via a dynamic reference. fix(dropcontact): render icon with currentColor instead of hardcoded fill The new teal swirl mark's fill (#0ABA9F) matched the block's bgColor exactly, making the icon invisible on its own tile. Use currentColor and set iconColor so the shared tile-contrast logic (getTileIconColorClass) renders it white-on-teal like the rest of the brand icon system. * fix(algolia): trim indexName in request bodies, not just URL paths Greptile caught that search.ts's body-level indexName (sent inside the multi-query POST body, not URL-encoded) wasn't trimmed like every other tool's URL-path indexName. Fixed there and in get_records.ts's per-request indexName default/override, which had the same gap. * fix(algolia): route list_indices and get_task_status GETs to the -dsn read host Verified against Algolia's official JS client source (getDefaultHosts + transporter isRead = useReadTransporter || method === 'GET'): every GET request routes to the read (-dsn) host, matching the other 14 tools in this integration (get_record, get_settings, etc). Both tools were incorrectly hitting the write host. * chore(algolia): regenerate docs to drop stale pendingTask entry The get_task_status pendingTask output was removed from code in d1021292ec (fabricated field, not in Algolia's real API response) but docs weren't regenerated at the time, leaving a stale entry. Also syncs the Dropcontact icon's currentColor fill into the docs mirror. * fix(algolia): correct batch_operations body requirement wording Verified against Algolia's actual batchWriteParams schema (specs/common/schemas/Batch.yml): body is a required property on every batch request item, including index-level delete/clear actions — it isn't omittable. The tool's param description previously said to omit it; corrected to say use an empty object instead. --- apps/docs/components/icons.tsx | 14 +- .../content/docs/en/integrations/algolia.mdx | 33 ++++- .../docs/en/integrations/dropcontact.mdx | 2 +- .../content/docs/en/integrations/grafana.mdx | 2 +- apps/sim/blocks/blocks/algolia.ts | 128 ++++++++++++------ apps/sim/blocks/blocks/dropcontact.ts | 3 +- apps/sim/blocks/blocks/grafana.ts | 2 +- apps/sim/components/icons.tsx | 14 +- apps/sim/lib/integrations/integrations.json | 12 +- .../migrations/subblock-migrations.ts | 4 + apps/sim/tools/algolia/add_record.ts | 4 +- apps/sim/tools/algolia/batch_operations.ts | 4 +- apps/sim/tools/algolia/browse_records.ts | 43 +++++- apps/sim/tools/algolia/clear_records.ts | 2 +- apps/sim/tools/algolia/copy_move_index.ts | 4 +- apps/sim/tools/algolia/delete_by_filter.ts | 4 +- apps/sim/tools/algolia/delete_index.ts | 2 +- apps/sim/tools/algolia/delete_record.ts | 2 +- apps/sim/tools/algolia/get_record.ts | 2 +- apps/sim/tools/algolia/get_records.ts | 3 +- apps/sim/tools/algolia/get_settings.ts | 2 +- apps/sim/tools/algolia/get_task_status.ts | 70 ++++++++++ apps/sim/tools/algolia/index.ts | 2 + apps/sim/tools/algolia/list_indices.ts | 2 +- .../tools/algolia/partial_update_record.ts | 2 +- apps/sim/tools/algolia/search.ts | 60 +++++++- apps/sim/tools/algolia/types.ts | 22 +++ apps/sim/tools/algolia/update_settings.ts | 2 +- apps/sim/tools/registry.ts | 2 + 29 files changed, 363 insertions(+), 85 deletions(-) create mode 100644 apps/sim/tools/algolia/get_task_status.ts diff --git a/apps/docs/components/icons.tsx b/apps/docs/components/icons.tsx index 58d2124d7b3..2bc753071f2 100644 --- a/apps/docs/components/icons.tsx +++ b/apps/docs/components/icons.tsx @@ -7985,18 +7985,16 @@ export function LeadMagicIcon(props: SVGProps) { ) } -/** Dropcontact brand icon: teal disc with the white open-"d" contact mark. */ +/** Dropcontact brand icon: the teal swirl mark from the official wordmark. */ export function DropcontactIcon(props: SVGProps) { return ( - - + - ) } diff --git a/apps/docs/content/docs/en/integrations/algolia.mdx b/apps/docs/content/docs/en/integrations/algolia.mdx index 29c6725e6e7..9d506105db4 100644 --- a/apps/docs/content/docs/en/integrations/algolia.mdx +++ b/apps/docs/content/docs/en/integrations/algolia.mdx @@ -50,6 +50,12 @@ Search an Algolia index | `page` | number | No | Page number to retrieve \(default: 0\) | | `filters` | string | No | Filter string \(e.g., "category:electronics AND price < 100"\) | | `attributesToRetrieve` | string | No | Comma-separated list of attributes to retrieve | +| `facets` | string | No | Comma-separated list of facet attribute names to retrieve counts for \(use "*" for all\) | +| `getRankingInfo` | boolean | No | Whether to include detailed ranking information in each hit | +| `aroundLatLng` | string | No | Coordinates for geo-search \(e.g., "40.71,-74.01"\) | +| `aroundRadius` | string | No | Maximum radius in meters for geo-search, or "all" for unlimited | +| `insideBoundingBox` | json | No | Bounding box coordinates as \[\[lat1, lng1, lat2, lng2\]\] for geo-search | +| `insidePolygon` | json | No | Polygon coordinates as \[\[lat1, lng1, lat2, lng2, lat3, lng3, ...\]\] for geo-search | #### Output @@ -200,6 +206,10 @@ Browse and iterate over all records in an Algolia index using cursor pagination | `attributesToRetrieve` | string | No | Comma-separated list of attributes to retrieve | | `hitsPerPage` | number | No | Number of hits per page \(default: 1000, max: 1000\) | | `cursor` | string | No | Cursor from a previous browse response for pagination | +| `aroundLatLng` | string | No | Coordinates for geo-search \(e.g., "40.71,-74.01"\) | +| `aroundRadius` | string | No | Maximum radius in meters for geo-search, or "all" for unlimited | +| `insideBoundingBox` | json | No | Bounding box coordinates as \[\[lat1, lng1, lat2, lng2\]\] for geo-search | +| `insidePolygon` | json | No | Polygon coordinates as \[\[lat1, lng1, lat2, lng2, lat3, lng3, ...\]\] for geo-search | #### Output @@ -225,7 +235,7 @@ Perform batch add, update, partial update, or delete operations on records in an | `applicationId` | string | Yes | Algolia Application ID | | `apiKey` | string | Yes | Algolia Admin API Key | | `indexName` | string | Yes | Name of the Algolia index | -| `requests` | json | Yes | Array of batch operations. Each item has "action" \(addObject, updateObject, partialUpdateObject, partialUpdateObjectNoCreate, deleteObject\) and "body" \(the record data, must include objectID for update/delete\) | +| `requests` | json | Yes | Array of batch operations. Each item has "action" \(addObject, updateObject, partialUpdateObject, partialUpdateObjectNoCreate, deleteObject, delete, clear\) and "body" \(the record data, must include objectID for update/delete; omit body for delete/clear\) | #### Output @@ -390,7 +400,7 @@ Delete all records matching a filter from an Algolia index | `numericFilters` | json | No | Array of numeric filters \(e.g., \["price > 100"\]\) | | `tagFilters` | json | No | Array of tag filters using the _tags attribute \(e.g., \["published"\]\) | | `aroundLatLng` | string | No | Coordinates for geo-search filter \(e.g., "40.71,-74.01"\) | -| `aroundRadius` | number | No | Maximum radius in meters for geo-search, or "all" for unlimited | +| `aroundRadius` | string | No | Maximum radius in meters for geo-search, or "all" for unlimited | | `insideBoundingBox` | json | No | Bounding box coordinates as \[\[lat1, lng1, lat2, lng2\]\] for geo-search filter | | `insidePolygon` | json | No | Polygon coordinates as \[\[lat1, lng1, lat2, lng2, lat3, lng3, ...\]\] for geo-search filter | @@ -401,4 +411,23 @@ Delete all records matching a filter from an Algolia index | `taskID` | number | Algolia task ID for tracking the delete-by-filter operation | | `updatedAt` | string | Timestamp when the operation was performed | +### `algolia_get_task_status` + +Check whether an Algolia indexing task has finished publishing + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `applicationId` | string | Yes | Algolia Application ID | +| `apiKey` | string | Yes | Algolia API Key | +| `indexName` | string | Yes | Name of the Algolia index the task ran against | +| `taskID` | number | Yes | The taskID returned by a previous write operation | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `status` | string | Task status: "published" once the operation has been applied, "notPublished" while still pending | + diff --git a/apps/docs/content/docs/en/integrations/dropcontact.mdx b/apps/docs/content/docs/en/integrations/dropcontact.mdx index 1f5cf1c8f29..b4ec2126cb2 100644 --- a/apps/docs/content/docs/en/integrations/dropcontact.mdx +++ b/apps/docs/content/docs/en/integrations/dropcontact.mdx @@ -7,7 +7,7 @@ import { BlockInfoCard } from "@/components/ui/block-info-card" {/* MANUAL-CONTENT-START:intro */} diff --git a/apps/docs/content/docs/en/integrations/grafana.mdx b/apps/docs/content/docs/en/integrations/grafana.mdx index 103d69dc265..5bb28062b23 100644 --- a/apps/docs/content/docs/en/integrations/grafana.mdx +++ b/apps/docs/content/docs/en/integrations/grafana.mdx @@ -7,7 +7,7 @@ import { BlockInfoCard } from "@/components/ui/block-info-card" {/* MANUAL-CONTENT-START:intro */} diff --git a/apps/sim/blocks/blocks/algolia.ts b/apps/sim/blocks/blocks/algolia.ts index be4edae15f3..7b62fa540b5 100644 --- a/apps/sim/blocks/blocks/algolia.ts +++ b/apps/sim/blocks/blocks/algolia.ts @@ -37,6 +37,7 @@ export const AlgoliaBlock: BlockConfig = { { label: 'Copy/Move Index', id: 'copy_move_index' }, { label: 'Clear Records', id: 'clear_records' }, { label: 'Delete By Filter', id: 'delete_by_filter' }, + { label: 'Get Task Status', id: 'get_task_status' }, ], value: () => 'search', }, @@ -63,7 +64,7 @@ export const AlgoliaBlock: BlockConfig = { title: 'Hits Per Page', type: 'short-input', placeholder: '20', - condition: { field: 'operation', value: ['search', 'browse_records'] }, + condition: { field: 'operation', value: ['search', 'browse_records', 'list_indices'] }, mode: 'advanced', }, { @@ -71,7 +72,7 @@ export const AlgoliaBlock: BlockConfig = { title: 'Page', type: 'short-input', placeholder: '0', - condition: { field: 'operation', value: 'search' }, + condition: { field: 'operation', value: ['search', 'list_indices'] }, mode: 'advanced', }, { @@ -108,6 +109,26 @@ Return ONLY the filter string, no quotes or explanation.`, condition: { field: 'operation', value: ['search', 'get_record', 'browse_records'] }, mode: 'advanced', }, + { + id: 'facets', + title: 'Facets', + type: 'short-input', + placeholder: 'category,brand (or * for all)', + condition: { field: 'operation', value: 'search' }, + mode: 'advanced', + }, + { + id: 'getRankingInfo', + title: 'Include Ranking Info', + type: 'dropdown', + options: [ + { label: 'No', id: 'false' }, + { label: 'Yes', id: 'true' }, + ], + value: () => 'false', + condition: { field: 'operation', value: 'search' }, + mode: 'advanced', + }, // Browse cursor { id: 'cursor', @@ -389,7 +410,7 @@ Return ONLY the filter string, no quotes or explanation.`, title: 'Around Lat/Lng', type: 'short-input', placeholder: '40.71,-74.01', - condition: { field: 'operation', value: 'delete_by_filter' }, + condition: { field: 'operation', value: ['delete_by_filter', 'search', 'browse_records'] }, mode: 'advanced', }, { @@ -397,7 +418,7 @@ Return ONLY the filter string, no quotes or explanation.`, title: 'Around Radius (m)', type: 'short-input', placeholder: '1000 or "all"', - condition: { field: 'operation', value: 'delete_by_filter' }, + condition: { field: 'operation', value: ['delete_by_filter', 'search', 'browse_records'] }, mode: 'advanced', }, { @@ -405,7 +426,7 @@ Return ONLY the filter string, no quotes or explanation.`, title: 'Inside Bounding Box', type: 'short-input', placeholder: '[[47.3165,0.757,47.3424,0.8012]]', - condition: { field: 'operation', value: 'delete_by_filter' }, + condition: { field: 'operation', value: ['delete_by_filter', 'search', 'browse_records'] }, mode: 'advanced', }, { @@ -413,7 +434,7 @@ Return ONLY the filter string, no quotes or explanation.`, title: 'Inside Polygon', type: 'short-input', placeholder: '[[47.3165,0.757,47.3424,0.8012,47.33,0.78]]', - condition: { field: 'operation', value: 'delete_by_filter' }, + condition: { field: 'operation', value: ['delete_by_filter', 'search', 'browse_records'] }, mode: 'advanced', }, // Get records (batch) field @@ -447,22 +468,14 @@ Return ONLY the JSON array.`, generationType: 'json-object', }, }, - // List indices pagination + // Get task status field { - id: 'listPage', - title: 'Page', + id: 'taskID', + title: 'Task ID', type: 'short-input', - placeholder: '0', - condition: { field: 'operation', value: 'list_indices' }, - mode: 'advanced', - }, - { - id: 'listHitsPerPage', - title: 'Indices Per Page', - type: 'short-input', - placeholder: '100', - condition: { field: 'operation', value: 'list_indices' }, - mode: 'advanced', + placeholder: '12345', + condition: { field: 'operation', value: 'get_task_status' }, + required: { field: 'operation', value: 'get_task_status' }, }, // Object ID - for add (optional), get, partial update, delete { @@ -515,32 +528,48 @@ Return ONLY the JSON array.`, 'algolia_copy_move_index', 'algolia_clear_records', 'algolia_delete_by_filter', + 'algolia_get_task_status', ], config: { - tool: (params: Record) => { - const op = params.operation as string - if (op === 'partial_update_record') { - params.createIfNotExists = params.createIfNotExists !== 'false' + tool: (params: Record) => `algolia_${params.operation}`, + params: (params: Record) => { + const { operation, ...rest } = params + const result: Record = {} + + for (const [key, value] of Object.entries(rest)) { + if (value === undefined || value === null || value === '') continue + result[key] = value } - if (op === 'update_settings' && params.forwardToReplicas === 'true') { - params.forwardToReplicas = true - } else if (op === 'update_settings') { - params.forwardToReplicas = false + + const toBool = (value: unknown, defaultValue: boolean) => { + if (typeof value === 'boolean') return value + if (typeof value === 'string') return value === 'true' + return defaultValue + } + + if (operation === 'partial_update_record') { + result.createIfNotExists = toBool(result.createIfNotExists, true) } - if (op === 'copy_move_index') { - params.operation = params.copyMoveOperation + if (operation === 'update_settings') { + result.forwardToReplicas = toBool(result.forwardToReplicas, false) } - if (op === 'delete_by_filter') { - params.filters = params.deleteFilters + if (operation === 'search' && result.getRankingInfo !== undefined) { + result.getRankingInfo = toBool(result.getRankingInfo, false) } - if (op === 'get_records') { - params.requests = params.getRecordsRequests + if (operation === 'copy_move_index') { + result.operation = result.copyMoveOperation + result.copyMoveOperation = undefined } - if (op === 'list_indices') { - if (params.listPage !== undefined) params.page = params.listPage - if (params.listHitsPerPage !== undefined) params.hitsPerPage = params.listHitsPerPage + if (operation === 'delete_by_filter') { + result.filters = result.deleteFilters + result.deleteFilters = undefined } - return `algolia_${op}` + if (operation === 'get_records') { + result.requests = result.getRecordsRequests + result.getRecordsRequests = undefined + } + + return result }, }, }, @@ -553,6 +582,8 @@ Return ONLY the JSON array.`, page: { type: 'string', description: 'Page number' }, filters: { type: 'string', description: 'Algolia filter string' }, attributesToRetrieve: { type: 'string', description: 'Attributes to retrieve' }, + facets: { type: 'string', description: 'Comma-separated facet attribute names to count' }, + getRankingInfo: { type: 'string', description: 'Include detailed ranking info in each hit' }, cursor: { type: 'string', description: 'Browse cursor for pagination' }, record: { type: 'json', description: 'Record data to add' }, attributes: { type: 'json', description: 'Attributes to partially update' }, @@ -579,8 +610,7 @@ Return ONLY the JSON array.`, type: 'json', description: 'Array of objects with objectID to retrieve multiple records', }, - listPage: { type: 'string', description: 'Page number for list indices pagination' }, - listHitsPerPage: { type: 'string', description: 'Indices per page for list indices' }, + taskID: { type: 'string', description: 'Task ID returned by a previous write operation' }, applicationId: { type: 'string', description: 'Algolia Application ID' }, apiKey: { type: 'string', description: 'Algolia API Key' }, }, @@ -644,6 +674,10 @@ Return ONLY the JSON array.`, type: 'number', description: 'Maximum number of hits accessible via pagination (default 1000)', }, + status: { + type: 'string', + description: 'Task status: "published" once applied, "notPublished" while still pending', + }, }, } @@ -739,5 +773,19 @@ export const AlgoliaBlockMeta = { content: '# Audit Search Relevance\n\nCheck that important queries return good results from an Algolia index.\n\n## Steps\n1. Run each query in the provided test set against the index.\n2. Record the top results, total hit count, and whether the expected record appears.\n3. Flag queries that return zero hits, too many hits, or miss the expected record.\n\n## Output\nA table of queries with result counts and pass/fail, plus suggestions for synonyms or ranking tweaks where relevance is weak.', }, + { + name: 'tune-index-ranking', + description: + 'Read an Algolia index configuration, propose ranking and searchable-attribute changes, and apply the update.', + content: + '# Tune Index Ranking\n\nAdjust how an Algolia index ranks results without touching the underlying data.\n\n## Steps\n1. Fetch the current index settings (searchable attributes, custom ranking, ranking criteria).\n2. Compare them against the desired outcome (e.g., surface newer or more popular items first).\n3. Propose specific changes to customRanking, searchableAttributes order, or attributesForFaceting.\n4. Apply the approved settings update to the index.\n\n## Output\nA before/after summary of the settings changed and why, plus confirmation the update succeeded.', + }, + { + name: 'snapshot-index-before-change', + description: + 'Copy an Algolia index to a timestamped backup before applying a risky settings or data change.', + content: + '# Snapshot Index Before Change\n\nProtect against a bad settings or batch update by copying the index first.\n\n## Steps\n1. Copy the source index to a new destination index named with a date or version suffix.\n2. Confirm the copy completed by checking the resulting task status.\n3. Apply the intended change (settings update, batch operation, or delete-by-filter) to the original index.\n4. If the change causes problems, the snapshot index can be copied back or used for comparison.\n\n## Output\nThe name of the backup index created and confirmation the source change was applied afterward.', + }, ], } as const satisfies BlockMeta diff --git a/apps/sim/blocks/blocks/dropcontact.ts b/apps/sim/blocks/blocks/dropcontact.ts index be4ca04a2f2..d335da37266 100644 --- a/apps/sim/blocks/blocks/dropcontact.ts +++ b/apps/sim/blocks/blocks/dropcontact.ts @@ -10,7 +10,8 @@ export const DropcontactBlock: BlockConfig = { 'Use Dropcontact to verify and enrich B2B contacts. Submit a contact with their name, company, website, or LinkedIn URL and receive a verified professional email, phone number, company firmographics, and LinkedIn profile. Enrichment is async: Dropcontact processes the request, then Sim polls until the result is ready. Credits are only charged when a verified email is returned.', docsLink: 'https://docs.sim.ai/tools/dropcontact', category: 'tools', - bgColor: '#0066FF', + bgColor: '#0ABA9F', + iconColor: '#0ABA9F', icon: DropcontactIcon, authMode: AuthMode.ApiKey, integrationType: IntegrationType.Sales, diff --git a/apps/sim/blocks/blocks/grafana.ts b/apps/sim/blocks/blocks/grafana.ts index 9cb12c810ce..d1c70517d66 100644 --- a/apps/sim/blocks/blocks/grafana.ts +++ b/apps/sim/blocks/blocks/grafana.ts @@ -13,7 +13,7 @@ export const GrafanaBlock: BlockConfig = { docsLink: 'https://docs.sim.ai/integrations/grafana', category: 'tools', integrationType: IntegrationType.Observability, - bgColor: '#F46800', + bgColor: '#FFFFFF', icon: GrafanaIcon, subBlocks: [ { diff --git a/apps/sim/components/icons.tsx b/apps/sim/components/icons.tsx index 58d2124d7b3..2bc753071f2 100644 --- a/apps/sim/components/icons.tsx +++ b/apps/sim/components/icons.tsx @@ -7985,18 +7985,16 @@ export function LeadMagicIcon(props: SVGProps) { ) } -/** Dropcontact brand icon: teal disc with the white open-"d" contact mark. */ +/** Dropcontact brand icon: the teal swirl mark from the official wordmark. */ export function DropcontactIcon(props: SVGProps) { return ( - - + - ) } diff --git a/apps/sim/lib/integrations/integrations.json b/apps/sim/lib/integrations/integrations.json index e362db3a581..03f07029836 100644 --- a/apps/sim/lib/integrations/integrations.json +++ b/apps/sim/lib/integrations/integrations.json @@ -1,5 +1,5 @@ { - "updatedAt": "2026-07-01", + "updatedAt": "2026-07-02", "integrations": [ { "type": "onepassword", @@ -541,9 +541,13 @@ { "name": "Delete By Filter", "description": "Delete all records matching a filter from an Algolia index" + }, + { + "name": "Get Task Status", + "description": "Check whether an Algolia indexing task has finished publishing" } ], - "operationCount": 15, + "operationCount": 16, "triggers": [], "triggerCount": 0, "authType": "api-key", @@ -4418,7 +4422,7 @@ "name": "Dropcontact", "description": "Enrich B2B contacts with verified email, phone, and company data", "longDescription": "Use Dropcontact to verify and enrich B2B contacts. Submit a contact with their name, company, website, or LinkedIn URL and receive a verified professional email, phone number, company firmographics, and LinkedIn profile. Enrichment is async: Dropcontact processes the request, then Sim polls until the result is ready. Credits are only charged when a verified email is returned.", - "bgColor": "#0066FF", + "bgColor": "#0ABA9F", "iconName": "DropcontactIcon", "docsUrl": "https://docs.sim.ai/tools/dropcontact", "operations": [ @@ -7312,7 +7316,7 @@ "name": "Grafana", "description": "Interact with Grafana dashboards, alerts, and annotations", "longDescription": "Integrate Grafana into workflows. Manage dashboards, alerts, annotations, data sources, folders, and monitor health status.", - "bgColor": "#F46800", + "bgColor": "#FFFFFF", "iconName": "GrafanaIcon", "docsUrl": "https://docs.sim.ai/integrations/grafana", "operations": [ diff --git a/apps/sim/lib/workflows/migrations/subblock-migrations.ts b/apps/sim/lib/workflows/migrations/subblock-migrations.ts index 3deedca5212..d56d51ff055 100644 --- a/apps/sim/lib/workflows/migrations/subblock-migrations.ts +++ b/apps/sim/lib/workflows/migrations/subblock-migrations.ts @@ -26,6 +26,10 @@ export const SUBBLOCK_ID_MIGRATIONS: Record> = { knowledge: { knowledgeBaseId: 'knowledgeBaseSelector', }, + algolia: { + listPage: 'page', + listHitsPerPage: 'hitsPerPage', + }, kalshi: { settlementStatus: '_removed_settlementStatus', }, diff --git a/apps/sim/tools/algolia/add_record.ts b/apps/sim/tools/algolia/add_record.ts index a1ccb3b30c1..a6f5623ac44 100644 --- a/apps/sim/tools/algolia/add_record.ts +++ b/apps/sim/tools/algolia/add_record.ts @@ -42,9 +42,9 @@ export const addRecordTool: ToolConfig { - const base = `https://${params.applicationId}.algolia.net/1/indexes/${encodeURIComponent(params.indexName)}` + const base = `https://${params.applicationId}.algolia.net/1/indexes/${encodeURIComponent(params.indexName.trim())}` if (params.objectID) { - return `${base}/${encodeURIComponent(params.objectID)}` + return `${base}/${encodeURIComponent(params.objectID.trim())}` } return base }, diff --git a/apps/sim/tools/algolia/batch_operations.ts b/apps/sim/tools/algolia/batch_operations.ts index d65c69c0feb..7016dc26717 100644 --- a/apps/sim/tools/algolia/batch_operations.ts +++ b/apps/sim/tools/algolia/batch_operations.ts @@ -38,13 +38,13 @@ export const batchOperationsTool: ToolConfig< required: true, visibility: 'user-or-llm', description: - 'Array of batch operations. Each item has "action" (addObject, updateObject, partialUpdateObject, partialUpdateObjectNoCreate, deleteObject) and "body" (the record data, must include objectID for update/delete)', + 'Array of batch operations. Each item has "action" (addObject, updateObject, partialUpdateObject, partialUpdateObjectNoCreate, deleteObject, delete, clear) and "body" (the record data; must include objectID for update/delete; use an empty object {} for the index-level delete/clear actions)', }, }, request: { url: (params) => - `https://${params.applicationId}.algolia.net/1/indexes/${encodeURIComponent(params.indexName)}/batch`, + `https://${params.applicationId}.algolia.net/1/indexes/${encodeURIComponent(params.indexName.trim())}/batch`, method: 'POST', headers: (params) => ({ 'x-algolia-application-id': params.applicationId, diff --git a/apps/sim/tools/algolia/browse_records.ts b/apps/sim/tools/algolia/browse_records.ts index 7b6dc3064f6..c466879fae0 100644 --- a/apps/sim/tools/algolia/browse_records.ts +++ b/apps/sim/tools/algolia/browse_records.ts @@ -62,11 +62,36 @@ export const browseRecordsTool: ToolConfig< visibility: 'user-or-llm', description: 'Cursor from a previous browse response for pagination', }, + aroundLatLng: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Coordinates for geo-search (e.g., "40.71,-74.01")', + }, + aroundRadius: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Maximum radius in meters for geo-search, or "all" for unlimited', + }, + insideBoundingBox: { + type: 'json', + required: false, + visibility: 'user-or-llm', + description: 'Bounding box coordinates as [[lat1, lng1, lat2, lng2]] for geo-search', + }, + insidePolygon: { + type: 'json', + required: false, + visibility: 'user-or-llm', + description: + 'Polygon coordinates as [[lat1, lng1, lat2, lng2, lat3, lng3, ...]] for geo-search', + }, }, request: { url: (params) => - `https://${params.applicationId}-dsn.algolia.net/1/indexes/${encodeURIComponent(params.indexName)}/browse`, + `https://${params.applicationId}-dsn.algolia.net/1/indexes/${encodeURIComponent(params.indexName.trim())}/browse`, method: 'POST', headers: (params) => ({ 'x-algolia-application-id': params.applicationId, @@ -86,6 +111,22 @@ export const browseRecordsTool: ToolConfig< .map((a: string) => a.trim()) } if (params.hitsPerPage !== undefined) body.hitsPerPage = Number(params.hitsPerPage) + if (params.aroundLatLng) body.aroundLatLng = params.aroundLatLng + if (params.aroundRadius !== undefined) { + body.aroundRadius = params.aroundRadius === 'all' ? 'all' : Number(params.aroundRadius) + } + if (params.insideBoundingBox) { + body.insideBoundingBox = + typeof params.insideBoundingBox === 'string' + ? JSON.parse(params.insideBoundingBox) + : params.insideBoundingBox + } + if (params.insidePolygon) { + body.insidePolygon = + typeof params.insidePolygon === 'string' + ? JSON.parse(params.insidePolygon) + : params.insidePolygon + } return body }, }, diff --git a/apps/sim/tools/algolia/clear_records.ts b/apps/sim/tools/algolia/clear_records.ts index 1c789ea8fdc..b647e3912f9 100644 --- a/apps/sim/tools/algolia/clear_records.ts +++ b/apps/sim/tools/algolia/clear_records.ts @@ -32,7 +32,7 @@ export const clearRecordsTool: ToolConfig - `https://${params.applicationId}.algolia.net/1/indexes/${encodeURIComponent(params.indexName)}/clear`, + `https://${params.applicationId}.algolia.net/1/indexes/${encodeURIComponent(params.indexName.trim())}/clear`, method: 'POST', headers: (params) => ({ 'x-algolia-application-id': params.applicationId, diff --git a/apps/sim/tools/algolia/copy_move_index.ts b/apps/sim/tools/algolia/copy_move_index.ts index 7174d1f9912..970d18d6414 100644 --- a/apps/sim/tools/algolia/copy_move_index.ts +++ b/apps/sim/tools/algolia/copy_move_index.ts @@ -55,7 +55,7 @@ export const copyMoveIndexTool: ToolConfig< request: { url: (params) => - `https://${params.applicationId}.algolia.net/1/indexes/${encodeURIComponent(params.indexName)}/operation`, + `https://${params.applicationId}.algolia.net/1/indexes/${encodeURIComponent(params.indexName.trim())}/operation`, method: 'POST', headers: (params) => ({ 'x-algolia-application-id': params.applicationId, @@ -65,7 +65,7 @@ export const copyMoveIndexTool: ToolConfig< body: (params) => { const body: Record = { operation: params.operation, - destination: params.destination, + destination: params.destination.trim(), } if (params.scope) { const scope = typeof params.scope === 'string' ? JSON.parse(params.scope) : params.scope diff --git a/apps/sim/tools/algolia/delete_by_filter.ts b/apps/sim/tools/algolia/delete_by_filter.ts index ca030f07f5c..b79cc0f2d80 100644 --- a/apps/sim/tools/algolia/delete_by_filter.ts +++ b/apps/sim/tools/algolia/delete_by_filter.ts @@ -63,7 +63,7 @@ export const deleteByFilterTool: ToolConfig< description: 'Coordinates for geo-search filter (e.g., "40.71,-74.01")', }, aroundRadius: { - type: 'number', + type: 'string', required: false, visibility: 'user-or-llm', description: 'Maximum radius in meters for geo-search, or "all" for unlimited', @@ -85,7 +85,7 @@ export const deleteByFilterTool: ToolConfig< request: { url: (params) => - `https://${params.applicationId}.algolia.net/1/indexes/${encodeURIComponent(params.indexName)}/deleteByQuery`, + `https://${params.applicationId}.algolia.net/1/indexes/${encodeURIComponent(params.indexName.trim())}/deleteByQuery`, method: 'POST', headers: (params) => ({ 'x-algolia-application-id': params.applicationId, diff --git a/apps/sim/tools/algolia/delete_index.ts b/apps/sim/tools/algolia/delete_index.ts index 7e206aafcbf..130f56ee5a9 100644 --- a/apps/sim/tools/algolia/delete_index.ts +++ b/apps/sim/tools/algolia/delete_index.ts @@ -31,7 +31,7 @@ export const deleteIndexTool: ToolConfig - `https://${params.applicationId}.algolia.net/1/indexes/${encodeURIComponent(params.indexName)}`, + `https://${params.applicationId}.algolia.net/1/indexes/${encodeURIComponent(params.indexName.trim())}`, headers: (params) => ({ 'x-algolia-application-id': params.applicationId, 'x-algolia-api-key': params.apiKey, diff --git a/apps/sim/tools/algolia/delete_record.ts b/apps/sim/tools/algolia/delete_record.ts index db63ee70593..5332d322a67 100644 --- a/apps/sim/tools/algolia/delete_record.ts +++ b/apps/sim/tools/algolia/delete_record.ts @@ -38,7 +38,7 @@ export const deleteRecordTool: ToolConfig - `https://${params.applicationId}.algolia.net/1/indexes/${encodeURIComponent(params.indexName)}/${encodeURIComponent(params.objectID)}`, + `https://${params.applicationId}.algolia.net/1/indexes/${encodeURIComponent(params.indexName.trim())}/${encodeURIComponent(params.objectID.trim())}`, headers: (params) => ({ 'x-algolia-application-id': params.applicationId, 'x-algolia-api-key': params.apiKey, diff --git a/apps/sim/tools/algolia/get_record.ts b/apps/sim/tools/algolia/get_record.ts index 88992c0feb8..61aa7482765 100644 --- a/apps/sim/tools/algolia/get_record.ts +++ b/apps/sim/tools/algolia/get_record.ts @@ -43,7 +43,7 @@ export const getRecordTool: ToolConfig { - const base = `https://${params.applicationId}-dsn.algolia.net/1/indexes/${encodeURIComponent(params.indexName)}/${encodeURIComponent(params.objectID)}` + const base = `https://${params.applicationId}-dsn.algolia.net/1/indexes/${encodeURIComponent(params.indexName.trim())}/${encodeURIComponent(params.objectID.trim())}` if (params.attributesToRetrieve) { return `${base}?attributesToRetrieve=${encodeURIComponent(params.attributesToRetrieve)}` } diff --git a/apps/sim/tools/algolia/get_records.ts b/apps/sim/tools/algolia/get_records.ts index 15199f0a8e7..4c1b2f355fe 100644 --- a/apps/sim/tools/algolia/get_records.ts +++ b/apps/sim/tools/algolia/get_records.ts @@ -48,7 +48,8 @@ export const getRecordsTool: ToolConfig[]).map((req) => ({ ...req, - indexName: req.indexName ?? params.indexName, + indexName: + typeof req.indexName === 'string' ? req.indexName.trim() : params.indexName.trim(), })) return { requests } }, diff --git a/apps/sim/tools/algolia/get_settings.ts b/apps/sim/tools/algolia/get_settings.ts index 16bc90ddfd0..db3b7aeebd8 100644 --- a/apps/sim/tools/algolia/get_settings.ts +++ b/apps/sim/tools/algolia/get_settings.ts @@ -31,7 +31,7 @@ export const getSettingsTool: ToolConfig - `https://${params.applicationId}-dsn.algolia.net/1/indexes/${encodeURIComponent(params.indexName)}/settings`, + `https://${params.applicationId}-dsn.algolia.net/1/indexes/${encodeURIComponent(params.indexName.trim())}/settings`, headers: (params) => ({ 'x-algolia-application-id': params.applicationId, 'x-algolia-api-key': params.apiKey, diff --git a/apps/sim/tools/algolia/get_task_status.ts b/apps/sim/tools/algolia/get_task_status.ts new file mode 100644 index 00000000000..c8ea30d0809 --- /dev/null +++ b/apps/sim/tools/algolia/get_task_status.ts @@ -0,0 +1,70 @@ +import type { + AlgoliaGetTaskStatusParams, + AlgoliaGetTaskStatusResponse, +} from '@/tools/algolia/types' +import type { ToolConfig } from '@/tools/types' + +export const getTaskStatusTool: ToolConfig< + AlgoliaGetTaskStatusParams, + AlgoliaGetTaskStatusResponse +> = { + id: 'algolia_get_task_status', + name: 'Algolia Get Task Status', + description: 'Check whether an Algolia indexing task has finished publishing', + version: '1.0', + + params: { + applicationId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Algolia Application ID', + }, + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Algolia API Key', + }, + indexName: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Name of the Algolia index the task ran against', + }, + taskID: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: 'The taskID returned by a previous write operation', + }, + }, + + request: { + method: 'GET', + url: (params) => + `https://${params.applicationId}-dsn.algolia.net/1/indexes/${encodeURIComponent(params.indexName.trim())}/task/${encodeURIComponent(String(params.taskID).trim())}`, + headers: (params) => ({ + 'x-algolia-application-id': params.applicationId, + 'x-algolia-api-key': params.apiKey, + }), + }, + + transformResponse: async (response) => { + const data = await response.json() + return { + success: true, + output: { + status: data.status ?? '', + }, + } + }, + + outputs: { + status: { + type: 'string', + description: + 'Task status: "published" once the operation has been applied, "notPublished" while still pending', + }, + }, +} diff --git a/apps/sim/tools/algolia/index.ts b/apps/sim/tools/algolia/index.ts index b5fdc4bd70f..a86ac448f73 100644 --- a/apps/sim/tools/algolia/index.ts +++ b/apps/sim/tools/algolia/index.ts @@ -9,6 +9,7 @@ import { deleteRecordTool } from '@/tools/algolia/delete_record' import { getRecordTool } from '@/tools/algolia/get_record' import { getRecordsTool } from '@/tools/algolia/get_records' import { getSettingsTool } from '@/tools/algolia/get_settings' +import { getTaskStatusTool } from '@/tools/algolia/get_task_status' import { listIndicesTool } from '@/tools/algolia/list_indices' import { partialUpdateRecordTool } from '@/tools/algolia/partial_update_record' import { searchTool } from '@/tools/algolia/search' @@ -24,6 +25,7 @@ export const algoliaBrowseRecordsTool = browseRecordsTool export const algoliaBatchOperationsTool = batchOperationsTool export const algoliaListIndicesTool = listIndicesTool export const algoliaGetSettingsTool = getSettingsTool +export const algoliaGetTaskStatusTool = getTaskStatusTool export const algoliaUpdateSettingsTool = updateSettingsTool export const algoliaDeleteIndexTool = deleteIndexTool export const algoliaCopyMoveIndexTool = copyMoveIndexTool diff --git a/apps/sim/tools/algolia/list_indices.ts b/apps/sim/tools/algolia/list_indices.ts index 5e6f3d30e75..b938be24405 100644 --- a/apps/sim/tools/algolia/list_indices.ts +++ b/apps/sim/tools/algolia/list_indices.ts @@ -37,7 +37,7 @@ export const listIndicesTool: ToolConfig { - const base = `https://${params.applicationId}.algolia.net/1/indexes` + const base = `https://${params.applicationId}-dsn.algolia.net/1/indexes` const queryParams: string[] = [] if (params.page !== undefined) queryParams.push(`page=${params.page}`) if (params.hitsPerPage !== undefined) queryParams.push(`hitsPerPage=${params.hitsPerPage}`) diff --git a/apps/sim/tools/algolia/partial_update_record.ts b/apps/sim/tools/algolia/partial_update_record.ts index ce6430ecbd8..80f6214c07c 100644 --- a/apps/sim/tools/algolia/partial_update_record.ts +++ b/apps/sim/tools/algolia/partial_update_record.ts @@ -55,7 +55,7 @@ export const partialUpdateRecordTool: ToolConfig< request: { url: (params) => { - const base = `https://${params.applicationId}.algolia.net/1/indexes/${encodeURIComponent(params.indexName)}/${encodeURIComponent(params.objectID)}/partial` + const base = `https://${params.applicationId}.algolia.net/1/indexes/${encodeURIComponent(params.indexName.trim())}/${encodeURIComponent(params.objectID.trim())}/partial` if (params.createIfNotExists === false) { return `${base}?createIfNotExists=false` } diff --git a/apps/sim/tools/algolia/search.ts b/apps/sim/tools/algolia/search.ts index fb46fd7dc41..f02901eeaaa 100644 --- a/apps/sim/tools/algolia/search.ts +++ b/apps/sim/tools/algolia/search.ts @@ -56,6 +56,44 @@ export const searchTool: ToolConfig visibility: 'user-or-llm', description: 'Comma-separated list of attributes to retrieve', }, + facets: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Comma-separated list of facet attribute names to retrieve counts for (use "*" for all)', + }, + getRankingInfo: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether to include detailed ranking information in each hit', + }, + aroundLatLng: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Coordinates for geo-search (e.g., "40.71,-74.01")', + }, + aroundRadius: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Maximum radius in meters for geo-search, or "all" for unlimited', + }, + insideBoundingBox: { + type: 'json', + required: false, + visibility: 'user-or-llm', + description: 'Bounding box coordinates as [[lat1, lng1, lat2, lng2]] for geo-search', + }, + insidePolygon: { + type: 'json', + required: false, + visibility: 'user-or-llm', + description: + 'Polygon coordinates as [[lat1, lng1, lat2, lng2, lat3, lng3, ...]] for geo-search', + }, }, request: { @@ -68,7 +106,7 @@ export const searchTool: ToolConfig }), body: (params) => { const request: Record = { - indexName: params.indexName, + indexName: params.indexName.trim(), query: params.query, } if (params.hitsPerPage !== undefined) request.hitsPerPage = Number(params.hitsPerPage) @@ -79,6 +117,26 @@ export const searchTool: ToolConfig .split(',') .map((a: string) => a.trim()) } + if (params.facets) { + request.facets = params.facets.split(',').map((f: string) => f.trim()) + } + if (params.getRankingInfo) request.getRankingInfo = true + if (params.aroundLatLng) request.aroundLatLng = params.aroundLatLng + if (params.aroundRadius !== undefined) { + request.aroundRadius = params.aroundRadius === 'all' ? 'all' : Number(params.aroundRadius) + } + if (params.insideBoundingBox) { + request.insideBoundingBox = + typeof params.insideBoundingBox === 'string' + ? JSON.parse(params.insideBoundingBox) + : params.insideBoundingBox + } + if (params.insidePolygon) { + request.insidePolygon = + typeof params.insidePolygon === 'string' + ? JSON.parse(params.insidePolygon) + : params.insidePolygon + } return { requests: [request] } }, }, diff --git a/apps/sim/tools/algolia/types.ts b/apps/sim/tools/algolia/types.ts index c297871d6d0..3e30293a52a 100644 --- a/apps/sim/tools/algolia/types.ts +++ b/apps/sim/tools/algolia/types.ts @@ -13,6 +13,12 @@ export interface AlgoliaSearchParams extends AlgoliaBaseParams { page?: number | string filters?: string attributesToRetrieve?: string + facets?: string + getRankingInfo?: boolean | string + aroundLatLng?: string + aroundRadius?: number | string + insideBoundingBox?: string | number[][] + insidePolygon?: string | number[][] } export interface AlgoliaSearchResponse extends ToolResponse { @@ -116,6 +122,10 @@ export interface AlgoliaBrowseRecordsParams extends AlgoliaBaseParams { attributesToRetrieve?: string hitsPerPage?: number | string cursor?: string + aroundLatLng?: string + aroundRadius?: number | string + insideBoundingBox?: string | number[][] + insidePolygon?: string | number[][] } export interface AlgoliaBrowseRecordsResponse extends ToolResponse { @@ -266,3 +276,15 @@ export interface AlgoliaDeleteByFilterResponse extends ToolResponse { updatedAt: string | null } } + +// Get Task Status +export interface AlgoliaGetTaskStatusParams extends AlgoliaBaseParams { + indexName: string + taskID: number | string +} + +export interface AlgoliaGetTaskStatusResponse extends ToolResponse { + output: { + status: string + } +} diff --git a/apps/sim/tools/algolia/update_settings.ts b/apps/sim/tools/algolia/update_settings.ts index e8ff7f873aa..e3c4dd367fa 100644 --- a/apps/sim/tools/algolia/update_settings.ts +++ b/apps/sim/tools/algolia/update_settings.ts @@ -49,7 +49,7 @@ export const updateSettingsTool: ToolConfig< request: { url: (params) => { - const base = `https://${params.applicationId}.algolia.net/1/indexes/${encodeURIComponent(params.indexName)}/settings` + const base = `https://${params.applicationId}.algolia.net/1/indexes/${encodeURIComponent(params.indexName.trim())}/settings` if (params.forwardToReplicas) { return `${base}?forwardToReplicas=true` } diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index 57683fc1349..61737af4ccb 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -103,6 +103,7 @@ import { algoliaGetRecordsTool, algoliaGetRecordTool, algoliaGetSettingsTool, + algoliaGetTaskStatusTool, algoliaListIndicesTool, algoliaPartialUpdateRecordTool, algoliaSearchTool, @@ -6651,6 +6652,7 @@ export const tools: Record = { algolia_copy_move_index: algoliaCopyMoveIndexTool, algolia_clear_records: algoliaClearRecordsTool, algolia_delete_by_filter: algoliaDeleteByFilterTool, + algolia_get_task_status: algoliaGetTaskStatusTool, airtable_create_records: airtableCreateRecordsTool, airtable_delete_records: airtableDeleteRecordsTool, airtable_get_base_schema: airtableGetBaseSchemaTool, From 5966e5cf5abecd0a6c809a44b8d61ffa3023c98e Mon Sep 17 00:00:00 2001 From: Waleed Date: Thu, 2 Jul 2026 10:12:53 -0700 Subject: [PATCH 07/28] feat(similarweb): add page views tool, fix paid referrals field mismatch (#5354) * feat(similarweb): add page views tool, fix paid referrals field mismatch - Add similarweb_page_views tool (Total Page Views, Desktop & Mobile) - Fix paidReferrals in website overview: API Lite response key is literally "paid _referrals" (space before underscore), not caught by the existing fallback chain, so it always resolved to null - Move startDate/endDate/mainDomainOnly to advanced mode * fix(similarweb): accept both page_views key spellings in page views response Cursor Bugbot and Greptile both flagged the response field as page_views by convention, but SimilarWeb's own docs example response uses pages_views for this specific endpoint. Accept both spellings defensively so the tool works regardless of which is actually returned. --- apps/sim/blocks/blocks/similarweb.ts | 6 + apps/sim/tools/registry.ts | 2 + apps/sim/tools/similarweb/index.ts | 1 + apps/sim/tools/similarweb/page_views.ts | 148 ++++++++++++++++++ apps/sim/tools/similarweb/types.ts | 21 +++ apps/sim/tools/similarweb/website_overview.ts | 8 +- 6 files changed, 185 insertions(+), 1 deletion(-) create mode 100644 apps/sim/tools/similarweb/page_views.ts diff --git a/apps/sim/blocks/blocks/similarweb.ts b/apps/sim/blocks/blocks/similarweb.ts index 535a706ca69..5810267316a 100644 --- a/apps/sim/blocks/blocks/similarweb.ts +++ b/apps/sim/blocks/blocks/similarweb.ts @@ -26,6 +26,7 @@ export const SimilarwebBlock: BlockConfig = { { label: 'Bounce Rate', id: 'similarweb_bounce_rate' }, { label: 'Pages Per Visit', id: 'similarweb_pages_per_visit' }, { label: 'Visit Duration (Desktop)', id: 'similarweb_visit_duration' }, + { label: 'Page Views', id: 'similarweb_page_views' }, ], value: () => 'similarweb_website_overview', }, @@ -88,6 +89,7 @@ export const SimilarwebBlock: BlockConfig = { title: 'Start Date', type: 'short-input', placeholder: 'YYYY-MM (e.g., 2024-01)', + mode: 'advanced', condition: { field: 'operation', value: 'similarweb_website_overview', @@ -112,6 +114,7 @@ Return ONLY the date string in YYYY-MM format - no explanations, no quotes, no e title: 'End Date', type: 'short-input', placeholder: 'YYYY-MM (e.g., 2024-12)', + mode: 'advanced', condition: { field: 'operation', value: 'similarweb_website_overview', @@ -134,6 +137,7 @@ Return ONLY the date string in YYYY-MM format - no explanations, no quotes, no e id: 'mainDomainOnly', title: 'Main Domain Only', type: 'switch', + mode: 'advanced', condition: { field: 'operation', value: 'similarweb_website_overview', @@ -157,6 +161,7 @@ Return ONLY the date string in YYYY-MM format - no explanations, no quotes, no e 'similarweb_bounce_rate', 'similarweb_pages_per_visit', 'similarweb_visit_duration', + 'similarweb_page_views', ], config: { tool: (params) => params.operation, @@ -197,6 +202,7 @@ Return ONLY the date string in YYYY-MM format - no explanations, no quotes, no e bounceRate: { type: 'json', description: 'Bounce rate data over time' }, pagesPerVisit: { type: 'json', description: 'Pages per visit data over time' }, averageVisitDuration: { type: 'json', description: 'Desktop visit duration data over time' }, + pageViews: { type: 'json', description: 'Page view data over time' }, }, } diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index 61737af4ccb..3b8aa8db8d8 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -3198,6 +3198,7 @@ import { import { similarwebBounceRateTool, similarwebPagesPerVisitTool, + similarwebPageViewsTool, similarwebTrafficVisitsTool, similarwebVisitDurationTool, similarwebWebsiteOverviewTool, @@ -5166,6 +5167,7 @@ export const tools: Record = { similarweb_bounce_rate: similarwebBounceRateTool, similarweb_pages_per_visit: similarwebPagesPerVisitTool, similarweb_visit_duration: similarwebVisitDurationTool, + similarweb_page_views: similarwebPageViewsTool, servicenow_create_record: servicenowCreateRecordTool, servicenow_read_record: servicenowReadRecordTool, servicenow_update_record: servicenowUpdateRecordTool, diff --git a/apps/sim/tools/similarweb/index.ts b/apps/sim/tools/similarweb/index.ts index 8418ba21ca1..8f5e4acdeff 100644 --- a/apps/sim/tools/similarweb/index.ts +++ b/apps/sim/tools/similarweb/index.ts @@ -1,4 +1,5 @@ export { similarwebBounceRateTool } from './bounce_rate' +export { similarwebPageViewsTool } from './page_views' export { similarwebPagesPerVisitTool } from './pages_per_visit' export { similarwebTrafficVisitsTool } from './traffic_visits' export * from './types' diff --git a/apps/sim/tools/similarweb/page_views.ts b/apps/sim/tools/similarweb/page_views.ts new file mode 100644 index 00000000000..87bf4ad11a6 --- /dev/null +++ b/apps/sim/tools/similarweb/page_views.ts @@ -0,0 +1,148 @@ +import type { + SimilarwebPageViewsParams, + SimilarwebPageViewsResponse, +} from '@/tools/similarweb/types' +import type { ToolConfig } from '@/tools/types' + +export const similarwebPageViewsTool: ToolConfig< + SimilarwebPageViewsParams, + SimilarwebPageViewsResponse +> = { + id: 'similarweb_page_views', + name: 'SimilarWeb Page Views', + description: 'Get total page views over time (desktop and mobile combined)', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'SimilarWeb API key', + }, + domain: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Website domain to analyze (e.g., "example.com" without www or protocol)', + }, + country: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + '2-letter ISO country code (e.g., "us", "gb", "de") or "world" for worldwide data', + }, + granularity: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Data granularity: daily, weekly, or monthly', + }, + startDate: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Start date in YYYY-MM format (e.g., "2024-01")', + }, + endDate: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'End date in YYYY-MM format (e.g., "2024-12")', + }, + mainDomainOnly: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Exclude subdomains from results', + }, + }, + + request: { + url: (params) => { + const domain = params.domain + ?.trim() + .replace(/^(https?:\/\/)?(www\.)?/, '') + .replace(/\/$/, '') + const url = new URL( + `https://api.similarweb.com/v1/website/${domain}/total-traffic-and-engagement/page-views` + ) + url.searchParams.set('api_key', params.apiKey?.trim()) + url.searchParams.set('country', params.country?.trim() ?? 'world') + url.searchParams.set('granularity', params.granularity ?? 'monthly') + url.searchParams.set('format', 'json') + if (params.startDate) url.searchParams.set('start_date', params.startDate) + if (params.endDate) url.searchParams.set('end_date', params.endDate) + if (params.mainDomainOnly !== undefined) + url.searchParams.set('main_domain_only', String(params.mainDomainOnly)) + return url.toString() + }, + method: 'GET', + headers: () => ({ + Accept: 'application/json', + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error?.message || data.message || 'Failed to get page views') + } + + const meta = data.meta ?? {} + const request = meta.request ?? {} + + return { + success: true, + output: { + domain: request.domain ?? null, + country: request.country ?? null, + granularity: request.granularity ?? null, + lastUpdated: meta.last_updated ?? null, + // SimilarWeb's own docs example response uses "pages_views" (with the extra "s") for + // this endpoint, unlike its sibling total-traffic-and-engagement endpoints; fall back + // to "page_views" too in case that spelling changes or varies by account. + pageViews: + (data.pages_views ?? data.page_views)?.map( + (p: { date: string; pages_views?: number; page_views?: number }) => ({ + date: p.date, + pageViews: p.pages_views ?? p.page_views ?? 0, + }) + ) ?? [], + }, + } + }, + + outputs: { + domain: { + type: 'string', + description: 'Analyzed domain', + }, + country: { + type: 'string', + description: 'Country filter applied', + }, + granularity: { + type: 'string', + description: 'Data granularity', + }, + lastUpdated: { + type: 'string', + description: 'Data last updated timestamp', + optional: true, + }, + pageViews: { + type: 'array', + description: 'Page view data over time', + items: { + type: 'object', + properties: { + date: { type: 'string', description: 'Date (YYYY-MM-DD)' }, + pageViews: { type: 'number', description: 'Total page views' }, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/similarweb/types.ts b/apps/sim/tools/similarweb/types.ts index d8d542ae866..2ae5efd7b6c 100644 --- a/apps/sim/tools/similarweb/types.ts +++ b/apps/sim/tools/similarweb/types.ts @@ -117,6 +117,27 @@ export interface SimilarwebPagesPerVisitResponse extends ToolResponse { } } +/** + * Page Views parameters + */ +export interface SimilarwebPageViewsParams extends SimilarwebTimeSeriesParams {} + +/** + * Page Views response + */ +export interface SimilarwebPageViewsResponse extends ToolResponse { + output: { + domain: string + country: string + granularity: string + lastUpdated: string | null + pageViews: Array<{ + date: string + pageViews: number + }> + } +} + /** * Average Visit Duration parameters */ diff --git a/apps/sim/tools/similarweb/website_overview.ts b/apps/sim/tools/similarweb/website_overview.ts index c48d2cb54a4..c0667082a58 100644 --- a/apps/sim/tools/similarweb/website_overview.ts +++ b/apps/sim/tools/similarweb/website_overview.ts @@ -116,7 +116,13 @@ export const similarwebWebsiteOverviewTool: ToolConfig< search: sources.Search ?? sources.search ?? null, social: sources.Social ?? sources.social ?? null, mail: sources.Mail ?? sources.mail ?? null, - paidReferrals: sources['Paid Referrals'] ?? sources.paid_referrals ?? null, + paidReferrals: + sources['Paid Referrals'] ?? + // SimilarWeb's API Lite response literally uses "paid _referrals" (space before + // the underscore) as the key for this field. + sources['paid _referrals'] ?? + sources.paid_referrals ?? + null, }, }, } From f658e6dc7302b3bd23e4b7b5eaf054430c2e208e Mon Sep 17 00:00:00 2001 From: Waleed Date: Thu, 2 Jul 2026 10:15:06 -0700 Subject: [PATCH 08/28] fix(tailscale): align tool coverage and outputs with the Tailscale API (#5366) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(tailscale): align tool coverage and outputs with the Tailscale API - fix list_users profilePicUrl field name (API returns lowercase, was always null) - add nodeId, keyExpiryDisabled, expires to device outputs - quote the If-Match header value on ACL updates per API spec - add set_acl, expire_device_key, suspend_user, delete_user tools - add wandConfig to dnsServers/searchPaths block fields - expand BlockMeta skills/templates for ACL and key-expiry workflows * fix(tailscale): remove phantom magicDNS field from list_dns_nameservers The GET /tailnet/{tailnet}/dns/nameservers response only returns {dns: string[]} per the API spec — magicDNS is not part of this endpoint's response and was always silently false. * fix(tailscale): extend ToolResponse in expire_device_key response type Matches the pattern used by the other new tools in this PR (delete_user, suspend_user). * fix(tailscale): list_auth_keys now returns all tailnet keys GET /tailnet/{tailnet}/keys silently scopes to the caller's own keys unless all=true is passed, contradicting the tool's stated purpose of listing all auth keys in the tailnet. --- apps/sim/blocks/blocks/tailscale.ts | 77 ++++++++++++++- apps/sim/tools/registry.ts | 8 ++ apps/sim/tools/tailscale/delete_user.ts | 77 +++++++++++++++ apps/sim/tools/tailscale/expire_device_key.ts | 74 ++++++++++++++ apps/sim/tools/tailscale/get_device.ts | 19 +++- apps/sim/tools/tailscale/index.ts | 4 + apps/sim/tools/tailscale/list_auth_keys.ts | 2 +- apps/sim/tools/tailscale/list_devices.ts | 11 ++- .../tools/tailscale/list_dns_nameservers.ts | 4 +- apps/sim/tools/tailscale/list_users.ts | 2 +- apps/sim/tools/tailscale/set_acl.ts | 97 +++++++++++++++++++ apps/sim/tools/tailscale/suspend_user.ts | 77 +++++++++++++++ apps/sim/tools/tailscale/types.ts | 4 +- 13 files changed, 443 insertions(+), 13 deletions(-) create mode 100644 apps/sim/tools/tailscale/delete_user.ts create mode 100644 apps/sim/tools/tailscale/expire_device_key.ts create mode 100644 apps/sim/tools/tailscale/set_acl.ts create mode 100644 apps/sim/tools/tailscale/suspend_user.ts diff --git a/apps/sim/blocks/blocks/tailscale.ts b/apps/sim/blocks/blocks/tailscale.ts index 690bb0a772e..a4f3a583172 100644 --- a/apps/sim/blocks/blocks/tailscale.ts +++ b/apps/sim/blocks/blocks/tailscale.ts @@ -29,6 +29,7 @@ export const TailscaleBlock: BlockConfig = { { label: 'Get Device Routes', id: 'get_device_routes' }, { label: 'Set Device Routes', id: 'set_device_routes' }, { label: 'Update Device Key', id: 'update_device_key' }, + { label: 'Expire Device Key', id: 'expire_device_key' }, { label: 'List DNS Nameservers', id: 'list_dns_nameservers' }, { label: 'Set DNS Nameservers', id: 'set_dns_nameservers' }, { label: 'Get DNS Preferences', id: 'get_dns_preferences' }, @@ -36,11 +37,14 @@ export const TailscaleBlock: BlockConfig = { { label: 'Get DNS Search Paths', id: 'get_dns_searchpaths' }, { label: 'Set DNS Search Paths', id: 'set_dns_searchpaths' }, { label: 'List Users', id: 'list_users' }, + { label: 'Suspend User', id: 'suspend_user' }, + { label: 'Delete User', id: 'delete_user' }, { label: 'Create Auth Key', id: 'create_auth_key' }, { label: 'List Auth Keys', id: 'list_auth_keys' }, { label: 'Get Auth Key', id: 'get_auth_key' }, { label: 'Delete Auth Key', id: 'delete_auth_key' }, { label: 'Get ACL', id: 'get_acl' }, + { label: 'Set ACL', id: 'set_acl' }, ], value: () => 'list_devices', }, @@ -74,6 +78,7 @@ export const TailscaleBlock: BlockConfig = { 'get_device_routes', 'set_device_routes', 'update_device_key', + 'expire_device_key', ], }, required: { @@ -86,6 +91,7 @@ export const TailscaleBlock: BlockConfig = { 'get_device_routes', 'set_device_routes', 'update_device_key', + 'expire_device_key', ], }, }, @@ -144,6 +150,11 @@ export const TailscaleBlock: BlockConfig = { placeholder: '8.8.8.8,8.8.4.4', condition: { field: 'operation', value: 'set_dns_nameservers' }, required: { field: 'operation', value: 'set_dns_nameservers' }, + wandConfig: { + enabled: true, + prompt: + 'Generate a comma-separated list of DNS nameserver IP addresses (e.g., 8.8.8.8,8.8.4.4). Return ONLY the comma-separated IP addresses - no explanations, no extra text.', + }, }, { id: 'magicDNS', @@ -163,6 +174,11 @@ export const TailscaleBlock: BlockConfig = { placeholder: 'corp.example.com,internal.example.com', condition: { field: 'operation', value: 'set_dns_searchpaths' }, required: { field: 'operation', value: 'set_dns_searchpaths' }, + wandConfig: { + enabled: true, + prompt: + 'Generate a comma-separated list of DNS search path domains (e.g., corp.example.com,internal.example.com). Return ONLY the comma-separated domains - no explanations, no extra text.', + }, }, { id: 'keyId', @@ -224,6 +240,30 @@ export const TailscaleBlock: BlockConfig = { condition: { field: 'operation', value: 'create_auth_key' }, mode: 'advanced', }, + { + id: 'userId', + title: 'User ID', + type: 'short-input', + placeholder: 'Enter user ID', + condition: { field: 'operation', value: ['suspend_user', 'delete_user'] }, + required: { field: 'operation', value: ['suspend_user', 'delete_user'] }, + }, + { + id: 'acl', + title: 'ACL Policy', + type: 'long-input', + placeholder: '{"acls": [{"action": "accept", "users": ["*"], "ports": ["*:*"]}]}', + condition: { field: 'operation', value: 'set_acl' }, + required: { field: 'operation', value: 'set_acl' }, + }, + { + id: 'ifMatch', + title: 'If-Match ETag', + type: 'short-input', + placeholder: 'ETag from Get ACL, or "ts-default"', + condition: { field: 'operation', value: 'set_acl' }, + mode: 'advanced', + }, ], tools: { @@ -236,6 +276,7 @@ export const TailscaleBlock: BlockConfig = { 'tailscale_get_device_routes', 'tailscale_set_device_routes', 'tailscale_update_device_key', + 'tailscale_expire_device_key', 'tailscale_list_dns_nameservers', 'tailscale_set_dns_nameservers', 'tailscale_get_dns_preferences', @@ -243,11 +284,14 @@ export const TailscaleBlock: BlockConfig = { 'tailscale_get_dns_searchpaths', 'tailscale_set_dns_searchpaths', 'tailscale_list_users', + 'tailscale_suspend_user', + 'tailscale_delete_user', 'tailscale_create_auth_key', 'tailscale_list_auth_keys', 'tailscale_get_auth_key', 'tailscale_delete_auth_key', 'tailscale_get_acl', + 'tailscale_set_acl', ], config: { tool: (params) => `tailscale_${params.operation}`, @@ -258,10 +302,13 @@ export const TailscaleBlock: BlockConfig = { } if (params.deviceId) mapped.deviceId = params.deviceId if (params.keyId) mapped.keyId = params.keyId + if (params.userId) mapped.userId = params.userId if (params.tags) mapped.tags = params.tags if (params.routes) mapped.routes = params.routes if (params.dnsServers) mapped.dns = params.dnsServers if (params.searchPaths) mapped.searchPaths = params.searchPaths + if (params.acl) mapped.acl = params.acl + if (params.ifMatch) mapped.ifMatch = params.ifMatch if (params.authorized !== undefined) mapped.authorized = params.authorized === 'true' if (params.keyExpiryDisabled !== undefined) mapped.keyExpiryDisabled = params.keyExpiryDisabled === 'true' @@ -282,6 +329,9 @@ export const TailscaleBlock: BlockConfig = { tailnet: { type: 'string', description: 'Tailnet name' }, deviceId: { type: 'string', description: 'Device ID' }, keyId: { type: 'string', description: 'Auth key ID' }, + userId: { type: 'string', description: 'User ID' }, + acl: { type: 'string', description: 'ACL policy file as a JSON string' }, + ifMatch: { type: 'string', description: 'ETag for optimistic concurrency on ACL updates' }, authorized: { type: 'string', description: 'Authorization status' }, keyExpiryDisabled: { type: 'string', description: 'Whether to disable key expiry' }, tags: { type: 'string', description: 'Comma-separated tags' }, @@ -300,6 +350,7 @@ export const TailscaleBlock: BlockConfig = { devices: { type: 'json', description: 'List of devices in the tailnet' }, count: { type: 'number', description: 'Total count of items returned' }, id: { type: 'string', description: 'Device or auth key ID' }, + nodeId: { type: 'string', description: 'Preferred device ID' }, name: { type: 'string', description: 'Device name' }, hostname: { type: 'string', description: 'Device hostname' }, user: { type: 'string', description: 'Associated user' }, @@ -331,11 +382,12 @@ export const TailscaleBlock: BlockConfig = { key: { type: 'string', description: 'Auth key value (only at creation)' }, keyId: { type: 'string', description: 'Auth key ID' }, description: { type: 'string', description: 'Auth key description' }, - expires: { type: 'string', description: 'Expiration timestamp' }, + expires: { type: 'string', description: 'Device key or auth key expiration timestamp' }, revoked: { type: 'string', description: 'Revocation timestamp' }, capabilities: { type: 'json', description: 'Auth key capabilities' }, acl: { type: 'string', description: 'ACL policy as JSON string' }, etag: { type: 'string', description: 'ACL ETag for conditional updates' }, + userId: { type: 'string', description: 'User ID' }, }, } @@ -356,7 +408,7 @@ export const TailscaleBlockMeta = { icon: TailscaleIcon, title: 'Tailscale ACL drift detector', prompt: - 'Create a scheduled workflow that diffs Tailscale ACLs against the source of truth, alerts on drift, and writes the drift report to Slack.', + 'Create a scheduled workflow that diffs Tailscale ACLs against the source of truth, alerts on drift to Slack, and, on approval, pushes the corrected policy back with Set ACL.', modules: ['scheduled', 'agent', 'workflows'], category: 'engineering', tags: ['devops', 'monitoring'], @@ -376,7 +428,7 @@ export const TailscaleBlockMeta = { icon: TailscaleIcon, title: 'Tailscale offboarder', prompt: - "Create a workflow that on a Workday termination deletes the departing engineer's Tailscale devices, revokes their auth keys, and writes the security audit log.", + "Create a workflow that on a Workday termination deletes the departing engineer's Tailscale devices, revokes their auth keys, suspends their tailnet user account, and writes the security audit log.", modules: ['agent', 'workflows'], category: 'operations', tags: ['hr', 'enterprise'], @@ -429,9 +481,24 @@ export const TailscaleBlockMeta = { }, { name: 'offboard-device', - description: 'Deauthorize or remove a departing user device and revoke its auth keys.', + description: + "Deauthorize or remove a departing user's devices and auth keys, then suspend or delete their tailnet account.", + content: + '# Offboard a Tailscale Device\n\nRemove a device from the tailnet during offboarding so access is cut cleanly.\n\n## Steps\n1. Use List Devices to find the deviceId tied to the departing user.\n2. To immediately cut access use Authorize Device set to Deauthorize, or Delete Device to remove it entirely.\n3. Use List Auth Keys to find any keys the user created, then Delete Auth Key for each.\n4. Use List Users to find the userId, then Suspend User to freeze access reversibly, or Delete User to remove the account entirely.\n5. Capture the device detail with Get Device before deletion if you need an audit record.\n\n## Output\nConfirm the device was deauthorized or deleted, the auth keys were revoked, and the user account was suspended or deleted for the offboarding audit log.', + }, + { + name: 'update-tailnet-acl', + description: + 'Push an updated ACL policy file to the tailnet using ETag-guarded writes to avoid clobbering concurrent edits.', + content: + '# Update the Tailnet ACL as Policy-as-Code\n\nApply a reviewed ACL change programmatically instead of editing it by hand in the admin console.\n\n## Steps\n1. Use Get ACL to fetch the current policy file and its etag.\n2. Compute or generate the new policy JSON from your source of truth (git, agent-authored rules, etc.).\n3. Use Set ACL with the new ACL Policy JSON, passing the etag from step 1 in If-Match to guard against concurrent updates (use "ts-default" instead if you only want to replace an untouched default policy).\n4. If the write fails with a precondition error, re-fetch the ACL and retry.\n\n## Output\nReturn the updated ACL JSON and its new etag, plus a summary of what changed for the change-management record.', + }, + { + name: 'lock-down-compromised-device', + description: + "Immediately expire a suspected-compromised device's node key so it must re-authenticate before rejoining the tailnet.", content: - '# Offboard a Tailscale Device\n\nRemove a device from the tailnet during offboarding so access is cut cleanly.\n\n## Steps\n1. Use List Devices to find the deviceId tied to the departing user.\n2. To immediately cut access use Authorize Device set to Deauthorize, or Delete Device to remove it entirely.\n3. Use List Auth Keys to find any keys the user created, then Delete Auth Key for each.\n4. Capture the device detail with Get Device before deletion if you need an audit record.\n\n## Output\nConfirm the device was deauthorized or deleted and list the revoked auth keys for the offboarding audit log.', + "# Lock Down a Compromised Device\n\nCut off a device the moment it looks compromised, without waiting for its key to expire naturally.\n\n## Steps\n1. Use Get Device or List Devices to confirm the deviceId and review its tags, addresses, and lastSeen.\n2. Use Expire Device Key to immediately invalidate the device's node key so it can no longer connect until it re-authenticates.\n3. For a harder block, follow up with Authorize Device set to Deauthorize, or Delete Device to remove it outright.\n4. Log the deviceId, hostname, and user in the incident record.\n\n## Output\nConfirm the key was expired (and the device deauthorized/deleted if applicable) for the security incident log.", }, ], } as const satisfies BlockMeta diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index 3b8aa8db8d8..d7e5eda0107 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -3731,6 +3731,8 @@ import { tailscaleCreateAuthKeyTool, tailscaleDeleteAuthKeyTool, tailscaleDeleteDeviceTool, + tailscaleDeleteUserTool, + tailscaleExpireDeviceKeyTool, tailscaleGetAclTool, tailscaleGetAuthKeyTool, tailscaleGetDeviceRoutesTool, @@ -3741,11 +3743,13 @@ import { tailscaleListDevicesTool, tailscaleListDnsNameserversTool, tailscaleListUsersTool, + tailscaleSetAclTool, tailscaleSetDeviceRoutesTool, tailscaleSetDeviceTagsTool, tailscaleSetDnsNameserversTool, tailscaleSetDnsPreferencesTool, tailscaleSetDnsSearchpathsTool, + tailscaleSuspendUserTool, tailscaleUpdateDeviceKeyTool, } from '@/tools/tailscale' import { tavilyCrawlTool, tavilyExtractTool, tavilyMapTool, tavilySearchTool } from '@/tools/tavily' @@ -5215,6 +5219,7 @@ export const tools: Record = { tailscale_get_device_routes: tailscaleGetDeviceRoutesTool, tailscale_set_device_routes: tailscaleSetDeviceRoutesTool, tailscale_update_device_key: tailscaleUpdateDeviceKeyTool, + tailscale_expire_device_key: tailscaleExpireDeviceKeyTool, tailscale_list_dns_nameservers: tailscaleListDnsNameserversTool, tailscale_set_dns_nameservers: tailscaleSetDnsNameserversTool, tailscale_get_dns_preferences: tailscaleGetDnsPreferencesTool, @@ -5222,11 +5227,14 @@ export const tools: Record = { tailscale_get_dns_searchpaths: tailscaleGetDnsSearchpathsTool, tailscale_set_dns_searchpaths: tailscaleSetDnsSearchpathsTool, tailscale_list_users: tailscaleListUsersTool, + tailscale_suspend_user: tailscaleSuspendUserTool, + tailscale_delete_user: tailscaleDeleteUserTool, tailscale_create_auth_key: tailscaleCreateAuthKeyTool, tailscale_list_auth_keys: tailscaleListAuthKeysTool, tailscale_get_auth_key: tailscaleGetAuthKeyTool, tailscale_delete_auth_key: tailscaleDeleteAuthKeyTool, tailscale_get_acl: tailscaleGetAclTool, + tailscale_set_acl: tailscaleSetAclTool, calendly_get_current_user: calendlyGetCurrentUserTool, calendly_list_event_types: calendlyListEventTypesTool, calendly_get_event_type: calendlyGetEventTypeTool, diff --git a/apps/sim/tools/tailscale/delete_user.ts b/apps/sim/tools/tailscale/delete_user.ts new file mode 100644 index 00000000000..374c8a7c291 --- /dev/null +++ b/apps/sim/tools/tailscale/delete_user.ts @@ -0,0 +1,77 @@ +import type { ToolConfig, ToolResponse } from '@/tools/types' +import type { TailscaleBaseParams } from './types' + +interface TailscaleDeleteUserParams extends TailscaleBaseParams { + userId: string +} + +interface TailscaleDeleteUserResponse extends ToolResponse { + output: { + success: boolean + userId: string + } +} + +export const tailscaleDeleteUserTool: ToolConfig< + TailscaleDeleteUserParams, + TailscaleDeleteUserResponse +> = { + id: 'tailscale_delete_user', + name: 'Tailscale Delete User', + description: 'Delete a user from the tailnet', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Tailscale API key', + }, + tailnet: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Tailnet name (e.g., example.com) or "-" for default', + }, + userId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'User ID to delete', + }, + }, + + request: { + url: (params) => + `https://api.tailscale.com/api/v2/users/${encodeURIComponent(params.userId.trim())}/delete`, + method: 'POST', + headers: (params) => ({ + Authorization: `Bearer ${params.apiKey.trim()}`, + }), + }, + + transformResponse: async (response: Response, params?: TailscaleDeleteUserParams) => { + if (!response.ok) { + const data = await response.json().catch(() => ({})) + return { + success: false, + output: { success: false, userId: '' }, + error: (data as Record).message ?? 'Failed to delete user', + } + } + + return { + success: true, + output: { + success: true, + userId: params?.userId ?? '', + }, + } + }, + + outputs: { + success: { type: 'boolean', description: 'Whether the user was successfully deleted' }, + userId: { type: 'string', description: 'ID of the deleted user' }, + }, +} diff --git a/apps/sim/tools/tailscale/expire_device_key.ts b/apps/sim/tools/tailscale/expire_device_key.ts new file mode 100644 index 00000000000..70801d7871c --- /dev/null +++ b/apps/sim/tools/tailscale/expire_device_key.ts @@ -0,0 +1,74 @@ +import type { ToolConfig, ToolResponse } from '@/tools/types' +import type { TailscaleDeviceParams } from './types' + +interface TailscaleExpireDeviceKeyResponse extends ToolResponse { + output: { + success: boolean + deviceId: string + } +} + +export const tailscaleExpireDeviceKeyTool: ToolConfig< + TailscaleDeviceParams, + TailscaleExpireDeviceKeyResponse +> = { + id: 'tailscale_expire_device_key', + name: 'Tailscale Expire Device Key', + description: + "Immediately expire a device's node key, requiring it to re-authenticate before it can reconnect to the tailnet", + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Tailscale API key', + }, + tailnet: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Tailnet name (e.g., example.com) or "-" for default', + }, + deviceId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Device ID to expire the key for', + }, + }, + + request: { + url: (params) => + `https://api.tailscale.com/api/v2/device/${encodeURIComponent(params.deviceId.trim())}/expire`, + method: 'POST', + headers: (params) => ({ + Authorization: `Bearer ${params.apiKey.trim()}`, + }), + }, + + transformResponse: async (response: Response, params?: TailscaleDeviceParams) => { + if (!response.ok) { + const data = await response.json().catch(() => ({})) + return { + success: false, + output: { success: false, deviceId: '' }, + error: (data as Record).message ?? 'Failed to expire device key', + } + } + + return { + success: true, + output: { + success: true, + deviceId: params?.deviceId ?? '', + }, + } + }, + + outputs: { + success: { type: 'boolean', description: "Whether the device's key was successfully expired" }, + deviceId: { type: 'string', description: 'Device ID' }, + }, +} diff --git a/apps/sim/tools/tailscale/get_device.ts b/apps/sim/tools/tailscale/get_device.ts index fe3ba670a7c..eaa2f7d0b90 100644 --- a/apps/sim/tools/tailscale/get_device.ts +++ b/apps/sim/tools/tailscale/get_device.ts @@ -45,6 +45,7 @@ export const tailscaleGetDeviceTool: ToolConfig - `https://api.tailscale.com/api/v2/tailnet/${encodeURIComponent(params.tailnet.trim())}/keys`, + `https://api.tailscale.com/api/v2/tailnet/${encodeURIComponent(params.tailnet.trim())}/keys?all=true`, method: 'GET', headers: (params) => ({ Authorization: `Bearer ${params.apiKey.trim()}`, diff --git a/apps/sim/tools/tailscale/list_devices.ts b/apps/sim/tools/tailscale/list_devices.ts index b55835d4828..fec321170bf 100644 --- a/apps/sim/tools/tailscale/list_devices.ts +++ b/apps/sim/tools/tailscale/list_devices.ts @@ -47,6 +47,7 @@ export const tailscaleListDevicesTool: ToolConfig< const data = await response.json() const devices = (data.devices ?? []).map((device: Record) => ({ id: (device.id as string) ?? null, + nodeId: (device.nodeId as string) ?? null, name: (device.name as string) ?? null, hostname: (device.hostname as string) ?? null, user: (device.user as string) ?? null, @@ -56,6 +57,8 @@ export const tailscaleListDevicesTool: ToolConfig< tags: (device.tags as string[]) ?? [], authorized: (device.authorized as boolean) ?? false, blocksIncomingConnections: (device.blocksIncomingConnections as boolean) ?? false, + keyExpiryDisabled: (device.keyExpiryDisabled as boolean) ?? false, + expires: (device.expires as string) ?? null, lastSeen: (device.lastSeen as string) ?? null, created: (device.created as string) ?? null, })) @@ -76,7 +79,8 @@ export const tailscaleListDevicesTool: ToolConfig< items: { type: 'object', properties: { - id: { type: 'string', description: 'Device ID' }, + id: { type: 'string', description: 'Legacy device ID' }, + nodeId: { type: 'string', description: 'Preferred device ID' }, name: { type: 'string', description: 'Device name' }, hostname: { type: 'string', description: 'Device hostname' }, user: { type: 'string', description: 'Associated user' }, @@ -89,6 +93,11 @@ export const tailscaleListDevicesTool: ToolConfig< type: 'boolean', description: 'Whether the device blocks incoming connections', }, + keyExpiryDisabled: { + type: 'boolean', + description: 'Whether the device key is exempt from expiring', + }, + expires: { type: 'string', description: "The device's auth key expiration timestamp" }, lastSeen: { type: 'string', description: 'Last seen timestamp' }, created: { type: 'string', description: 'Creation timestamp' }, }, diff --git a/apps/sim/tools/tailscale/list_dns_nameservers.ts b/apps/sim/tools/tailscale/list_dns_nameservers.ts index 67b0ac6745c..fa3bb18c378 100644 --- a/apps/sim/tools/tailscale/list_dns_nameservers.ts +++ b/apps/sim/tools/tailscale/list_dns_nameservers.ts @@ -39,7 +39,7 @@ export const tailscaleListDnsNameserversTool: ToolConfig< const data = await response.json().catch(() => ({})) return { success: false, - output: { dns: [], magicDNS: false }, + output: { dns: [] }, error: (data as Record).message ?? 'Failed to list DNS nameservers', } } @@ -49,13 +49,11 @@ export const tailscaleListDnsNameserversTool: ToolConfig< success: true, output: { dns: data.dns ?? [], - magicDNS: data.magicDNS ?? false, }, } }, outputs: { dns: { type: 'array', description: 'List of DNS nameserver addresses' }, - magicDNS: { type: 'boolean', description: 'Whether MagicDNS is enabled' }, }, } diff --git a/apps/sim/tools/tailscale/list_users.ts b/apps/sim/tools/tailscale/list_users.ts index 100719d637b..5d0c58c28fd 100644 --- a/apps/sim/tools/tailscale/list_users.ts +++ b/apps/sim/tools/tailscale/list_users.ts @@ -46,7 +46,7 @@ export const tailscaleListUsersTool: ToolConfig = { + id: 'tailscale_set_acl', + name: 'Tailscale Set ACL', + description: 'Replace the ACL policy file for the tailnet', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Tailscale API key', + }, + tailnet: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Tailnet name (e.g., example.com) or "-" for default', + }, + acl: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The new ACL policy file, as a JSON string', + }, + ifMatch: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'ETag from a prior Get ACL call to avoid overwriting concurrent updates. Use "ts-default" to only replace an untouched default policy file.', + }, + }, + + request: { + url: (params) => + `https://api.tailscale.com/api/v2/tailnet/${encodeURIComponent(params.tailnet.trim())}/acl`, + method: 'POST', + headers: (params) => { + const headers: Record = { + Authorization: `Bearer ${params.apiKey.trim()}`, + 'Content-Type': 'application/json', + Accept: 'application/json', + } + if (params.ifMatch) headers['If-Match'] = `"${params.ifMatch.trim().replace(/^"|"$/g, '')}"` + return headers + }, + body: (params) => params.acl.trim(), + }, + + transformResponse: async (response) => { + if (!response.ok) { + const data = await response.json().catch(() => ({})) + return { + success: false, + output: { acl: '', etag: '' }, + error: (data as Record).message ?? 'Failed to set ACL', + } + } + + const etag = response.headers.get('ETag') ?? '' + const data = await response.json() + + return { + success: true, + output: { + acl: JSON.stringify(data, null, 2), + etag, + }, + } + }, + + outputs: { + acl: { type: 'string', description: 'Updated ACL policy as JSON string' }, + etag: { + type: 'string', + description: 'ETag for the new ACL version (use with If-Match header for future updates)', + optional: true, + }, + }, +} diff --git a/apps/sim/tools/tailscale/suspend_user.ts b/apps/sim/tools/tailscale/suspend_user.ts new file mode 100644 index 00000000000..ac744155da5 --- /dev/null +++ b/apps/sim/tools/tailscale/suspend_user.ts @@ -0,0 +1,77 @@ +import type { ToolConfig, ToolResponse } from '@/tools/types' +import type { TailscaleBaseParams } from './types' + +interface TailscaleSuspendUserParams extends TailscaleBaseParams { + userId: string +} + +interface TailscaleSuspendUserResponse extends ToolResponse { + output: { + success: boolean + userId: string + } +} + +export const tailscaleSuspendUserTool: ToolConfig< + TailscaleSuspendUserParams, + TailscaleSuspendUserResponse +> = { + id: 'tailscale_suspend_user', + name: 'Tailscale Suspend User', + description: "Suspend a user's access to the tailnet", + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Tailscale API key', + }, + tailnet: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Tailnet name (e.g., example.com) or "-" for default', + }, + userId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'User ID to suspend', + }, + }, + + request: { + url: (params) => + `https://api.tailscale.com/api/v2/users/${encodeURIComponent(params.userId.trim())}/suspend`, + method: 'POST', + headers: (params) => ({ + Authorization: `Bearer ${params.apiKey.trim()}`, + }), + }, + + transformResponse: async (response: Response, params?: TailscaleSuspendUserParams) => { + if (!response.ok) { + const data = await response.json().catch(() => ({})) + return { + success: false, + output: { success: false, userId: '' }, + error: (data as Record).message ?? 'Failed to suspend user', + } + } + + return { + success: true, + output: { + success: true, + userId: params?.userId ?? '', + }, + } + }, + + outputs: { + success: { type: 'boolean', description: 'Whether the user was successfully suspended' }, + userId: { type: 'string', description: 'ID of the suspended user' }, + }, +} diff --git a/apps/sim/tools/tailscale/types.ts b/apps/sim/tools/tailscale/types.ts index 6f358964089..e97fa82953b 100644 --- a/apps/sim/tools/tailscale/types.ts +++ b/apps/sim/tools/tailscale/types.ts @@ -32,6 +32,7 @@ export interface TailscaleCreateAuthKeyParams extends TailscaleBaseParams { interface TailscaleDeviceOutput { id: string + nodeId: string name: string hostname: string user: string @@ -41,6 +42,8 @@ interface TailscaleDeviceOutput { tags: string[] authorized: boolean blocksIncomingConnections: boolean + keyExpiryDisabled: boolean + expires: string lastSeen: string created: string } @@ -126,7 +129,6 @@ export interface TailscaleSetDeviceRoutesResponse extends ToolResponse { export interface TailscaleListDnsNameserversResponse extends ToolResponse { output: { dns: string[] - magicDNS: boolean } } From 59d6b8a62ec4dae62b508900bf67760712f9efad Mon Sep 17 00:00:00 2001 From: Waleed Date: Thu, 2 Jul 2026 10:23:48 -0700 Subject: [PATCH 09/28] fix(onepassword): validate integration against API docs, add file downloads (#5365) * fix(onepassword): validate integration against API docs, add file downloads - add onepassword_get_item_file tool + route for downloading item file attachments (SDK items.files.read / Connect files/{id}/content), backed by newly-exposed item.files metadata on get/create/replace/update item - fix update_item JSON Patch applying array indices instead of 1Password's documented field-ID addressing (/fields/{fieldId}/...), which silently dropped field edits in Service Account mode - fix Service Account mode's list-vaults/list-items filter to honor SCIM `eq` exact-match semantics instead of always substring-matching - expand the create-item category dropdown from 9 to 19 real, creatable 1Password categories (was missing SOFTWARE_LICENSE, EMAIL_ACCOUNT, MEMBERSHIP, PASSPORT, REWARD_PROGRAM, DRIVER_LICENSE, BANK_ACCOUNT, MEDICAL_RECORD, OUTDOOR_LICENSE, WIRELESS_ROUTER, SOCIAL_SECURITY_NUMBER) - replace the block's single opaque `response: json` output with typed, per-operation output fields matching repo convention - remove incorrect password-masking on the Vault ID field (not a secret) - re-export tool types from the onepassword barrel * fix(onepassword): honor SCIM attribute name in filter matcher matchesFilter always compared against name/title regardless of the attribute named in the eq expression, so `id eq "..."` incorrectly matched against the display name instead of the id. * fix(onepassword): close output-parity and doc-string gaps from final audit - restore a deprecated no-op 'response' output so pre-existing saved workflows referencing it fail soft (empty) instead of hard-erroring now that per-operation outputs replace it - add missing block outputs (urls, favorite, version, state, lastEditedBy) for get/create/replace/update item so all real FULL_ITEM fields are discoverable as references - hide Connect Server credential fields for Resolve Secret (Service Account only) instead of leaving them selectable and silently ignored - correct two doc-string enum lists that advertised values the API doesn't return (vault type TRANSFER, item state DELETED) * fix(onepassword): fix silent data loss in update_item (Service Account mode) update_item applied user JSON Patch ops (documented/typed against the Connect-shaped vocabulary get_item returns: label/type/section.id) directly onto the raw SDK item, whose vocabulary differs (title/ fieldType/sectionId, and SDK category enum strings vs Connect's SCREAMING_SNAKE_CASE). Most patches beyond /title, /tags/-, and /fields/{id}/value silently no-opped or could corrupt the item while still reporting success. Extracted the Connect->SDK item conversion already used by replace_item into a shared connectItemToSdkItem helper. update_item now normalizes the fetched item to Connect shape, applies patches to that, then converts back before calling items.put() -- matching create/replace's existing translation pattern. Found via an adversarial final-verification pass that traced concrete patch operations by hand against the SDK's actual field vocabulary. * fix(onepassword): preserve field metadata and empty-title fallback connectItemToSdkItem rebuilt every field as a bare object, dropping SDK-only metadata (e.g. password-generation details) that a raw patch/replace previously left untouched. Now merges onto the existing SDK field by id before applying the translated properties, and only starts fields bare when they're genuinely new. Also restored the || (not ??) fallback on title to match replace_item's prior behavior of treating an explicitly empty title as "not provided". --- .../tools/onepassword/get-item-file/route.ts | 106 ++++++++ .../api/tools/onepassword/list-items/route.ts | 9 +- .../tools/onepassword/list-vaults/route.ts | 8 +- .../tools/onepassword/replace-item/route.ts | 39 +-- .../tools/onepassword/update-item/route.ts | 43 +++- apps/sim/app/api/tools/onepassword/utils.ts | 124 +++++++++ apps/sim/blocks/blocks/onepassword.ts | 236 +++++++++++++++++- .../lib/api/contracts/tools/onepassword.ts | 23 ++ apps/sim/tools/onepassword/create_item.ts | 2 +- apps/sim/tools/onepassword/get_item_file.ts | 103 ++++++++ apps/sim/tools/onepassword/get_vault.ts | 2 +- apps/sim/tools/onepassword/index.ts | 4 + apps/sim/tools/onepassword/list_items.ts | 2 +- apps/sim/tools/onepassword/list_vaults.ts | 2 +- apps/sim/tools/onepassword/types.ts | 23 ++ apps/sim/tools/onepassword/utils.ts | 32 ++- apps/sim/tools/registry.ts | 2 + scripts/check-api-validation-contracts.ts | 4 +- 18 files changed, 695 insertions(+), 69 deletions(-) create mode 100644 apps/sim/app/api/tools/onepassword/get-item-file/route.ts create mode 100644 apps/sim/tools/onepassword/get_item_file.ts diff --git a/apps/sim/app/api/tools/onepassword/get-item-file/route.ts b/apps/sim/app/api/tools/onepassword/get-item-file/route.ts new file mode 100644 index 00000000000..65efec88e06 --- /dev/null +++ b/apps/sim/app/api/tools/onepassword/get-item-file/route.ts @@ -0,0 +1,106 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { type NextRequest, NextResponse } from 'next/server' +import { onePasswordGetItemFileContract } from '@/lib/api/contracts/tools/onepassword' +import { parseRequest, validationErrorResponse } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + connectRequest, + createOnePasswordClient, + findItemFileAttributes, + resolveCredentials, +} from '../utils' + +const logger = createLogger('OnePasswordGetItemFileAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateId().slice(0, 8) + + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + logger.warn(`[${requestId}] Unauthorized 1Password get-item-file attempt`) + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + try { + const parsed = await parseRequest( + onePasswordGetItemFileContract, + request, + {}, + { + validationErrorResponse: (error) => validationErrorResponse(error, 'Invalid request data'), + } + ) + if (!parsed.success) return parsed.response + const params = parsed.data.body + const creds = resolveCredentials(params) + + logger.info( + `[${requestId}] Downloading file ${params.fileId} from item ${params.itemId} (${creds.mode} mode)` + ) + + if (creds.mode === 'service_account') { + const client = await createOnePasswordClient(creds.serviceAccountToken!) + const item = await client.items.get(params.vaultId, params.itemId) + const attr = findItemFileAttributes(item, params.fileId) + if (!attr) { + return NextResponse.json({ error: 'File not found on item' }, { status: 404 }) + } + + const content = await client.items.files.read(params.vaultId, params.itemId, attr) + return NextResponse.json({ + file: { + name: attr.name, + mimeType: 'application/octet-stream', + data: Buffer.from(content).toString('base64'), + size: attr.size, + }, + }) + } + + const metaResponse = await connectRequest({ + serverUrl: creds.serverUrl!, + apiKey: creds.apiKey!, + path: `/v1/vaults/${params.vaultId}/items/${params.itemId}/files/${params.fileId}`, + method: 'GET', + }) + if (!metaResponse.ok) { + const metaData = await metaResponse.json().catch(() => ({})) + return NextResponse.json( + { error: metaData.message || 'Failed to get file metadata' }, + { status: metaResponse.status } + ) + } + const meta = await metaResponse.json() + + const contentResponse = await connectRequest({ + serverUrl: creds.serverUrl!, + apiKey: creds.apiKey!, + path: `/v1/vaults/${params.vaultId}/items/${params.itemId}/files/${params.fileId}/content`, + method: 'GET', + }) + if (!contentResponse.ok) { + const errorData = await contentResponse.json().catch(() => ({})) + return NextResponse.json( + { error: errorData.message || 'Failed to download file content' }, + { status: contentResponse.status } + ) + } + + const buffer = Buffer.from(await contentResponse.arrayBuffer()) + return NextResponse.json({ + file: { + name: meta.name ?? 'attachment', + mimeType: contentResponse.headers.get('content-type') || 'application/octet-stream', + data: buffer.toString('base64'), + size: meta.size ?? buffer.length, + }, + }) + } catch (error) { + const message = getErrorMessage(error, 'Unknown error') + logger.error(`[${requestId}] Get item file failed:`, error) + return NextResponse.json({ error: `Failed to get item file: ${message}` }, { status: 500 }) + } +}) diff --git a/apps/sim/app/api/tools/onepassword/list-items/route.ts b/apps/sim/app/api/tools/onepassword/list-items/route.ts index daeb42e807d..395d0955ced 100644 --- a/apps/sim/app/api/tools/onepassword/list-items/route.ts +++ b/apps/sim/app/api/tools/onepassword/list-items/route.ts @@ -9,6 +9,7 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { connectRequest, createOnePasswordClient, + matchesFilter, normalizeSdkItemOverview, resolveCredentials, } from '../utils' @@ -45,11 +46,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const normalized = items.map(normalizeSdkItemOverview) if (params.filter) { - const filterLower = params.filter.toLowerCase() - const filtered = normalized.filter( - (item) => - item.title?.toLowerCase().includes(filterLower) || - item.id?.toLowerCase().includes(filterLower) + const filter = params.filter + const filtered = normalized.filter((item) => + matchesFilter(item.title ?? '', item.id ?? '', filter) ) return NextResponse.json(filtered) } diff --git a/apps/sim/app/api/tools/onepassword/list-vaults/route.ts b/apps/sim/app/api/tools/onepassword/list-vaults/route.ts index fa4011daa70..3638db1c2d7 100644 --- a/apps/sim/app/api/tools/onepassword/list-vaults/route.ts +++ b/apps/sim/app/api/tools/onepassword/list-vaults/route.ts @@ -9,6 +9,7 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { connectRequest, createOnePasswordClient, + matchesFilter, normalizeSdkVault, resolveCredentials, } from '../utils' @@ -45,11 +46,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const normalized = vaults.map(normalizeSdkVault) if (params.filter) { - const filterLower = params.filter.toLowerCase() - const filtered = normalized.filter( - (v) => - v.name?.toLowerCase().includes(filterLower) || v.id?.toLowerCase().includes(filterLower) - ) + const filter = params.filter + const filtered = normalized.filter((v) => matchesFilter(v.name ?? '', v.id ?? '', filter)) return NextResponse.json(filtered) } diff --git a/apps/sim/app/api/tools/onepassword/replace-item/route.ts b/apps/sim/app/api/tools/onepassword/replace-item/route.ts index 0f2ee44b76b..67d7a7b10f8 100644 --- a/apps/sim/app/api/tools/onepassword/replace-item/route.ts +++ b/apps/sim/app/api/tools/onepassword/replace-item/route.ts @@ -1,4 +1,3 @@ -import type { Item } from '@1password/sdk' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' @@ -8,12 +7,11 @@ import { parseRequest, validationErrorResponse } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { + connectItemToSdkItem, connectRequest, createOnePasswordClient, normalizeSdkItem, resolveCredentials, - toSdkCategory, - toSdkFieldType, } from '../utils' const logger = createLogger('OnePasswordReplaceItemAPI') @@ -49,40 +47,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const client = await createOnePasswordClient(creds.serviceAccountToken!) const existing = await client.items.get(params.vaultId, params.itemId) - - const sdkItem = { - ...existing, - id: params.itemId, - title: itemData.title || existing.title, - category: itemData.category ? toSdkCategory(itemData.category) : existing.category, - vaultId: params.vaultId, - fields: itemData.fields - ? (itemData.fields as Array>).map((f) => ({ - id: f.id || generateId().slice(0, 8), - title: f.label || f.title || '', - fieldType: toSdkFieldType(f.type || 'STRING'), - value: f.value || '', - sectionId: f.section?.id ?? f.sectionId, - })) - : existing.fields, - sections: itemData.sections - ? (itemData.sections as Array>).map((s) => ({ - id: s.id || '', - title: s.label || s.title || '', - })) - : existing.sections, - notes: itemData.notes ?? existing.notes, - tags: itemData.tags ?? existing.tags, - websites: - itemData.urls || itemData.websites - ? (itemData.urls ?? itemData.websites ?? []).map((u: Record) => ({ - url: u.href || u.url || '', - label: u.label || '', - autofillBehavior: 'AnywhereOnWebsite' as const, - })) - : existing.websites, - } as Item - + const sdkItem = connectItemToSdkItem(itemData, existing) const result = await client.items.put(sdkItem) return NextResponse.json(normalizeSdkItem(result)) } diff --git a/apps/sim/app/api/tools/onepassword/update-item/route.ts b/apps/sim/app/api/tools/onepassword/update-item/route.ts index f027e95c45d..70fa927b992 100644 --- a/apps/sim/app/api/tools/onepassword/update-item/route.ts +++ b/apps/sim/app/api/tools/onepassword/update-item/route.ts @@ -7,6 +7,7 @@ import { parseRequest, validationErrorResponse } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { + connectItemToSdkItem, connectRequest, createOnePasswordClient, normalizeSdkItem, @@ -45,13 +46,21 @@ export const POST = withRouteHandler(async (request: NextRequest) => { if (creds.mode === 'service_account') { const client = await createOnePasswordClient(creds.serviceAccountToken!) - const item = await client.items.get(params.vaultId, params.itemId) + const existing = await client.items.get(params.vaultId, params.itemId) + // Patch operations are documented and typed against the Connect-shaped + // vocabulary (label/type/section.id) that get_item/create_item/replace_item + // return — apply them to that normalized view, then convert back to the + // SDK's vocabulary (title/fieldType/sectionId) before writing. Patching the + // raw SDK item directly would silently no-op most field/category writes. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const connectItem = normalizeSdkItem(existing) as Record for (const op of ops) { - applyPatch(item, op) + applyPatch(connectItem, op) } - const result = await client.items.put(item) + const sdkItem = connectItemToSdkItem(connectItem, existing) + const result = await client.items.put(sdkItem) return NextResponse.json(normalizeSdkItem(result)) } @@ -104,7 +113,7 @@ function applyPatch(item: Record, op: JsonPatchOperation) { for (let i = 0; i < segments.length - 1; i++) { const seg = segments[i] if (Array.isArray(target)) { - target = target[Number(seg)] + target = arrayElementForSegment(target, seg) } else { target = target[seg] } @@ -117,15 +126,37 @@ function applyPatch(item: Record, op: JsonPatchOperation) { if (Array.isArray(target) && lastSeg === '-') { target.push(op.value) } else if (Array.isArray(target)) { - target[Number(lastSeg)] = op.value + const index = arrayIndexForSegment(target, lastSeg) + if (index !== -1) target[index] = op.value } else { target[lastSeg] = op.value } } else if (op.op === 'remove') { if (Array.isArray(target)) { - target.splice(Number(lastSeg), 1) + const index = arrayIndexForSegment(target, lastSeg) + if (index !== -1) target.splice(index, 1) } else { delete target[lastSeg] } } } + +/** + * Resolves an array element for a JSON Patch path segment. 1Password's PATCH API + * addresses items in the `fields`/`sections` arrays by their `id`, not by numeric + * array index (e.g. `/fields/{fieldId}/value`), so a numeric-looking segment is + * only treated as a literal index when no element's `id` matches it. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function arrayIndexForSegment(target: any[], segment: string): number { + const byId = target.findIndex((el) => el && typeof el === 'object' && el.id === segment) + if (byId !== -1) return byId + const index = Number(segment) + return Number.isInteger(index) && index >= 0 && index < target.length ? index : -1 +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function arrayElementForSegment(target: any[], segment: string): any { + const index = arrayIndexForSegment(target, segment) + return index === -1 ? undefined : target[index] +} diff --git a/apps/sim/app/api/tools/onepassword/utils.ts b/apps/sim/app/api/tools/onepassword/utils.ts index 87c5e090da0..b78cf0d511c 100644 --- a/apps/sim/app/api/tools/onepassword/utils.ts +++ b/apps/sim/app/api/tools/onepassword/utils.ts @@ -1,9 +1,11 @@ import dns from 'dns/promises' import type { + FileAttributes, Item, ItemCategory, ItemField, ItemFieldType, + ItemFile, ItemOverview, ItemSection, VaultOverview, @@ -11,6 +13,7 @@ import type { } from '@1password/sdk' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' import * as ipaddr from 'ipaddr.js' import { isHosted } from '@/lib/core/config/env-flags' import { @@ -102,10 +105,19 @@ interface NormalizedField { entropy: null } +/** Normalized attached-file metadata shape matching the Connect API response. */ +export interface NormalizedItemFile { + id: string + name: string + size: number + section: { id: string } | null +} + /** Normalized full item shape matching the Connect API response. */ export interface NormalizedItem extends NormalizedItemOverview { fields: NormalizedField[] sections: Array<{ id: string; label: string }> + files: NormalizedItemFile[] } /** @@ -323,9 +335,11 @@ export interface ConnectResponse { ok: boolean status: number statusText: string + headers: { get: (name: string) => string | null } // eslint-disable-next-line @typescript-eslint/no-explicit-any json: () => Promise text: () => Promise + arrayBuffer: () => Promise } /** Proxy a request to the 1Password Connect Server. */ @@ -431,6 +445,24 @@ export function normalizeSdkItem(item: Item): NormalizedItem { id: section.id, label: section.title, })), + files: [ + ...(item.files ?? []).map((file: ItemFile) => ({ + id: file.attributes.id, + name: file.attributes.name, + size: file.attributes.size, + section: file.sectionId ? { id: file.sectionId } : null, + })), + ...(item.document + ? [ + { + id: item.document.id, + name: item.document.name, + size: item.document.size, + section: null, + }, + ] + : []), + ], createdAt: item.createdAt instanceof Date ? item.createdAt.toISOString() : (item.createdAt ?? null), updatedAt: @@ -439,6 +471,98 @@ export function normalizeSdkItem(item: Item): NormalizedItem { } } +/** + * Find an attached file's SDK {@link FileAttributes} on an item by file ID. + * Checks both the `files` array and the single `document` attribute that + * Document-category items carry instead of a `files` entry. + */ +export function findItemFileAttributes(item: Item, fileId: string): FileAttributes | undefined { + if (item.document?.id === fileId) return item.document + return item.files?.find((file) => file.attributes.id === fileId)?.attributes +} + +/** + * Convert a Connect-shaped item (the vocabulary `normalizeSdkItem` produces and + * this integration's tools document — `label`/`type`/`section: {id}`) back into + * an SDK-compatible {@link Item} for `client.items.put()`. Falls back to `existing` + * for any array the caller didn't provide, so partial input (e.g. Replace Item's + * optional fields) is preserved. + * + * Service Account mode must always convert through this function before calling + * `put()` — never apply a Connect-shaped JSON Patch directly onto a raw SDK + * {@link Item}, since SDK field/category vocabulary differs from Connect's + * (`title` vs `label`, `fieldType` vs `type`, `sectionId` vs `section.id`, SDK + * category enum strings vs Connect's SCREAMING_SNAKE_CASE) and silently no-ops or + * corrupts the write otherwise. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function connectItemToSdkItem(connectItem: Record, existing: Item): Item { + const existingFieldsById = new Map((existing.fields ?? []).map((f) => [f.id, f])) + const existingSectionsById = new Map((existing.sections ?? []).map((s) => [s.id, s])) + + return { + ...existing, + id: existing.id, + vaultId: existing.vaultId, + title: connectItem.title || existing.title, + category: connectItem.category ? toSdkCategory(connectItem.category) : existing.category, + fields: Array.isArray(connectItem.fields) + ? // eslint-disable-next-line @typescript-eslint/no-explicit-any + connectItem.fields.map((f: Record) => ({ + // Preserve any SDK-only metadata (e.g. password-generation `details`) + // on fields that already existed — only brand-new fields start bare. + ...(f.id ? existingFieldsById.get(f.id) : undefined), + id: f.id || generateId().slice(0, 8), + title: f.label || f.title || '', + fieldType: toSdkFieldType(f.type || 'STRING'), + value: f.value || '', + sectionId: f.section?.id ?? f.sectionId, + })) + : existing.fields, + sections: Array.isArray(connectItem.sections) + ? // eslint-disable-next-line @typescript-eslint/no-explicit-any + connectItem.sections.map((s: Record) => ({ + ...(s.id ? existingSectionsById.get(s.id) : undefined), + id: s.id || '', + title: s.label || s.title || '', + })) + : existing.sections, + notes: connectItem.notes ?? existing.notes, + tags: connectItem.tags ?? existing.tags, + websites: Array.isArray(connectItem.urls ?? connectItem.websites) + ? (connectItem.urls ?? connectItem.websites).map( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (u: Record) => ({ + url: u.href || u.url || '', + label: u.label || '', + autofillBehavior: 'AnywhereOnWebsite' as const, + }) + ) + : existing.websites, + } as Item +} + +/** + * Best-effort SCIM `eq` filter matcher for Service Account mode, which has no + * server-side filtering (unlike Connect, whose `filter` query param is forwarded + * verbatim and evaluated by the Connect server). Recognizes `attribute eq "value"` + * (quotes optional) as an exact, case-insensitive match against the named attribute + * — `id` compares against the id, anything else (name/title/etc.) against the + * display value; anything that doesn't parse as `eq` falls back to a + * case-insensitive substring match against both so the field remains useful for + * free-text search. + */ +export function matchesFilter(value: string, id: string, filter: string): boolean { + const eqMatch = filter.match(/^\s*(\S+)\s+eq\s+"?([^"]*)"?\s*$/i) + if (eqMatch) { + const [, attribute, needle] = eqMatch + const target = attribute.toLowerCase() === 'id' ? id : value + return target.toLowerCase() === needle.toLowerCase() + } + const needle = filter.toLowerCase() + return value.toLowerCase().includes(needle) || id.toLowerCase().includes(needle) +} + /** Convert a Connect-style category string to the SDK category string. */ export function toSdkCategory(category: string): `${ItemCategory}` { return CONNECT_TO_SDK_CATEGORY[category] ?? 'Login' diff --git a/apps/sim/blocks/blocks/onepassword.ts b/apps/sim/blocks/blocks/onepassword.ts index c5e9b61271d..084ef242d92 100644 --- a/apps/sim/blocks/blocks/onepassword.ts +++ b/apps/sim/blocks/blocks/onepassword.ts @@ -6,7 +6,7 @@ export const OnePasswordBlock: BlockConfig = { name: '1Password', description: 'Manage secrets and items in 1Password vaults', longDescription: - 'Access and manage secrets stored in 1Password vaults using the Connect API or Service Account SDK. List vaults, retrieve items with their fields and secrets, create new items, update existing ones, delete items, and resolve secret references.', + 'Access and manage secrets stored in 1Password vaults using the Connect API or Service Account SDK. List vaults, retrieve items with their fields and secrets, download attached files, create new items, update existing ones, delete items, and resolve secret references.', docsLink: 'https://docs.sim.ai/integrations/onepassword', category: 'tools', integrationType: IntegrationType.Security, @@ -24,6 +24,7 @@ export const OnePasswordBlock: BlockConfig = { { label: 'Get Vault', id: 'get_vault' }, { label: 'List Items', id: 'list_items' }, { label: 'Get Item', id: 'get_item' }, + { label: 'Get Item File', id: 'get_item_file' }, { label: 'Create Item', id: 'create_item' }, { label: 'Replace Item', id: 'replace_item' }, { label: 'Update Item', id: 'update_item' }, @@ -56,8 +57,16 @@ export const OnePasswordBlock: BlockConfig = { title: 'Server URL', type: 'short-input', placeholder: 'http://localhost:8080', - required: { field: 'connectionMode', value: 'connect' }, - condition: { field: 'connectionMode', value: 'connect' }, + required: { + field: 'connectionMode', + value: 'connect', + and: { field: 'operation', value: 'resolve_secret', not: true }, + }, + condition: { + field: 'connectionMode', + value: 'connect', + and: { field: 'operation', value: 'resolve_secret', not: true }, + }, }, { id: 'apiKey', @@ -65,8 +74,16 @@ export const OnePasswordBlock: BlockConfig = { type: 'short-input', placeholder: 'Enter your 1Password Connect token', password: true, - required: { field: 'connectionMode', value: 'connect' }, - condition: { field: 'connectionMode', value: 'connect' }, + required: { + field: 'connectionMode', + value: 'connect', + and: { field: 'operation', value: 'resolve_secret', not: true }, + }, + condition: { + field: 'connectionMode', + value: 'connect', + and: { field: 'operation', value: 'resolve_secret', not: true }, + }, }, { id: 'secretReference', @@ -93,13 +110,13 @@ Return ONLY the op:// URI - no explanations, no quotes, no markdown.`, title: 'Vault ID', type: 'short-input', placeholder: 'Enter vault UUID', - password: true, required: { field: 'operation', value: [ 'get_vault', 'list_items', 'get_item', + 'get_item_file', 'create_item', 'replace_item', 'update_item', @@ -119,19 +136,38 @@ Return ONLY the op:// URI - no explanations, no quotes, no markdown.`, placeholder: 'Enter item UUID', required: { field: 'operation', - value: ['get_item', 'replace_item', 'update_item', 'delete_item'], + value: ['get_item', 'get_item_file', 'replace_item', 'update_item', 'delete_item'], }, condition: { field: 'operation', - value: ['get_item', 'replace_item', 'update_item', 'delete_item'], + value: ['get_item', 'get_item_file', 'replace_item', 'update_item', 'delete_item'], }, }, + { + id: 'fileId', + title: 'File ID', + type: 'short-input', + placeholder: 'Enter file ID (from Get Item output)', + required: { field: 'operation', value: 'get_item_file' }, + condition: { field: 'operation', value: 'get_item_file' }, + }, { id: 'filter', title: 'Filter', type: 'short-input', placeholder: 'SCIM filter (e.g., name eq "My Vault")', condition: { field: 'operation', value: ['list_vaults', 'list_items'] }, + mode: 'advanced', + wandConfig: { + enabled: true, + prompt: `Generate a SCIM filter expression for a 1Password vault or item list based on the user's description. +Examples: +- name eq "My Vault" +- title eq "API Key" +- tag eq "production" + +Return ONLY the SCIM filter expression - no explanations, no quotes, no markdown.`, + }, }, { id: 'category', @@ -147,6 +183,17 @@ Return ONLY the op:// URI - no explanations, no quotes, no markdown.`, { label: 'Credit Card', id: 'CREDIT_CARD' }, { label: 'Identity', id: 'IDENTITY' }, { label: 'SSH Key', id: 'SSH_KEY' }, + { label: 'Software License', id: 'SOFTWARE_LICENSE' }, + { label: 'Email Account', id: 'EMAIL_ACCOUNT' }, + { label: 'Membership', id: 'MEMBERSHIP' }, + { label: 'Passport', id: 'PASSPORT' }, + { label: 'Reward Program', id: 'REWARD_PROGRAM' }, + { label: 'Driver License', id: 'DRIVER_LICENSE' }, + { label: 'Bank Account', id: 'BANK_ACCOUNT' }, + { label: 'Medical Record', id: 'MEDICAL_RECORD' }, + { label: 'Outdoor License', id: 'OUTDOOR_LICENSE' }, + { label: 'Wireless Router', id: 'WIRELESS_ROUTER' }, + { label: 'Social Security Number', id: 'SOCIAL_SECURITY_NUMBER' }, ], value: () => 'LOGIN', required: { field: 'operation', value: 'create_item' }, @@ -231,6 +278,7 @@ Return ONLY valid JSON - no explanations, no markdown code blocks.`, 'onepassword_get_vault', 'onepassword_list_items', 'onepassword_get_item', + 'onepassword_get_item_file', 'onepassword_create_item', 'onepassword_replace_item', 'onepassword_update_item', @@ -251,6 +299,7 @@ Return ONLY valid JSON - no explanations, no markdown code blocks.`, secretReference: { type: 'string', description: 'Secret reference URI (op://...)' }, vaultId: { type: 'string', description: 'Vault UUID' }, itemId: { type: 'string', description: 'Item UUID' }, + fileId: { type: 'string', description: 'File ID of an attachment on the item' }, filter: { type: 'string', description: 'SCIM filter expression' }, category: { type: 'string', description: 'Item category' }, title: { type: 'string', description: 'Item title' }, @@ -263,7 +312,176 @@ Return ONLY valid JSON - no explanations, no markdown code blocks.`, outputs: { response: { type: 'json', - description: 'Operation response data', + description: + 'Deprecated — kept for backward compatibility with workflows saved before per-operation outputs were added below. Never populated; use the operation-specific outputs instead.', + }, + vaults: { + type: 'json', + description: + 'List of accessible vaults [{id, name, description, items, type, createdAt, updatedAt}]', + condition: { field: 'operation', value: 'list_vaults' }, + }, + id: { + type: 'string', + description: 'Vault or item ID', + condition: { + field: 'operation', + value: ['get_vault', 'get_item', 'create_item', 'replace_item', 'update_item'], + }, + }, + name: { + type: 'string', + description: 'Vault name', + condition: { field: 'operation', value: 'get_vault' }, + }, + description: { + type: 'string', + description: 'Vault description', + condition: { field: 'operation', value: 'get_vault' }, + }, + items: { + type: 'json', + description: + 'Number of items in the vault (Get Vault) or item summaries [{id, title, category, tags, favorite, version, updatedAt}] (List Items)', + condition: { field: 'operation', value: ['get_vault', 'list_items'] }, + }, + type: { + type: 'string', + description: 'Vault type (USER_CREATED, PERSONAL, or EVERYONE)', + condition: { field: 'operation', value: 'get_vault' }, + }, + title: { + type: 'string', + description: 'Item title', + condition: { + field: 'operation', + value: ['get_item', 'create_item', 'replace_item', 'update_item'], + }, + }, + category: { + type: 'string', + description: 'Item category (e.g., LOGIN, API_CREDENTIAL, SECURE_NOTE)', + condition: { + field: 'operation', + value: ['get_item', 'create_item', 'replace_item', 'update_item'], + }, + }, + vault: { + type: 'json', + description: 'Vault reference the item belongs to {id}', + condition: { + field: 'operation', + value: ['get_item', 'create_item', 'replace_item', 'update_item'], + }, + }, + fields: { + type: 'json', + description: 'Item fields including secrets [{id, label, type, purpose, value}]', + condition: { + field: 'operation', + value: ['get_item', 'create_item', 'replace_item', 'update_item'], + }, + }, + sections: { + type: 'json', + description: 'Item sections [{id, label}]', + condition: { + field: 'operation', + value: ['get_item', 'create_item', 'replace_item', 'update_item'], + }, + }, + files: { + type: 'json', + description: + 'Files attached to the item [{id, name, size, section}] — fetch content with Get Item File', + condition: { + field: 'operation', + value: ['get_item', 'create_item', 'replace_item', 'update_item'], + }, + }, + tags: { + type: 'json', + description: 'Item tags', + condition: { + field: 'operation', + value: ['get_item', 'create_item', 'replace_item', 'update_item'], + }, + }, + urls: { + type: 'json', + description: 'URLs associated with the item [{href, label, primary}]', + condition: { + field: 'operation', + value: ['get_item', 'create_item', 'replace_item', 'update_item'], + }, + }, + favorite: { + type: 'boolean', + description: 'Whether the item is favorited', + condition: { + field: 'operation', + value: ['get_item', 'create_item', 'replace_item', 'update_item'], + }, + }, + version: { + type: 'number', + description: 'Item version number', + condition: { + field: 'operation', + value: ['get_item', 'create_item', 'replace_item', 'update_item'], + }, + }, + state: { + type: 'string', + description: 'Item state (ARCHIVED, or absent/null when active)', + condition: { + field: 'operation', + value: ['get_item', 'create_item', 'replace_item', 'update_item'], + }, + }, + lastEditedBy: { + type: 'string', + description: 'ID of the last editor', + condition: { + field: 'operation', + value: ['get_item', 'create_item', 'replace_item', 'update_item'], + }, + }, + createdAt: { + type: 'string', + description: 'Creation timestamp', + condition: { + field: 'operation', + value: ['get_vault', 'get_item', 'create_item', 'replace_item', 'update_item'], + }, + }, + updatedAt: { + type: 'string', + description: 'Last update timestamp', + condition: { + field: 'operation', + value: ['get_vault', 'get_item', 'create_item', 'replace_item', 'update_item'], + }, + }, + success: { + type: 'boolean', + description: 'Whether the item was successfully deleted', + condition: { field: 'operation', value: 'delete_item' }, + }, + value: { + type: 'string', + description: 'The resolved secret value', + condition: { field: 'operation', value: 'resolve_secret' }, + }, + reference: { + type: 'string', + description: 'The original secret reference URI', + condition: { field: 'operation', value: 'resolve_secret' }, + }, + file: { + type: 'file', + description: 'Downloaded file attachment', + condition: { field: 'operation', value: 'get_item_file' }, }, }, } diff --git a/apps/sim/lib/api/contracts/tools/onepassword.ts b/apps/sim/lib/api/contracts/tools/onepassword.ts index b67c7fe12e1..349b13296f9 100644 --- a/apps/sim/lib/api/contracts/tools/onepassword.ts +++ b/apps/sim/lib/api/contracts/tools/onepassword.ts @@ -43,6 +43,10 @@ export const onePasswordResolveSecretBodySchema = onePasswordCredentialsBodySche secretReference: z.string().min(1, 'Secret reference is required'), }) +export const onePasswordGetItemFileBodySchema = onePasswordGetItemBodySchema.extend({ + fileId: z.string().min(1, 'File ID is required'), +}) + export const onePasswordListVaultsContract = defineRouteContract({ method: 'POST', path: '/api/tools/onepassword/list-vaults', @@ -148,3 +152,22 @@ export const onePasswordResolveSecretContract = defineRouteContract({ schema: onePasswordResolveSecretResponseSchema, }, }) + +const onePasswordGetItemFileResponseSchema = z.object({ + file: z.object({ + name: z.string(), + mimeType: z.string(), + data: z.string(), + size: z.number(), + }), +}) + +export const onePasswordGetItemFileContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/onepassword/get-item-file', + body: onePasswordGetItemFileBodySchema, + response: { + mode: 'json', + schema: onePasswordGetItemFileResponseSchema, + }, +}) diff --git a/apps/sim/tools/onepassword/create_item.ts b/apps/sim/tools/onepassword/create_item.ts index 5f9b70a0715..feb717f3af2 100644 --- a/apps/sim/tools/onepassword/create_item.ts +++ b/apps/sim/tools/onepassword/create_item.ts @@ -68,7 +68,7 @@ export const createItemTool: ToolConfig< required: false, visibility: 'user-or-llm', description: - 'JSON array of field objects (e.g., [{"label":"username","value":"admin","type":"STRING","purpose":"USERNAME"}])', + 'JSON array of field objects (e.g., [{"label":"username","value":"admin","type":"STRING","purpose":"USERNAME"}]). "purpose" is honored in Connect Server mode; in Service Account mode 1Password infers it from the field label/type instead.', }, }, diff --git a/apps/sim/tools/onepassword/get_item_file.ts b/apps/sim/tools/onepassword/get_item_file.ts new file mode 100644 index 00000000000..f5934886f4f --- /dev/null +++ b/apps/sim/tools/onepassword/get_item_file.ts @@ -0,0 +1,103 @@ +import type { + OnePasswordGetItemFileParams, + OnePasswordGetItemFileResponse, +} from '@/tools/onepassword/types' +import type { ToolConfig } from '@/tools/types' + +export const getItemFileTool: ToolConfig< + OnePasswordGetItemFileParams, + OnePasswordGetItemFileResponse +> = { + id: 'onepassword_get_item_file', + name: '1Password Get Item File', + description: 'Download the content of a file attached to an item', + version: '1.0.0', + + params: { + connectionMode: { + type: 'string', + required: false, + description: 'Connection mode: "service_account" or "connect"', + }, + serviceAccountToken: { + type: 'string', + required: false, + visibility: 'user-only', + description: '1Password Service Account token (for Service Account mode)', + }, + apiKey: { + type: 'string', + required: false, + visibility: 'user-only', + description: '1Password Connect API token (for Connect Server mode)', + }, + serverUrl: { + type: 'string', + required: false, + visibility: 'user-only', + description: '1Password Connect server URL (for Connect Server mode)', + }, + vaultId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The vault UUID', + }, + itemId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The item UUID the file is attached to', + }, + fileId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The file ID (from the item\'s "files" array, e.g. via Get Item)', + }, + }, + + request: { + url: '/api/tools/onepassword/get-item-file', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + connectionMode: params.connectionMode, + serviceAccountToken: params.serviceAccountToken, + serverUrl: params.serverUrl, + apiKey: params.apiKey, + vaultId: params.vaultId, + itemId: params.itemId, + fileId: params.fileId, + }), + }, + + transformResponse: async (response) => { + const data = await response.json() + if (data.error) { + return { + success: false, + output: { file: { name: '', mimeType: '', data: '', size: 0 } }, + error: data.error, + } + } + return { + success: true, + output: { + file: { + name: data.file.name, + mimeType: data.file.mimeType, + data: data.file.data, + size: data.file.size, + }, + }, + } + }, + + outputs: { + file: { + type: 'file', + description: 'Downloaded file attachment', + }, + }, +} diff --git a/apps/sim/tools/onepassword/get_vault.ts b/apps/sim/tools/onepassword/get_vault.ts index cf2b63a4479..30415c78363 100644 --- a/apps/sim/tools/onepassword/get_vault.ts +++ b/apps/sim/tools/onepassword/get_vault.ts @@ -99,7 +99,7 @@ export const getVaultTool: ToolConfig + files: Array<{ + id: string | null + name: string | null + size: number + section: { id: string } | null + }> createdAt: string | null updatedAt: string | null lastEditedBy: string | null @@ -151,6 +163,17 @@ export interface OnePasswordDeleteItemResponse extends ToolResponse { } } +export interface OnePasswordGetItemFileResponse extends ToolResponse { + output: { + file: { + name: string + mimeType: string + data: string + size: number + } + } +} + export interface OnePasswordResolveSecretResponse extends ToolResponse { output: { value: string diff --git a/apps/sim/tools/onepassword/utils.ts b/apps/sim/tools/onepassword/utils.ts index d1abbf9632c..fad1f8fd555 100644 --- a/apps/sim/tools/onepassword/utils.ts +++ b/apps/sim/tools/onepassword/utils.ts @@ -37,6 +37,12 @@ export function transformFullItem(data: any) { id: section.id ?? null, label: section.label ?? null, })), + files: (data.files ?? []).map((file: any) => ({ + id: file.id ?? null, + name: file.name ?? null, + size: file.size ?? 0, + section: file.section ?? null, + })), createdAt: data.createdAt ?? null, updatedAt: data.updatedAt ?? null, lastEditedBy: data.lastEditedBy ?? null, @@ -83,7 +89,11 @@ export const FULL_ITEM_OUTPUTS: Record< favorite: { type: 'boolean', description: 'Whether the item is favorited' }, tags: { type: 'array', description: 'Item tags' }, version: { type: 'number', description: 'Item version number' }, - state: { type: 'string', description: 'Item state (ARCHIVED or DELETED)', optional: true }, + state: { + type: 'string', + description: 'Item state (ARCHIVED, or absent/null when active)', + optional: true, + }, fields: { type: 'array', description: 'Item fields including secrets', @@ -142,6 +152,26 @@ export const FULL_ITEM_OUTPUTS: Record< }, }, }, + files: { + type: 'array', + description: 'Files attached to the item (fetch content with Get Item File)', + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'File ID' }, + name: { type: 'string', description: 'File name' }, + size: { type: 'number', description: 'File size in bytes' }, + section: { + type: 'object', + description: 'Section reference this file belongs to', + optional: true, + properties: { + id: { type: 'string', description: 'Section ID' }, + }, + }, + }, + }, + }, createdAt: { type: 'string', description: 'Creation timestamp', optional: true }, updatedAt: { type: 'string', description: 'Last update timestamp', optional: true }, lastEditedBy: { type: 'string', description: 'ID of the last editor', optional: true }, diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index d7e5eda0107..401fae48744 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -2400,6 +2400,7 @@ import { import { onepasswordCreateItemTool, onepasswordDeleteItemTool, + onepasswordGetItemFileTool, onepasswordGetItemTool, onepasswordGetVaultTool, onepasswordListItemsTool, @@ -5352,6 +5353,7 @@ export const tools: Record = { onepassword_get_vault: onepasswordGetVaultTool, onepassword_list_items: onepasswordListItemsTool, onepassword_get_item: onepasswordGetItemTool, + onepassword_get_item_file: onepasswordGetItemFileTool, onepassword_create_item: onepasswordCreateItemTool, onepassword_replace_item: onepasswordReplaceItemTool, onepassword_update_item: onepasswordUpdateItemTool, diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index 0d0d94230aa..80f935786b0 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries') const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors') const BASELINE = { - totalRoutes: 882, - zodRoutes: 882, + totalRoutes: 883, + zodRoutes: 883, nonZodRoutes: 0, } as const From 0507acf2ddfa60063125f16c37c6975defe624b9 Mon Sep 17 00:00:00 2001 From: Waleed Date: Thu, 2 Jul 2026 10:25:59 -0700 Subject: [PATCH 10/28] fix(amplitude): correct wire formats and add funnels/retention analytics (#5355) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(amplitude): validate integration against API docs, add funnels/retention - Fix Identify/Group Identify sending JSON instead of the form-urlencoded body Amplitude requires - Fix Send Event using snake_case product_id/revenue_type instead of Amplitude's camelCase productId/revenueType - Fix Get Revenue parsing a response shape that never matched the real Revenue LTV API - Add EU data residency support across all tools - Add missing filters/formula/segment params to Event Segmentation, groupBy/segment to Active Users and Revenue - Add first_used/last_used to User Activity output, non_active/flow_hidden to List Events output - Add real-time/hourly interval options to Event Segmentation - Add Funnels and Retention tools for conversion and retention analysis - Harden JSON-shape validation across funnels/segmentation/retention (fail loudly on malformed or partial input instead of silently degrading) - Expose every tool output field (user_profile, send_event, user_search) on the block so nothing is unreachable downstream - Update brand colors to current Amplitude guide * fix(amplitude): validate retention brackets, require formula param, add segmentation 2nd group-by - Retention now validates retentionBrackets as a JSON array and requires it when retentionMode is "bracket", matching the block UI's requirement - Event Segmentation now throws if metric is "formula" but no formula is provided, matching the block UI's requirement - Event Segmentation now supports a documented second group-by property via groupBy2/g2 - Corrected Get Active Users' group-by copy — Amplitude's docs don't document a second-property syntax for /api/2/users the way they do for segmentation's g2, so the field no longer overpromises "max two" --- apps/sim/blocks/blocks/amplitude.ts | 443 +++++++++++++++++- .../sim/tools/amplitude/event_segmentation.ts | 63 ++- apps/sim/tools/amplitude/funnels.ts | 195 ++++++++ apps/sim/tools/amplitude/get_active_users.ts | 23 +- apps/sim/tools/amplitude/get_revenue.ts | 49 +- apps/sim/tools/amplitude/group_identify.ts | 33 +- apps/sim/tools/amplitude/identify_user.ts | 19 +- apps/sim/tools/amplitude/index.ts | 4 + apps/sim/tools/amplitude/list_events.ts | 19 +- .../tools/amplitude/realtime_active_users.ts | 9 +- apps/sim/tools/amplitude/retention.ts | 186 ++++++++ apps/sim/tools/amplitude/send_event.ts | 13 +- apps/sim/tools/amplitude/types.ts | 99 +++- apps/sim/tools/amplitude/user_activity.ts | 13 +- apps/sim/tools/amplitude/user_profile.ts | 2 +- apps/sim/tools/amplitude/user_search.ts | 9 +- apps/sim/tools/amplitude/utils.ts | 12 + apps/sim/tools/registry.ts | 4 + 18 files changed, 1150 insertions(+), 45 deletions(-) create mode 100644 apps/sim/tools/amplitude/funnels.ts create mode 100644 apps/sim/tools/amplitude/retention.ts create mode 100644 apps/sim/tools/amplitude/utils.ts diff --git a/apps/sim/blocks/blocks/amplitude.ts b/apps/sim/blocks/blocks/amplitude.ts index a05f9ed5a0c..71ac94a19b1 100644 --- a/apps/sim/blocks/blocks/amplitude.ts +++ b/apps/sim/blocks/blocks/amplitude.ts @@ -6,12 +6,12 @@ export const AmplitudeBlock: BlockConfig = { name: 'Amplitude', description: 'Track events and query analytics from Amplitude', longDescription: - 'Integrate Amplitude into your workflow to track events, identify users and groups, search for users, query analytics, and retrieve revenue data.', + 'Integrate Amplitude into your workflow to track events, identify users and groups, search for users, query analytics, analyze funnels and retention, and retrieve revenue data.', docsLink: 'https://docs.sim.ai/integrations/amplitude', category: 'tools', integrationType: IntegrationType.Analytics, - bgColor: '#1B1F3B', - iconColor: '#1F77E0', + bgColor: '#13294B', + iconColor: '#1E61F0', icon: AmplitudeIcon, authMode: AuthMode.ApiKey, @@ -32,6 +32,8 @@ export const AmplitudeBlock: BlockConfig = { { label: 'Real-time Active Users', id: 'realtime_active_users' }, { label: 'List Events', id: 'list_events' }, { label: 'Get Revenue', id: 'get_revenue' }, + { label: 'Funnels', id: 'funnels' }, + { label: 'Retention', id: 'retention' }, ], value: () => 'send_event', }, @@ -70,6 +72,8 @@ export const AmplitudeBlock: BlockConfig = { 'realtime_active_users', 'list_events', 'get_revenue', + 'funnels', + 'retention', ], }, placeholder: 'Enter your Amplitude Secret Key', @@ -85,10 +89,26 @@ export const AmplitudeBlock: BlockConfig = { 'realtime_active_users', 'list_events', 'get_revenue', + 'funnels', + 'retention', ], }, }, + // Data Residency (all operations except User Profile, which is US-only) + { + id: 'dataResidency', + title: 'Data Residency', + type: 'dropdown', + options: [ + { label: 'US (default)', id: 'us' }, + { label: 'EU', id: 'eu' }, + ], + value: () => 'us', + condition: { field: 'operation', value: 'user_profile', not: true }, + mode: 'advanced', + }, + // --- Send Event fields --- { id: 'eventType', @@ -460,6 +480,8 @@ export const AmplitudeBlock: BlockConfig = { title: 'Interval', type: 'dropdown', options: [ + { label: 'Real-time', id: '-300000' }, + { label: 'Hourly', id: '-3600000' }, { label: 'Daily', id: '1' }, { label: 'Weekly', id: '7' }, { label: 'Monthly', id: '30' }, @@ -476,6 +498,14 @@ export const AmplitudeBlock: BlockConfig = { condition: { field: 'operation', value: 'event_segmentation' }, mode: 'advanced', }, + { + id: 'segmentationGroupBy2', + title: 'Group By (2nd Property)', + type: 'short-input', + placeholder: 'Second property name (prefix custom with "gp:")', + condition: { field: 'operation', value: 'event_segmentation' }, + mode: 'advanced', + }, { id: 'segmentationLimit', title: 'Limit', @@ -484,6 +514,46 @@ export const AmplitudeBlock: BlockConfig = { condition: { field: 'operation', value: 'event_segmentation' }, mode: 'advanced', }, + { + id: 'segmentationFilters', + title: 'Filters', + type: 'long-input', + placeholder: + '[{"subprop_type":"event","subprop_key":"city","subprop_op":"is","subprop_value":["San Francisco"]}]', + condition: { field: 'operation', value: 'event_segmentation' }, + mode: 'advanced', + wandConfig: { + enabled: true, + prompt: + 'Generate a JSON array of Amplitude event segmentation filter objects, each with subprop_type ("event" or "user"), subprop_key, subprop_op (e.g. "is", "is not", "contains"), and subprop_value (array of strings). Return ONLY the JSON array - no explanations, no extra text.', + generationType: 'json-object', + }, + }, + { + id: 'segmentationFormula', + title: 'Formula', + type: 'short-input', + placeholder: 'e.g., UNIQUES(A)/UNIQUES(B) — required when Metric is Formula', + condition: { + field: 'operation', + value: 'event_segmentation', + and: { field: 'segmentationMetric', value: 'formula' }, + }, + required: { + field: 'operation', + value: 'event_segmentation', + and: { field: 'segmentationMetric', value: 'formula' }, + }, + mode: 'advanced', + }, + { + id: 'segmentationSegment', + title: 'Segment Definition', + type: 'long-input', + placeholder: 'JSON segment definition(s)', + condition: { field: 'operation', value: 'event_segmentation' }, + mode: 'advanced', + }, // --- Get Active Users fields --- { @@ -539,6 +609,22 @@ export const AmplitudeBlock: BlockConfig = { condition: { field: 'operation', value: 'get_active_users' }, mode: 'advanced', }, + { + id: 'activeUsersGroupBy', + title: 'Group By', + type: 'short-input', + placeholder: 'Property name', + condition: { field: 'operation', value: 'get_active_users' }, + mode: 'advanced', + }, + { + id: 'activeUsersSegment', + title: 'Segment Definition', + type: 'long-input', + placeholder: 'JSON segment definition(s)', + condition: { field: 'operation', value: 'get_active_users' }, + mode: 'advanced', + }, // --- Get Revenue fields --- { @@ -596,6 +682,243 @@ export const AmplitudeBlock: BlockConfig = { condition: { field: 'operation', value: 'get_revenue' }, mode: 'advanced', }, + { + id: 'revenueGroupBy', + title: 'Group By', + type: 'short-input', + placeholder: 'Property name (limit: one)', + condition: { field: 'operation', value: 'get_revenue' }, + mode: 'advanced', + }, + { + id: 'revenueSegment', + title: 'Segment Definition', + type: 'long-input', + placeholder: 'JSON segment definition(s)', + condition: { field: 'operation', value: 'get_revenue' }, + mode: 'advanced', + }, + + // --- Funnels fields --- + { + id: 'funnelEvents', + title: 'Funnel Steps', + type: 'long-input', + required: { field: 'operation', value: 'funnels' }, + placeholder: '[{"event_type":"signup"},{"event_type":"purchase"}]', + condition: { field: 'operation', value: 'funnels' }, + wandConfig: { + enabled: true, + prompt: + 'Generate a JSON array of Amplitude event objects, one per funnel step in order, each with an "event_type" key. Return ONLY the JSON array - no explanations, no extra text.', + generationType: 'json-object', + }, + }, + { + id: 'funnelStart', + title: 'Start Date', + type: 'short-input', + required: { field: 'operation', value: 'funnels' }, + placeholder: 'YYYYMMDD', + condition: { field: 'operation', value: 'funnels' }, + wandConfig: { + enabled: true, + prompt: + 'Generate a date in YYYYMMDD format. Return ONLY the date string - no explanations, no extra text.', + generationType: 'timestamp', + }, + }, + { + id: 'funnelEnd', + title: 'End Date', + type: 'short-input', + required: { field: 'operation', value: 'funnels' }, + placeholder: 'YYYYMMDD', + condition: { field: 'operation', value: 'funnels' }, + wandConfig: { + enabled: true, + prompt: + 'Generate a date in YYYYMMDD format. Return ONLY the date string - no explanations, no extra text.', + generationType: 'timestamp', + }, + }, + { + id: 'funnelMode', + title: 'Funnel Mode', + type: 'dropdown', + options: [ + { label: 'Ordered', id: 'ordered' }, + { label: 'Unordered', id: 'unordered' }, + { label: 'Sequential', id: 'sequential' }, + ], + value: () => 'ordered', + condition: { field: 'operation', value: 'funnels' }, + mode: 'advanced', + }, + { + id: 'funnelUserType', + title: 'User Type', + type: 'dropdown', + options: [ + { label: 'Active', id: 'active' }, + { label: 'New', id: 'new' }, + ], + value: () => 'active', + condition: { field: 'operation', value: 'funnels' }, + mode: 'advanced', + }, + { + id: 'funnelInterval', + title: 'Interval', + type: 'dropdown', + options: [ + { label: 'Real-time', id: '-300000' }, + { label: 'Hourly', id: '-3600000' }, + { label: 'Daily', id: '1' }, + { label: 'Weekly', id: '7' }, + { label: 'Monthly', id: '30' }, + ], + value: () => '1', + condition: { field: 'operation', value: 'funnels' }, + mode: 'advanced', + }, + { + id: 'funnelConversionWindowSeconds', + title: 'Conversion Window (seconds)', + type: 'short-input', + placeholder: '2592000 (30 days)', + condition: { field: 'operation', value: 'funnels' }, + mode: 'advanced', + }, + { + id: 'funnelGroupBy', + title: 'Group By', + type: 'short-input', + placeholder: 'Property name (limit: one)', + condition: { field: 'operation', value: 'funnels' }, + mode: 'advanced', + }, + { + id: 'funnelLimit', + title: 'Limit', + type: 'short-input', + placeholder: 'Max group-by values (max 1000)', + condition: { field: 'operation', value: 'funnels' }, + mode: 'advanced', + }, + { + id: 'funnelSegment', + title: 'Segment Definition', + type: 'long-input', + placeholder: 'JSON segment definition(s)', + condition: { field: 'operation', value: 'funnels' }, + mode: 'advanced', + }, + + // --- Retention fields --- + { + id: 'retentionStartEvent', + title: 'Starting Event', + type: 'short-input', + required: { field: 'operation', value: 'retention' }, + placeholder: '{"event_type":"_new"}', + condition: { field: 'operation', value: 'retention' }, + }, + { + id: 'retentionReturnEvent', + title: 'Returning Event', + type: 'short-input', + required: { field: 'operation', value: 'retention' }, + placeholder: '{"event_type":"_all"}', + condition: { field: 'operation', value: 'retention' }, + }, + { + id: 'retentionStart', + title: 'Start Date', + type: 'short-input', + required: { field: 'operation', value: 'retention' }, + placeholder: 'YYYYMMDD', + condition: { field: 'operation', value: 'retention' }, + wandConfig: { + enabled: true, + prompt: + 'Generate a date in YYYYMMDD format. Return ONLY the date string - no explanations, no extra text.', + generationType: 'timestamp', + }, + }, + { + id: 'retentionEnd', + title: 'End Date', + type: 'short-input', + required: { field: 'operation', value: 'retention' }, + placeholder: 'YYYYMMDD', + condition: { field: 'operation', value: 'retention' }, + wandConfig: { + enabled: true, + prompt: + 'Generate a date in YYYYMMDD format. Return ONLY the date string - no explanations, no extra text.', + generationType: 'timestamp', + }, + }, + { + id: 'retentionMode', + title: 'Retention Mode', + type: 'dropdown', + options: [ + { label: 'N-Day', id: 'n-day' }, + { label: 'Rolling', id: 'rolling' }, + { label: 'Bracket', id: 'bracket' }, + ], + value: () => 'n-day', + condition: { field: 'operation', value: 'retention' }, + mode: 'advanced', + }, + { + id: 'retentionBrackets', + title: 'Retention Brackets', + type: 'short-input', + placeholder: '[[0,4]] — required when Retention Mode is Bracket', + condition: { + field: 'operation', + value: 'retention', + and: { field: 'retentionMode', value: 'bracket' }, + }, + required: { + field: 'operation', + value: 'retention', + and: { field: 'retentionMode', value: 'bracket' }, + }, + mode: 'advanced', + }, + { + id: 'retentionInterval', + title: 'Interval', + type: 'dropdown', + options: [ + { label: 'Daily', id: '1' }, + { label: 'Weekly', id: '7' }, + { label: 'Monthly', id: '30' }, + ], + value: () => '1', + condition: { field: 'operation', value: 'retention' }, + mode: 'advanced', + }, + { + id: 'retentionGroupBy', + title: 'Group By', + type: 'short-input', + placeholder: 'Property name (limit: one)', + condition: { field: 'operation', value: 'retention' }, + mode: 'advanced', + }, + { + id: 'retentionSegment', + title: 'Segment Definition', + type: 'long-input', + placeholder: 'JSON segment definition(s)', + condition: { field: 'operation', value: 'retention' }, + mode: 'advanced', + }, ], tools: { @@ -611,6 +934,8 @@ export const AmplitudeBlock: BlockConfig = { 'amplitude_realtime_active_users', 'amplitude_list_events', 'amplitude_get_revenue', + 'amplitude_funnels', + 'amplitude_retention', ], config: { tool: (params) => `amplitude_${params.operation}`, @@ -649,7 +974,11 @@ export const AmplitudeBlock: BlockConfig = { if (params.segmentationMetric) result.metric = params.segmentationMetric if (params.segmentationInterval) result.interval = params.segmentationInterval if (params.segmentationGroupBy) result.groupBy = params.segmentationGroupBy + if (params.segmentationGroupBy2) result.groupBy2 = params.segmentationGroupBy2 if (params.segmentationLimit) result.limit = params.segmentationLimit + if (params.segmentationFilters) result.filters = params.segmentationFilters + if (params.segmentationFormula) result.formula = params.segmentationFormula + if (params.segmentationSegment) result.segment = params.segmentationSegment break case 'get_active_users': @@ -657,6 +986,8 @@ export const AmplitudeBlock: BlockConfig = { if (params.activeUsersEnd) result.end = params.activeUsersEnd if (params.activeUsersMetric) result.metric = params.activeUsersMetric if (params.activeUsersInterval) result.interval = params.activeUsersInterval + if (params.activeUsersGroupBy) result.groupBy = params.activeUsersGroupBy + if (params.activeUsersSegment) result.segment = params.activeUsersSegment break case 'get_revenue': @@ -664,6 +995,34 @@ export const AmplitudeBlock: BlockConfig = { if (params.revenueEnd) result.end = params.revenueEnd if (params.revenueMetric) result.metric = params.revenueMetric if (params.revenueInterval) result.interval = params.revenueInterval + if (params.revenueGroupBy) result.groupBy = params.revenueGroupBy + if (params.revenueSegment) result.segment = params.revenueSegment + break + + case 'funnels': + if (params.funnelEvents) result.events = params.funnelEvents + if (params.funnelStart) result.start = params.funnelStart + if (params.funnelEnd) result.end = params.funnelEnd + if (params.funnelMode) result.mode = params.funnelMode + if (params.funnelUserType) result.userType = params.funnelUserType + if (params.funnelInterval) result.interval = params.funnelInterval + if (params.funnelConversionWindowSeconds) + result.conversionWindowSeconds = params.funnelConversionWindowSeconds + if (params.funnelGroupBy) result.groupBy = params.funnelGroupBy + if (params.funnelLimit) result.limit = params.funnelLimit + if (params.funnelSegment) result.segment = params.funnelSegment + break + + case 'retention': + if (params.retentionStartEvent) result.startEvent = params.retentionStartEvent + if (params.retentionReturnEvent) result.returnEvent = params.retentionReturnEvent + if (params.retentionStart) result.start = params.retentionStart + if (params.retentionEnd) result.end = params.retentionEnd + if (params.retentionMode) result.retentionMode = params.retentionMode + if (params.retentionBrackets) result.retentionBrackets = params.retentionBrackets + if (params.retentionInterval) result.interval = params.retentionInterval + if (params.retentionGroupBy) result.groupBy = params.retentionGroupBy + if (params.retentionSegment) result.segment = params.retentionSegment break } @@ -696,6 +1055,32 @@ export const AmplitudeBlock: BlockConfig = { activeUsersEnd: { type: 'string', description: 'Active users end date' }, revenueStart: { type: 'string', description: 'Revenue start date' }, revenueEnd: { type: 'string', description: 'Revenue end date' }, + dataResidency: { type: 'string', description: 'Data residency region: "us" or "eu"' }, + segmentationFilters: { type: 'string', description: 'Event segmentation filters JSON' }, + segmentationFormula: { type: 'string', description: 'Event segmentation formula expression' }, + segmentationGroupBy2: { + type: 'string', + description: 'Event segmentation second group-by property', + }, + segmentationSegment: { + type: 'string', + description: 'Event segmentation segment definition JSON', + }, + activeUsersGroupBy: { type: 'string', description: 'Active users group-by property' }, + activeUsersSegment: { type: 'string', description: 'Active users segment definition JSON' }, + revenueGroupBy: { type: 'string', description: 'Revenue group-by property' }, + revenueSegment: { type: 'string', description: 'Revenue segment definition JSON' }, + funnelEvents: { type: 'string', description: 'Funnel step event objects JSON array' }, + funnelStart: { type: 'string', description: 'Funnel analysis start date' }, + funnelEnd: { type: 'string', description: 'Funnel analysis end date' }, + funnelGroupBy: { type: 'string', description: 'Funnel group-by property' }, + funnelSegment: { type: 'string', description: 'Funnel segment definition JSON' }, + retentionStartEvent: { type: 'string', description: 'Retention starting event JSON object' }, + retentionReturnEvent: { type: 'string', description: 'Retention returning event JSON object' }, + retentionStart: { type: 'string', description: 'Retention analysis start date' }, + retentionEnd: { type: 'string', description: 'Retention analysis end date' }, + retentionGroupBy: { type: 'string', description: 'Retention group-by property' }, + retentionSegment: { type: 'string', description: 'Retention segment definition JSON' }, }, outputs: { @@ -711,10 +1096,43 @@ export const AmplitudeBlock: BlockConfig = { type: 'number', description: 'Number of events ingested (send_event)', }, + payloadSizeBytes: { + type: 'number', + description: 'Size of the ingested payload in bytes (send_event)', + }, + serverUploadTime: { + type: 'number', + description: 'Server-side upload timestamp (send_event)', + }, matches: { type: 'json', description: 'User search matches (amplitudeId, userId)', }, + type: { + type: 'string', + description: 'Match type, e.g. match_user_or_device_id (user_search)', + }, + userId: { + type: 'string', + description: 'External user ID (user_profile)', + }, + deviceId: { + type: 'string', + description: 'Device ID (user_profile)', + }, + ampProps: { + type: 'json', + description: + 'Amplitude user properties (library, first_used, last_used, custom) (user_profile)', + }, + cohortIds: { + type: 'json', + description: 'Cohort IDs the user belongs to (user_profile)', + }, + computations: { + type: 'json', + description: 'Computed user properties (user_profile)', + }, events: { type: 'json', description: 'Event list (list_events, user_activity)', @@ -725,7 +1143,8 @@ export const AmplitudeBlock: BlockConfig = { }, series: { type: 'json', - description: 'Time-series data (segmentation, active_users, revenue, realtime)', + description: + 'Time-series data (segmentation, active_users, realtime: number[][]; revenue: [{dates, values}]; retention: [{dates, values, combined}])', }, seriesLabels: { type: 'json', @@ -733,7 +1152,7 @@ export const AmplitudeBlock: BlockConfig = { }, seriesMeta: { type: 'json', - description: 'Metadata labels for data series (active_users)', + description: 'Metadata labels for data series (active_users, retention)', }, seriesCollapsed: { type: 'json', @@ -741,7 +1160,12 @@ export const AmplitudeBlock: BlockConfig = { }, xValues: { type: 'json', - description: 'X-axis date/time values for chart data', + description: 'X-axis date/time values for chart data (segmentation, active_users, realtime)', + }, + funnels: { + type: 'json', + description: + 'Funnel results per segment (stepByStep, cumulative, medianTransTimes, dayFunnels, etc.) (funnels)', }, }, } @@ -849,5 +1273,12 @@ export const AmplitudeBlockMeta = { content: '# Lookup User Activity\n\nInvestigate a single user in Amplitude for support or debugging.\n\n## Steps\n1. Search for the user by user ID, device ID, or Amplitude ID to resolve their Amplitude ID.\n2. Pull the user activity stream for that Amplitude ID, ordered latest first.\n3. Optionally fetch the user profile to see their current properties.\n\n## Output\nA timeline of the user recent events plus key profile properties. Note the time range covered.', }, + { + name: 'analyze-conversion-funnel', + description: + 'Run an Amplitude funnel across a sequence of events to find conversion rates and drop-off points.', + content: + '# Analyze Conversion Funnel\n\nMeasure how users progress through a multi-step flow in Amplitude.\n\n## Steps\n1. Define the ordered sequence of events that make up the funnel (e.g., signup, activation, purchase).\n2. Pick the date range and, if useful, a property to group by.\n3. Run the funnel query and read the step-by-step and cumulative conversion numbers.\n\n## Output\nConversion counts and rates at each step, the biggest drop-off point, and any group-by breakdown.', + }, ], } as const satisfies BlockMeta diff --git a/apps/sim/tools/amplitude/event_segmentation.ts b/apps/sim/tools/amplitude/event_segmentation.ts index d5cbab3357a..394adf07ccf 100644 --- a/apps/sim/tools/amplitude/event_segmentation.ts +++ b/apps/sim/tools/amplitude/event_segmentation.ts @@ -2,6 +2,7 @@ import type { AmplitudeEventSegmentationParams, AmplitudeEventSegmentationResponse, } from '@/tools/amplitude/types' +import { getDashboardHost } from '@/tools/amplitude/utils' import type { ToolConfig } from '@/tools/types' export const eventSegmentationTool: ToolConfig< @@ -64,25 +65,81 @@ export const eventSegmentationTool: ToolConfig< visibility: 'user-or-llm', description: 'Property name to group by (prefix custom user properties with "gp:")', }, + groupBy2: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Second property name to group by (prefix custom user properties with "gp:")', + }, limit: { type: 'string', required: false, visibility: 'user-or-llm', description: 'Maximum number of group-by values (max 1000)', }, + filters: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'JSON array of filter objects applied to the event, e.g. [{"subprop_type":"event","subprop_key":"city","subprop_op":"is","subprop_value":["San Francisco"]}]', + }, + formula: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Required when metric is "formula", e.g. "UNIQUES(A)/UNIQUES(B)"', + }, + segment: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'JSON segment definition(s) applied to the query', + }, + dataResidency: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Data residency region: "us" (default) or "eu"', + }, }, request: { url: (params) => { - const url = new URL('https://amplitude.com/api/2/events/segmentation') - const eventObj = JSON.stringify({ event_type: params.eventType }) - url.searchParams.set('e', eventObj) + const url = new URL(`${getDashboardHost(params.dataResidency)}/api/2/events/segmentation`) + const event: Record = { event_type: params.eventType } + + if (params.filters) { + let parsedFilters: unknown + try { + parsedFilters = JSON.parse(params.filters) + } catch { + parsedFilters = undefined + } + if (!Array.isArray(parsedFilters)) { + throw new Error( + 'Amplitude Event Segmentation: "filters" must be a valid JSON array of filter objects' + ) + } + event.filters = parsedFilters + } + + if (params.metric === 'formula' && !params.formula) { + throw new Error( + 'Amplitude Event Segmentation: "formula" is required when metric is "formula"' + ) + } + + url.searchParams.set('e', JSON.stringify(event)) url.searchParams.set('start', params.start) url.searchParams.set('end', params.end) if (params.metric) url.searchParams.set('m', params.metric) if (params.interval) url.searchParams.set('i', params.interval) if (params.groupBy) url.searchParams.set('g', params.groupBy) + if (params.groupBy2) url.searchParams.set('g2', params.groupBy2) if (params.limit) url.searchParams.set('limit', params.limit) + if (params.formula) url.searchParams.set('formula', params.formula) + if (params.segment) url.searchParams.set('s', params.segment) return url.toString() }, method: 'GET', diff --git a/apps/sim/tools/amplitude/funnels.ts b/apps/sim/tools/amplitude/funnels.ts new file mode 100644 index 00000000000..7eed8cdc883 --- /dev/null +++ b/apps/sim/tools/amplitude/funnels.ts @@ -0,0 +1,195 @@ +import type { AmplitudeFunnelsParams, AmplitudeFunnelsResponse } from '@/tools/amplitude/types' +import { getDashboardHost } from '@/tools/amplitude/utils' +import type { ToolConfig } from '@/tools/types' + +export const funnelsTool: ToolConfig = { + id: 'amplitude_funnels', + name: 'Amplitude Funnels', + description: 'Analyze conversion rates and drop-off between a sequence of events.', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Amplitude API Key', + }, + secretKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Amplitude Secret Key', + }, + events: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + 'JSON array of event objects, one per funnel step in order, e.g. [{"event_type":"signup"},{"event_type":"purchase"}]', + }, + start: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Start date in YYYYMMDD format', + }, + end: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'End date in YYYYMMDD format', + }, + mode: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Funnel ordering: "ordered", "unordered", or "sequential" (default: ordered)', + }, + userType: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'User type: "new" or "active" (default: active)', + }, + interval: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Time interval: -300000 (real-time), -3600000 (hourly), 1 (daily), 7 (weekly), or 30 (monthly)', + }, + conversionWindowSeconds: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Conversion window in seconds (default: 2592000, i.e. 30 days)', + }, + groupBy: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Property to group by (limit: one; prefix custom properties with "gp:")', + }, + limit: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Maximum number of group-by values (default: 100, max: 1000)', + }, + segment: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'JSON segment definition(s) applied to the query', + }, + dataResidency: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Data residency region: "us" (default) or "eu"', + }, + }, + + request: { + url: (params) => { + const url = new URL(`${getDashboardHost(params.dataResidency)}/api/2/funnels`) + let parsed: unknown + try { + parsed = JSON.parse(params.events) + } catch { + throw new Error('Amplitude Funnels: "events" must be a valid JSON array of event objects') + } + const isPlainObject = (value: unknown): value is Record => + Boolean(value) && typeof value === 'object' && !Array.isArray(value) + + if (!Array.isArray(parsed) || parsed.length === 0 || !parsed.every(isPlainObject)) { + throw new Error( + 'Amplitude Funnels: "events" must be a non-empty JSON array of event objects' + ) + } + for (const step of parsed) { + url.searchParams.append('e', JSON.stringify(step)) + } + url.searchParams.set('start', params.start) + url.searchParams.set('end', params.end) + if (params.mode) url.searchParams.set('mode', params.mode) + if (params.userType) url.searchParams.set('n', params.userType) + if (params.interval) url.searchParams.set('i', params.interval) + if (params.conversionWindowSeconds) url.searchParams.set('cs', params.conversionWindowSeconds) + if (params.groupBy) url.searchParams.set('g', params.groupBy) + if (params.limit) url.searchParams.set('limit', params.limit) + if (params.segment) url.searchParams.set('s', params.segment) + return url.toString() + }, + method: 'GET', + headers: (params) => ({ + Authorization: `Basic ${btoa(`${params.apiKey}:${params.secretKey}`)}`, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || `Amplitude Funnels API error: ${response.status}`) + } + + const results = (Array.isArray(data.data) ? data.data : []) as Array> + + const funnels = results.map((r) => { + const dayFunnels = r.dayFunnels as Record | undefined + return { + stepByStep: (r.stepByStep as number[]) ?? [], + cumulative: (r.cumulative as number[]) ?? [], + cumulativeRaw: (r.cumulativeRaw as number[]) ?? [], + medianTransTimes: (r.medianTransTimes as number[]) ?? [], + avgTransTimes: (r.avgTransTimes as number[]) ?? [], + events: (r.events as string[]) ?? [], + dayFunnels: dayFunnels + ? { + series: (dayFunnels.series as number[][]) ?? [], + xValues: (dayFunnels.xValues as string[]) ?? [], + } + : null, + } + }) + + return { + success: true, + output: { funnels }, + } + }, + + outputs: { + funnels: { + type: 'array', + description: 'Funnel results, one entry per segment', + items: { + type: 'object', + properties: { + stepByStep: { type: 'json', description: 'Conversion count at each step' }, + cumulative: { + type: 'json', + description: 'Cumulative conversion percentage at each step', + }, + cumulativeRaw: { type: 'json', description: 'Cumulative conversion count at each step' }, + medianTransTimes: { + type: 'json', + description: 'Median transition time between steps (ms)', + }, + avgTransTimes: { + type: 'json', + description: 'Average transition time between steps (ms)', + }, + events: { type: 'json', description: 'Event names for each funnel step' }, + dayFunnels: { + type: 'json', + description: 'Daily funnel breakdown {series, xValues}', + optional: true, + }, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/amplitude/get_active_users.ts b/apps/sim/tools/amplitude/get_active_users.ts index 6670e5ab150..a935d9919be 100644 --- a/apps/sim/tools/amplitude/get_active_users.ts +++ b/apps/sim/tools/amplitude/get_active_users.ts @@ -2,6 +2,7 @@ import type { AmplitudeGetActiveUsersParams, AmplitudeGetActiveUsersResponse, } from '@/tools/amplitude/types' +import { getDashboardHost } from '@/tools/amplitude/utils' import type { ToolConfig } from '@/tools/types' export const getActiveUsersTool: ToolConfig< @@ -50,15 +51,35 @@ export const getActiveUsersTool: ToolConfig< visibility: 'user-or-llm', description: 'Time interval: 1 (daily), 7 (weekly), or 30 (monthly)', }, + groupBy: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Property name to group by', + }, + segment: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'JSON segment definition(s) applied to the query', + }, + dataResidency: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Data residency region: "us" (default) or "eu"', + }, }, request: { url: (params) => { - const url = new URL('https://amplitude.com/api/2/users') + const url = new URL(`${getDashboardHost(params.dataResidency)}/api/2/users`) url.searchParams.set('start', params.start) url.searchParams.set('end', params.end) if (params.metric) url.searchParams.set('m', params.metric) if (params.interval) url.searchParams.set('i', params.interval) + if (params.groupBy) url.searchParams.set('g', params.groupBy) + if (params.segment) url.searchParams.set('s', params.segment) return url.toString() }, method: 'GET', diff --git a/apps/sim/tools/amplitude/get_revenue.ts b/apps/sim/tools/amplitude/get_revenue.ts index 264eb1e0f95..5f1861bdd86 100644 --- a/apps/sim/tools/amplitude/get_revenue.ts +++ b/apps/sim/tools/amplitude/get_revenue.ts @@ -2,6 +2,7 @@ import type { AmplitudeGetRevenueParams, AmplitudeGetRevenueResponse, } from '@/tools/amplitude/types' +import { getDashboardHost } from '@/tools/amplitude/utils' import type { ToolConfig } from '@/tools/types' export const getRevenueTool: ToolConfig = { @@ -47,15 +48,35 @@ export const getRevenueTool: ToolConfig { - const url = new URL('https://amplitude.com/api/2/revenue/ltv') + const url = new URL(`${getDashboardHost(params.dataResidency)}/api/2/revenue/ltv`) url.searchParams.set('start', params.start) url.searchParams.set('end', params.end) if (params.metric) url.searchParams.set('m', params.metric) if (params.interval) url.searchParams.set('i', params.interval) + if (params.groupBy) url.searchParams.set('g', params.groupBy) + if (params.segment) url.searchParams.set('s', params.segment) return url.toString() }, method: 'GET', @@ -78,25 +99,35 @@ export const getRevenueTool: ToolConfig: {r1d..r90d, count, paid, total_amount}}}]', + items: { + type: 'json', + properties: { + dates: { + type: 'array', + description: 'Dates covered by this series', + items: { type: 'string' }, + }, + values: { + type: 'json', + description: + 'Per-date metric values keyed by date (r1d..r90d, count, paid, total_amount)', + }, + }, + }, }, seriesLabels: { type: 'array', description: 'Labels for each data series', items: { type: 'string' }, }, - xValues: { - type: 'array', - description: 'Date values for the x-axis', - items: { type: 'string' }, - }, }, } diff --git a/apps/sim/tools/amplitude/group_identify.ts b/apps/sim/tools/amplitude/group_identify.ts index b0bd548c49d..6cdefbb320f 100644 --- a/apps/sim/tools/amplitude/group_identify.ts +++ b/apps/sim/tools/amplitude/group_identify.ts @@ -2,6 +2,7 @@ import type { AmplitudeGroupIdentifyParams, AmplitudeGroupIdentifyResponse, } from '@/tools/amplitude/types' +import { getIngestionHost } from '@/tools/amplitude/utils' import type { ToolConfig } from '@/tools/types' export const groupIdentifyTool: ToolConfig< @@ -40,13 +41,19 @@ export const groupIdentifyTool: ToolConfig< description: 'JSON object of group properties. Use operations like $set, $setOnce, $add, $append, $unset.', }, + dataResidency: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Data residency region: "us" (default) or "eu"', + }, }, request: { - url: 'https://api2.amplitude.com/groupidentify', + url: (params) => `${getIngestionHost(params.dataResidency)}/groupidentify`, method: 'POST', headers: () => ({ - 'Content-Type': 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded', }), body: (params) => { let groupProperties: Record = {} @@ -56,16 +63,20 @@ export const groupIdentifyTool: ToolConfig< groupProperties = {} } - return { + const identification = [ + { + group_type: params.groupType, + group_value: params.groupValue, + group_properties: groupProperties, + }, + ] + + const body = new URLSearchParams({ api_key: params.apiKey, - identification: [ - { - group_type: params.groupType, - group_value: params.groupValue, - group_properties: groupProperties, - }, - ], - } + identification: JSON.stringify(identification), + }) + + return body.toString() }, }, diff --git a/apps/sim/tools/amplitude/identify_user.ts b/apps/sim/tools/amplitude/identify_user.ts index a0cb0316805..754316dbea1 100644 --- a/apps/sim/tools/amplitude/identify_user.ts +++ b/apps/sim/tools/amplitude/identify_user.ts @@ -2,6 +2,7 @@ import type { AmplitudeIdentifyUserParams, AmplitudeIdentifyUserResponse, } from '@/tools/amplitude/types' +import { getIngestionHost } from '@/tools/amplitude/utils' import type { ToolConfig } from '@/tools/types' export const identifyUserTool: ToolConfig< @@ -40,13 +41,19 @@ export const identifyUserTool: ToolConfig< description: 'JSON object of user properties. Use operations like $set, $setOnce, $add, $append, $unset.', }, + dataResidency: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Data residency region: "us" (default) or "eu"', + }, }, request: { - url: 'https://api2.amplitude.com/identify', + url: (params) => `${getIngestionHost(params.dataResidency)}/identify`, method: 'POST', headers: () => ({ - 'Content-Type': 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded', }), body: (params) => { const identification: Record = {} @@ -60,10 +67,12 @@ export const identifyUserTool: ToolConfig< identification.user_properties = {} } - return { + const body = new URLSearchParams({ api_key: params.apiKey, - identification: [identification], - } + identification: JSON.stringify([identification]), + }) + + return body.toString() }, }, diff --git a/apps/sim/tools/amplitude/index.ts b/apps/sim/tools/amplitude/index.ts index b0f1aab792f..6aca5c1ff9f 100644 --- a/apps/sim/tools/amplitude/index.ts +++ b/apps/sim/tools/amplitude/index.ts @@ -1,10 +1,12 @@ import { eventSegmentationTool } from '@/tools/amplitude/event_segmentation' +import { funnelsTool } from '@/tools/amplitude/funnels' import { getActiveUsersTool } from '@/tools/amplitude/get_active_users' import { getRevenueTool } from '@/tools/amplitude/get_revenue' import { groupIdentifyTool } from '@/tools/amplitude/group_identify' import { identifyUserTool } from '@/tools/amplitude/identify_user' import { listEventsTool } from '@/tools/amplitude/list_events' import { realtimeActiveUsersTool } from '@/tools/amplitude/realtime_active_users' +import { retentionTool } from '@/tools/amplitude/retention' import { sendEventTool } from '@/tools/amplitude/send_event' import { userActivityTool } from '@/tools/amplitude/user_activity' import { userProfileTool } from '@/tools/amplitude/user_profile' @@ -21,3 +23,5 @@ export const amplitudeGetActiveUsersTool = getActiveUsersTool export const amplitudeRealtimeActiveUsersTool = realtimeActiveUsersTool export const amplitudeListEventsTool = listEventsTool export const amplitudeGetRevenueTool = getRevenueTool +export const amplitudeFunnelsTool = funnelsTool +export const amplitudeRetentionTool = retentionTool diff --git a/apps/sim/tools/amplitude/list_events.ts b/apps/sim/tools/amplitude/list_events.ts index fa89d3ddf11..a1685bf0ee6 100644 --- a/apps/sim/tools/amplitude/list_events.ts +++ b/apps/sim/tools/amplitude/list_events.ts @@ -2,6 +2,7 @@ import type { AmplitudeListEventsParams, AmplitudeListEventsResponse, } from '@/tools/amplitude/types' +import { getDashboardHost } from '@/tools/amplitude/utils' import type { ToolConfig } from '@/tools/types' export const listEventsTool: ToolConfig = { @@ -24,10 +25,16 @@ export const listEventsTool: ToolConfig `${getDashboardHost(params.dataResidency)}/api/2/events/list`, method: 'GET', headers: (params) => ({ Authorization: `Basic ${btoa(`${params.apiKey}:${params.secretKey}`)}`, @@ -49,6 +56,8 @@ export const listEventsTool: ToolConfig

${escapeHtml(pageContent)}

`, }, ], }, diff --git a/apps/sim/tools/sharepoint/delete_file.ts b/apps/sim/tools/sharepoint/delete_file.ts new file mode 100644 index 00000000000..183ac484762 --- /dev/null +++ b/apps/sim/tools/sharepoint/delete_file.ts @@ -0,0 +1,66 @@ +import type { SharepointDeleteFileResponse, SharepointToolParams } from '@/tools/sharepoint/types' +import { optionalTrim } from '@/tools/sharepoint/utils' +import type { ToolConfig } from '@/tools/types' + +export const deleteFileTool: ToolConfig = { + id: 'sharepoint_delete_file', + name: 'Delete SharePoint File', + description: 'Delete a file (or folder) from a SharePoint document library', + version: '1.0.0', + + oauth: { + required: true, + provider: 'sharepoint', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'The access token for the SharePoint API', + }, + driveId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The ID of the document library (drive). Example: b!abc123def456', + }, + driveItemId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The ID of the file (drive item) to delete', + }, + }, + + request: { + url: (params) => { + const driveId = optionalTrim(params.driveId) + const driveItemId = optionalTrim(params.driveItemId) + if (!driveId) throw new Error('driveId must be provided') + if (!driveItemId) throw new Error('driveItemId must be provided') + return `https://graph.microsoft.com/v1.0/drives/${encodeURIComponent(driveId)}/items/${encodeURIComponent(driveItemId)}` + }, + method: 'DELETE', + headers: (params) => ({ + Authorization: `Bearer ${params.accessToken}`, + Accept: 'application/json', + }), + }, + + transformResponse: async (_response: Response, params) => { + return { + success: true, + output: { + deleted: true, + itemId: params?.driveItemId ?? '', + }, + } + }, + + outputs: { + deleted: { type: 'boolean', description: 'Whether the file was deleted' }, + itemId: { type: 'string', description: 'The ID of the deleted file' }, + }, +} diff --git a/apps/sim/tools/sharepoint/delete_list_item.ts b/apps/sim/tools/sharepoint/delete_list_item.ts new file mode 100644 index 00000000000..3c0c7dbb4ff --- /dev/null +++ b/apps/sim/tools/sharepoint/delete_list_item.ts @@ -0,0 +1,88 @@ +import type { + SharepointDeleteListItemResponse, + SharepointToolParams, +} from '@/tools/sharepoint/types' +import { optionalTrim } from '@/tools/sharepoint/utils' +import type { ToolConfig } from '@/tools/types' + +export const deleteListItemTool: ToolConfig< + SharepointToolParams, + SharepointDeleteListItemResponse +> = { + id: 'sharepoint_delete_list_item', + name: 'Delete SharePoint List Item', + description: 'Delete an item from a SharePoint list', + version: '1.0.0', + + oauth: { + required: true, + provider: 'sharepoint', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'The access token for the SharePoint API', + }, + siteSelector: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Select the SharePoint site', + }, + siteId: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'The ID of the SharePoint site (internal use)', + }, + listId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + 'The ID of the list containing the item. Example: b!abc123def456 or a GUID like 12345678-1234-1234-1234-123456789012', + }, + itemId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The ID of the list item to delete. Example: 1, 42, or 123', + }, + }, + + request: { + url: (params) => { + const siteId = optionalTrim(params.siteId) || optionalTrim(params.siteSelector) || 'root' + const listId = optionalTrim(params.listId) + const itemId = optionalTrim(params.itemId) + if (!listId) throw new Error('listId must be provided') + if (!itemId) throw new Error('itemId must be provided') + const listSegment = encodeURIComponent(listId) + const itemSegment = encodeURIComponent(itemId) + return `https://graph.microsoft.com/v1.0/sites/${encodeURIComponent(siteId)}/lists/${listSegment}/items/${itemSegment}` + }, + method: 'DELETE', + headers: (params) => ({ + Authorization: `Bearer ${params.accessToken}`, + Accept: 'application/json', + }), + }, + + transformResponse: async (_response: Response, params) => { + return { + success: true, + output: { + deleted: true, + itemId: params?.itemId ?? '', + }, + } + }, + + outputs: { + deleted: { type: 'boolean', description: 'Whether the list item was deleted' }, + itemId: { type: 'string', description: 'The ID of the deleted list item' }, + }, +} diff --git a/apps/sim/tools/sharepoint/delete_page.ts b/apps/sim/tools/sharepoint/delete_page.ts new file mode 100644 index 00000000000..6a061951cb7 --- /dev/null +++ b/apps/sim/tools/sharepoint/delete_page.ts @@ -0,0 +1,72 @@ +import type { SharepointDeletePageResponse, SharepointToolParams } from '@/tools/sharepoint/types' +import { optionalTrim } from '@/tools/sharepoint/utils' +import type { ToolConfig } from '@/tools/types' + +export const deletePageTool: ToolConfig = { + id: 'sharepoint_delete_page', + name: 'Delete SharePoint Page', + description: 'Delete a page from a SharePoint site', + version: '1.0.0', + + oauth: { + required: true, + provider: 'sharepoint', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'The access token for the SharePoint API', + }, + siteSelector: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Select the SharePoint site', + }, + siteId: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'The ID of the SharePoint site (internal use)', + }, + pageId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + 'The ID of the page to delete. Example: a GUID like 12345678-1234-1234-1234-123456789012', + }, + }, + + request: { + url: (params) => { + const siteId = optionalTrim(params.siteId) || optionalTrim(params.siteSelector) || 'root' + const pageId = optionalTrim(params.pageId) + if (!pageId) throw new Error('pageId must be provided') + return `https://graph.microsoft.com/v1.0/sites/${encodeURIComponent(siteId)}/pages/${encodeURIComponent(pageId)}` + }, + method: 'DELETE', + headers: (params) => ({ + Authorization: `Bearer ${params.accessToken}`, + Accept: 'application/json', + }), + }, + + transformResponse: async (_response: Response, params) => { + return { + success: true, + output: { + deleted: true, + pageId: params?.pageId ?? '', + }, + } + }, + + outputs: { + deleted: { type: 'boolean', description: 'Whether the page was deleted' }, + pageId: { type: 'string', description: 'The ID of the deleted page' }, + }, +} diff --git a/apps/sim/tools/sharepoint/download_file.ts b/apps/sim/tools/sharepoint/download_file.ts new file mode 100644 index 00000000000..add8673b254 --- /dev/null +++ b/apps/sim/tools/sharepoint/download_file.ts @@ -0,0 +1,59 @@ +import type { SharepointDownloadFileResponse, SharepointToolParams } from '@/tools/sharepoint/types' +import type { ToolConfig } from '@/tools/types' + +export const downloadFileTool: ToolConfig = { + id: 'sharepoint_download_file', + name: 'Download File from SharePoint', + description: 'Download a file from a SharePoint document library', + version: '1.0.0', + + oauth: { + required: true, + provider: 'sharepoint', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'The access token for the SharePoint API', + }, + driveId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The ID of the document library (drive). Example: b!abc123def456', + }, + driveItemId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The ID of the file (drive item) to download', + }, + fileName: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Optional filename override (e.g., "report.pdf", "data.xlsx")', + }, + }, + + request: { + url: '/api/tools/sharepoint/download-file', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + accessToken: params.accessToken, + driveId: params.driveId, + itemId: params.driveItemId, + fileName: params.fileName, + }), + }, + + outputs: { + file: { type: 'file', description: 'Downloaded file stored in execution files' }, + }, +} diff --git a/apps/sim/tools/sharepoint/get_drive_item.ts b/apps/sim/tools/sharepoint/get_drive_item.ts new file mode 100644 index 00000000000..0b6f689aaf6 --- /dev/null +++ b/apps/sim/tools/sharepoint/get_drive_item.ts @@ -0,0 +1,106 @@ +import type { + SharepointDriveItem, + SharepointGetDriveItemResponse, + SharepointToolParams, +} from '@/tools/sharepoint/types' +import { optionalTrim } from '@/tools/sharepoint/utils' +import type { ToolConfig } from '@/tools/types' + +export const getDriveItemTool: ToolConfig = { + id: 'sharepoint_get_drive_item', + name: 'Get SharePoint Drive Item', + description: 'Get metadata for a file or folder in a SharePoint document library', + version: '1.0.0', + + oauth: { + required: true, + provider: 'sharepoint', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'The access token for the SharePoint API', + }, + driveId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The ID of the document library (drive). Example: b!abc123def456', + }, + driveItemId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The ID of the file or folder (drive item) to retrieve', + }, + }, + + request: { + url: (params) => { + const driveId = optionalTrim(params.driveId) + const driveItemId = optionalTrim(params.driveItemId) + if (!driveId) throw new Error('driveId must be provided') + if (!driveItemId) throw new Error('driveItemId must be provided') + return `https://graph.microsoft.com/v1.0/drives/${encodeURIComponent(driveId)}/items/${encodeURIComponent(driveItemId)}` + }, + method: 'GET', + headers: (params) => ({ + Authorization: `Bearer ${params.accessToken}`, + Accept: 'application/json', + }), + }, + + transformResponse: async (response: Response) => { + const data: Record = await response.json() + + const driveItem: SharepointDriveItem = { + id: data.id as string, + name: data.name as string, + webUrl: data.webUrl as string | undefined, + size: data.size as number | undefined, + createdDateTime: data.createdDateTime as string | undefined, + lastModifiedDateTime: data.lastModifiedDateTime as string | undefined, + file: (data.file as SharepointDriveItem['file']) ?? null, + folder: (data.folder as SharepointDriveItem['folder']) ?? null, + parentReference: (data.parentReference as SharepointDriveItem['parentReference']) ?? null, + } + + return { + success: true, + output: { driveItem }, + } + }, + + outputs: { + driveItem: { + type: 'object', + description: 'Metadata for the SharePoint file or folder', + properties: { + id: { type: 'string', description: 'The unique ID of the drive item' }, + name: { type: 'string', description: 'The name of the file or folder' }, + webUrl: { type: 'string', description: 'The URL to access the item' }, + size: { type: 'number', description: 'The size of the item in bytes', optional: true }, + createdDateTime: { type: 'string', description: 'When the item was created' }, + lastModifiedDateTime: { type: 'string', description: 'When the item was last modified' }, + file: { + type: 'object', + description: 'Present if the item is a file (contains mimeType)', + optional: true, + }, + folder: { + type: 'object', + description: 'Present if the item is a folder (contains childCount)', + optional: true, + }, + parentReference: { + type: 'object', + description: 'Reference to the parent folder/drive', + optional: true, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/sharepoint/get_list.ts b/apps/sim/tools/sharepoint/get_list.ts index bcf3036a977..a4061e32f57 100644 --- a/apps/sim/tools/sharepoint/get_list.ts +++ b/apps/sim/tools/sharepoint/get_list.ts @@ -73,10 +73,11 @@ export const getListTool: ToolConfig 0) url.searchParams.append('$expand', expandParts.join(',')) const finalUrl = url.toString() @@ -128,21 +129,23 @@ export const getListTool: ToolConfig { - const data = await response.json() + const data: Record = await response.json() + const value = data.value // If the response is a collection of items (from the items endpoint) if ( - Array.isArray((data as any).value) && - (data as any).value.length > 0 && - (data as any).value[0] && - 'fields' in (data as any).value[0] + Array.isArray(value) && + value.length > 0 && + value[0] && + typeof value[0] === 'object' && + 'fields' in value[0] ) { - const items = (data as any).value.map((i: any) => ({ - id: i.id, + const items = value.map((i: Record) => ({ + id: i.id as string, fields: i.fields as Record, })) - const nextPageUrl = getGraphNextPageUrl(data as Record) + const nextPageUrl = getGraphNextPageUrl(data) return { success: true, @@ -154,18 +157,18 @@ export const getListTool: ToolConfig ({ - id: l.id, - displayName: l.displayName ?? l.name, - name: l.name, - webUrl: l.webUrl, - createdDateTime: l.createdDateTime, - lastModifiedDateTime: l.lastModifiedDateTime, - list: l.list, + if (Array.isArray(value)) { + const lists: SharepointList[] = value.map((l: Record) => ({ + id: l.id as string, + displayName: (l.displayName ?? l.name) as string | undefined, + name: l.name as string | undefined, + webUrl: l.webUrl as string | undefined, + createdDateTime: l.createdDateTime as string | undefined, + lastModifiedDateTime: l.lastModifiedDateTime as string | undefined, + list: l.list as SharepointList['list'], })) - const nextPageUrl = getGraphNextPageUrl(data as Record) + const nextPageUrl = getGraphNextPageUrl(data) return { success: true, @@ -174,29 +177,32 @@ export const getListTool: ToolConfig ({ - id: c.id, - name: c.name, - displayName: c.displayName, - description: c.description, - indexed: c.indexed, - enforcedUniqueValues: c.enforcedUniqueValues, - hidden: c.hidden, - readOnly: c.readOnly, - required: c.required, - columnGroup: c.columnGroup, + ? data.columns.map((c: Record) => ({ + id: c.id as string | undefined, + name: c.name as string | undefined, + displayName: c.displayName as string | undefined, + description: c.description as string | undefined, + indexed: c.indexed as boolean | undefined, + enforcedUniqueValues: c.enforcedUniqueValues as boolean | undefined, + hidden: c.hidden as boolean | undefined, + readOnly: c.readOnly as boolean | undefined, + required: c.required as boolean | undefined, + columnGroup: c.columnGroup as string | undefined, })) : undefined, items: Array.isArray(data.items) - ? data.items.map((i: any) => ({ id: i.id, fields: i.fields as Record })) + ? data.items.map((i: Record) => ({ + id: i.id as string, + fields: i.fields as Record, + })) : undefined, } diff --git a/apps/sim/tools/sharepoint/get_list_item.ts b/apps/sim/tools/sharepoint/get_list_item.ts new file mode 100644 index 00000000000..691ca97a45a --- /dev/null +++ b/apps/sim/tools/sharepoint/get_list_item.ts @@ -0,0 +1,96 @@ +import type { SharepointGetListItemResponse, SharepointToolParams } from '@/tools/sharepoint/types' +import { optionalTrim } from '@/tools/sharepoint/utils' +import type { ToolConfig } from '@/tools/types' + +export const getListItemTool: ToolConfig = { + id: 'sharepoint_get_list_item', + name: 'Get SharePoint List Item', + description: 'Get a single item (with field values) from a SharePoint list', + version: '1.0.0', + + oauth: { + required: true, + provider: 'sharepoint', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'The access token for the SharePoint API', + }, + siteSelector: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Select the SharePoint site', + }, + siteId: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'The ID of the SharePoint site (internal use)', + }, + listId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + 'The ID of the list containing the item. Example: b!abc123def456 or a GUID like 12345678-1234-1234-1234-123456789012', + }, + itemId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The ID of the list item to retrieve. Example: 1, 42, or 123', + }, + }, + + request: { + url: (params) => { + const siteId = optionalTrim(params.siteId) || optionalTrim(params.siteSelector) || 'root' + const listId = optionalTrim(params.listId) + const itemId = optionalTrim(params.itemId) + if (!listId) throw new Error('listId must be provided') + if (!itemId) throw new Error('itemId must be provided') + const listSegment = encodeURIComponent(listId) + const itemSegment = encodeURIComponent(itemId) + const url = new URL( + `https://graph.microsoft.com/v1.0/sites/${encodeURIComponent(siteId)}/lists/${listSegment}/items/${itemSegment}` + ) + url.searchParams.set('$expand', 'fields') + return url.toString() + }, + method: 'GET', + headers: (params) => ({ + Authorization: `Bearer ${params.accessToken}`, + Accept: 'application/json', + }), + }, + + transformResponse: async (response: Response) => { + const data: Record = await response.json() + + return { + success: true, + output: { + item: { + id: data.id as string, + fields: data.fields as Record | undefined, + }, + }, + } + }, + + outputs: { + item: { + type: 'object', + description: 'SharePoint list item with field values', + properties: { + id: { type: 'string', description: 'Item ID' }, + fields: { type: 'object', description: 'Field values for the item' }, + }, + }, + }, +} diff --git a/apps/sim/tools/sharepoint/index.ts b/apps/sim/tools/sharepoint/index.ts index eaa798cd192..01feb9f8618 100644 --- a/apps/sim/tools/sharepoint/index.ts +++ b/apps/sim/tools/sharepoint/index.ts @@ -1,19 +1,35 @@ import { addListItemTool } from '@/tools/sharepoint/add_list_items' import { createListTool } from '@/tools/sharepoint/create_list' import { createPageTool } from '@/tools/sharepoint/create_page' +import { deleteFileTool } from '@/tools/sharepoint/delete_file' +import { deleteListItemTool } from '@/tools/sharepoint/delete_list_item' +import { deletePageTool } from '@/tools/sharepoint/delete_page' +import { downloadFileTool } from '@/tools/sharepoint/download_file' +import { getDriveItemTool } from '@/tools/sharepoint/get_drive_item' import { getListTool } from '@/tools/sharepoint/get_list' +import { getListItemTool } from '@/tools/sharepoint/get_list_item' import { listSitesTool } from '@/tools/sharepoint/list_sites' +import { publishPageTool } from '@/tools/sharepoint/publish_page' import { readPageTool } from '@/tools/sharepoint/read_page' import { updateListItemTool } from '@/tools/sharepoint/update_list' +import { updatePageTool } from '@/tools/sharepoint/update_page' import { uploadFileTool } from '@/tools/sharepoint/upload_file' +export const sharepointAddListItemTool = addListItemTool export const sharepointCreatePageTool = createPageTool export const sharepointCreateListTool = createListTool +export const sharepointDeleteFileTool = deleteFileTool +export const sharepointDeleteListItemTool = deleteListItemTool +export const sharepointDeletePageTool = deletePageTool +export const sharepointDownloadFileTool = downloadFileTool +export const sharepointGetDriveItemTool = getDriveItemTool export const sharepointGetListTool = getListTool +export const sharepointGetListItemTool = getListItemTool export const sharepointListSitesTool = listSitesTool +export const sharepointPublishPageTool = publishPageTool export const sharepointReadPageTool = readPageTool export const sharepointUpdateListItemTool = updateListItemTool -export const sharepointAddListItemTool = addListItemTool +export const sharepointUpdatePageTool = updatePageTool export const sharepointUploadFileTool = uploadFileTool export * from '@/tools/sharepoint/types' diff --git a/apps/sim/tools/sharepoint/list_sites.ts b/apps/sim/tools/sharepoint/list_sites.ts index 089010ac4c3..609ed400237 100644 --- a/apps/sim/tools/sharepoint/list_sites.ts +++ b/apps/sim/tools/sharepoint/list_sites.ts @@ -62,9 +62,9 @@ export const listSitesTool: ToolConfig = { + id: 'sharepoint_publish_page', + name: 'Publish SharePoint Page', + description: 'Publish the latest version of a SharePoint page, making it available to all users', + version: '1.0.0', + + oauth: { + required: true, + provider: 'sharepoint', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'The access token for the SharePoint API', + }, + siteSelector: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Select the SharePoint site', + }, + siteId: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'The ID of the SharePoint site (internal use)', + }, + pageId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + 'The ID of the page to publish. Example: a GUID like 12345678-1234-1234-1234-123456789012', + }, + }, + + request: { + url: (params) => { + const siteId = optionalTrim(params.siteId) || optionalTrim(params.siteSelector) || 'root' + const pageId = optionalTrim(params.pageId) + if (!pageId) throw new Error('pageId must be provided') + return `https://graph.microsoft.com/v1.0/sites/${encodeURIComponent(siteId)}/pages/${encodeURIComponent(pageId)}/microsoft.graph.sitePage/publish` + }, + method: 'POST', + headers: (params) => ({ + Authorization: `Bearer ${params.accessToken}`, + Accept: 'application/json', + }), + }, + + transformResponse: async (_response: Response, params) => { + return { + success: true, + output: { + published: true, + pageId: params?.pageId ?? '', + }, + } + }, + + outputs: { + published: { type: 'boolean', description: 'Whether the page was published' }, + pageId: { type: 'string', description: 'The ID of the published page' }, + }, +} diff --git a/apps/sim/tools/sharepoint/read_page.ts b/apps/sim/tools/sharepoint/read_page.ts index 554f747af0b..8785b21a5da 100644 --- a/apps/sim/tools/sharepoint/read_page.ts +++ b/apps/sim/tools/sharepoint/read_page.ts @@ -85,12 +85,13 @@ export const readPageTool: ToolConfig ({ id: p.id, name: p.name, title: p.title })), + foundPages: data.value.map((p) => ({ id: p.id, name: p.name, title: p.title })), totalCount: data.value.length, }) if (params?.pageName) { const pageData = data.value[0] - const siteId = params?.siteId || params?.siteSelector || 'root' - const contentUrl = `https://graph.microsoft.com/v1.0/sites/${siteId}/pages/${pageData.id}/microsoft.graph.sitePage?$expand=canvasLayout` + const siteId = optionalTrim(params?.siteId) || optionalTrim(params?.siteSelector) || 'root' + const contentUrl = `https://graph.microsoft.com/v1.0/sites/${encodeURIComponent(siteId)}/pages/${encodeURIComponent(pageData.id)}/microsoft.graph.sitePage?$expand=canvasLayout` logger.info('Making API call to get page content for searched page', { pageId: pageData.id, @@ -253,7 +254,7 @@ export const readPageTool: ToolConfig - }> - webparts?: Array<{ - id: string - innerHtml: string - }> - }> - } + canvasLayout?: CanvasLayout } export interface SharepointPageContent { content: string - canvasLayout?: { - horizontalSections: Array<{ - layout: string - id: string - emphasis: string - webparts: Array<{ - id: string - innerHtml: string - }> - }> - } | null + canvasLayout?: CanvasLayout | null } interface SharepointColumn { @@ -132,9 +104,8 @@ export interface SharepointReadSiteResponse extends ToolResponse { createdDateTime?: string lastModifiedDateTime?: string isPersonalSite?: boolean - root?: { - serverRelativeUrl: string - } + // Graph returns an empty object marker (not a URL) when this site is the root of its site collection + root?: Record siteCollection?: { hostname: string } @@ -177,14 +148,15 @@ export interface SharepointToolParams { listDisplayName?: string listDescription?: string listTemplate?: string - // Update List Item + // Update List Item / Delete List Item / Get List Item itemId?: string listItemFields?: Record - // Upload File + // Upload File / Download File / Delete File / Get Drive Item driveId?: string folderPath?: string fileName?: string files?: UserFile[] + driveItemId?: string } export interface GraphApiResponse { @@ -220,6 +192,8 @@ export interface CanvasLayout { id?: string emphasis?: string columns?: Array<{ + id?: string + width?: number webparts?: Array<{ id?: string innerHtml?: string @@ -242,6 +216,14 @@ export type SharepointResponse = | SharepointUpdateListItemResponse | SharepointAddListItemResponse | SharepointUploadFileResponse + | SharepointDeleteListItemResponse + | SharepointGetListItemResponse + | SharepointDeletePageResponse + | SharepointUpdatePageResponse + | SharepointPublishPageResponse + | SharepointDownloadFileResponse + | SharepointDeleteFileResponse + | SharepointGetDriveItemResponse export interface SharepointGetListResponse extends ToolResponse { output: { @@ -307,3 +289,83 @@ export interface SharepointUploadFileResponse extends ToolResponse { errors?: SharepointUploadError[] } } + +export interface SharepointDeleteListItemResponse extends ToolResponse { + output: { + deleted: boolean + itemId: string + } +} + +export interface SharepointGetListItemResponse extends ToolResponse { + output: { + item: { + id: string + fields?: Record + } + } +} + +export interface SharepointDeletePageResponse extends ToolResponse { + output: { + deleted: boolean + pageId: string + } +} + +export interface SharepointUpdatePageResponse extends ToolResponse { + output: { + page: SharepointPage + } +} + +export interface SharepointPublishPageResponse extends ToolResponse { + output: { + published: boolean + pageId: string + } +} + +export interface SharepointDriveItem { + id: string + name: string + webUrl?: string + size?: number + createdDateTime?: string + lastModifiedDateTime?: string + file?: { + mimeType?: string + } | null + folder?: { + childCount?: number + } | null + parentReference?: { + id?: string + driveId?: string + path?: string + } | null +} + +export interface SharepointDownloadFileResponse extends ToolResponse { + output: { + file: { + name: string + mimeType: string + data: Buffer | string + size: number + } + } +} + +export interface SharepointDeleteFileResponse extends ToolResponse { + output: { + deleted: boolean + itemId: string + } +} + +export interface SharepointGetDriveItemResponse extends ToolResponse { + output: { + driveItem: SharepointDriveItem + } +} diff --git a/apps/sim/tools/sharepoint/update_list.ts b/apps/sim/tools/sharepoint/update_list.ts index a0511e45f2b..850c97f8f03 100644 --- a/apps/sim/tools/sharepoint/update_list.ts +++ b/apps/sim/tools/sharepoint/update_list.ts @@ -1,13 +1,10 @@ -import { createLogger } from '@sim/logger' import type { SharepointToolParams, SharepointUpdateListItemResponse, } from '@/tools/sharepoint/types' -import { optionalTrim } from '@/tools/sharepoint/utils' +import { optionalTrim, sanitizeListItemFields } from '@/tools/sharepoint/utils' import type { ToolConfig } from '@/tools/types' -const logger = createLogger('SharePointUpdateListItem') - export const updateListItemTool: ToolConfig< SharepointToolParams, SharepointUpdateListItemResponse @@ -72,7 +69,7 @@ export const updateListItemTool: ToolConfig< throw new Error('listId must be provided') } const listSegment = encodeURIComponent(listId) - return `https://graph.microsoft.com/v1.0/sites/${siteId}/lists/${listSegment}/items/${encodeURIComponent(itemId)}/fields` + return `https://graph.microsoft.com/v1.0/sites/${encodeURIComponent(siteId)}/lists/${listSegment}/items/${encodeURIComponent(itemId)}/fields` }, method: 'PATCH', headers: (params) => ({ @@ -85,53 +82,7 @@ export const updateListItemTool: ToolConfig< throw new Error('listItemFields must not be empty') } - // Filter out system/read-only fields that cannot be updated via Graph - const readOnlyFields = new Set([ - 'Id', - 'id', - 'UniqueId', - 'GUID', - 'ContentTypeId', - 'Created', - 'Modified', - 'Author', - 'Editor', - 'CreatedBy', - 'ModifiedBy', - 'AuthorId', - 'EditorId', - '_UIVersionString', - 'Attachments', - 'FileRef', - 'FileDirRef', - 'FileLeafRef', - ]) - - const entries = Object.entries(params.listItemFields) - const updatableEntries = entries.filter(([key]) => !readOnlyFields.has(key)) - - if (updatableEntries.length !== entries.length) { - const removed = entries.filter(([key]) => readOnlyFields.has(key)).map(([key]) => key) - logger.warn('Removed read-only SharePoint fields from update', { - removed, - }) - } - - if (updatableEntries.length === 0) { - const requestedKeys = Object.keys(params.listItemFields) - throw new Error( - `All provided fields are read-only and cannot be updated: ${requestedKeys.join(', ')}` - ) - } - - const sanitizedFields = Object.fromEntries(updatableEntries) - - logger.info('Updating SharePoint list item fields', { - listItemId: params.itemId, - listId: params.listId, - fieldsKeys: Object.keys(sanitizedFields), - }) - return sanitizedFields + return sanitizeListItemFields(params.listItemFields, { action: 'update' }) }, }, diff --git a/apps/sim/tools/sharepoint/update_page.ts b/apps/sim/tools/sharepoint/update_page.ts new file mode 100644 index 00000000000..52f5da6153b --- /dev/null +++ b/apps/sim/tools/sharepoint/update_page.ts @@ -0,0 +1,168 @@ +import { createLogger } from '@sim/logger' +import type { + CanvasLayout, + SharepointToolParams, + SharepointUpdatePageResponse, +} from '@/tools/sharepoint/types' +import { escapeHtml, optionalTrim } from '@/tools/sharepoint/utils' +import type { ToolConfig } from '@/tools/types' + +const logger = createLogger('SharePointUpdatePage') + +export const updatePageTool: ToolConfig = { + id: 'sharepoint_update_page', + name: 'Update SharePoint Page', + description: 'Update the title and/or content of a SharePoint page', + version: '1.0.0', + + oauth: { + required: true, + provider: 'sharepoint', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'The access token for the SharePoint API', + }, + siteId: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'The ID of the SharePoint site (internal use)', + }, + siteSelector: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Select the SharePoint site', + }, + pageId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + 'The ID of the page to update. Example: a GUID like 12345678-1234-1234-1234-123456789012', + }, + pageTitle: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'The new title of the page', + }, + pageContent: { + type: 'string', + required: false, + visibility: 'user-only', + description: + 'The new text content of the page. Replaces the entire canvas layout of the page.', + }, + }, + + request: { + url: (params) => { + const siteId = optionalTrim(params.siteId) || optionalTrim(params.siteSelector) || 'root' + const pageId = optionalTrim(params.pageId) + if (!pageId) throw new Error('pageId must be provided') + return `https://graph.microsoft.com/v1.0/sites/${encodeURIComponent(siteId)}/pages/${encodeURIComponent(pageId)}/microsoft.graph.sitePage` + }, + method: 'PATCH', + headers: (params) => ({ + Authorization: `Bearer ${params.accessToken}`, + 'Content-Type': 'application/json', + Accept: 'application/json', + }), + body: (params) => { + const pageTitle = optionalTrim(params.pageTitle) + const pageContent = typeof params.pageContent === 'string' ? params.pageContent : undefined + + if (!pageTitle && !pageContent) { + throw new Error('At least one of pageTitle or pageContent must be provided') + } + + const pageData: { + '@odata.type': string + title?: string + canvasLayout?: CanvasLayout + } = { + '@odata.type': '#microsoft.graph.sitePage', + } + if (pageTitle) pageData.title = pageTitle + + if (pageContent) { + pageData.canvasLayout = { + horizontalSections: [ + { + layout: 'oneColumn', + id: '1', + emphasis: 'none', + columns: [ + { + id: '1', + width: 12, + webparts: [ + { + id: '6f9230af-2a98-4952-b205-9ede4f9ef548', + innerHtml: `

${escapeHtml(pageContent)}

`, + }, + ], + }, + ], + }, + ], + } + } + + logger.info('Updating SharePoint page', { + pageId: params.pageId, + hasTitle: !!pageTitle, + hasContent: !!pageContent, + }) + + return pageData + }, + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + logger.info('SharePoint page updated successfully', { + pageId: data.id, + pageName: data.name, + pageTitle: data.title, + }) + + return { + success: true, + output: { + page: { + id: data.id, + name: data.name, + title: data.title || data.name, + webUrl: data.webUrl, + pageLayout: data.pageLayout, + createdDateTime: data.createdDateTime, + lastModifiedDateTime: data.lastModifiedDateTime, + }, + }, + } + }, + + outputs: { + page: { + type: 'object', + description: 'Updated SharePoint page information', + properties: { + id: { type: 'string', description: 'The unique ID of the page' }, + name: { type: 'string', description: 'The name of the page' }, + title: { type: 'string', description: 'The title of the page' }, + webUrl: { type: 'string', description: 'The URL to access the page' }, + pageLayout: { type: 'string', description: 'The layout type of the page' }, + createdDateTime: { type: 'string', description: 'When the page was created' }, + lastModifiedDateTime: { type: 'string', description: 'When the page was last modified' }, + }, + }, + }, +} diff --git a/apps/sim/tools/sharepoint/utils.ts b/apps/sim/tools/sharepoint/utils.ts index 200e67156eb..9e7d60abacd 100644 --- a/apps/sim/tools/sharepoint/utils.ts +++ b/apps/sim/tools/sharepoint/utils.ts @@ -13,6 +13,15 @@ export function escapeODataString(value: string): string { return value.replace(/'/g, "''") } +export function escapeHtml(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') +} + export function getGraphNextPageUrl(data: object): string | undefined { const nextLink = (data as Record)['@odata.nextLink'] return typeof nextLink === 'string' ? nextLink : undefined @@ -102,6 +111,57 @@ export function extractTextFromCanvasLayout(canvasLayout: CanvasLayout | null | return finalContent } +/** SharePoint list item fields that are system-managed and cannot be set via the Graph API. */ +export const READ_ONLY_LIST_ITEM_FIELDS = new Set([ + 'Id', + 'id', + 'UniqueId', + 'GUID', + 'ContentTypeId', + 'Created', + 'Modified', + 'Author', + 'Editor', + 'CreatedBy', + 'ModifiedBy', + 'AuthorId', + 'EditorId', + '_UIVersionString', + 'Attachments', + 'FileRef', + 'FileDirRef', + 'FileLeafRef', +]) + +/** + * Removes read-only/system-managed fields from a SharePoint list item field set, logging any + * fields that were stripped. Throws if no updatable fields remain. + */ +export function sanitizeListItemFields( + fields: Record, + context: { action: 'update' | 'create' } +): Record { + const entries = Object.entries(fields) + const updatableEntries = entries.filter(([key]) => !READ_ONLY_LIST_ITEM_FIELDS.has(key)) + + if (updatableEntries.length !== entries.length) { + const removed = entries + .filter(([key]) => READ_ONLY_LIST_ITEM_FIELDS.has(key)) + .map(([key]) => key) + logger.warn(`Removed read-only SharePoint fields from ${context.action}`, { removed }) + } + + if (updatableEntries.length === 0) { + const requestedKeys = Object.keys(fields) + const verb = context.action === 'update' ? 'updated' : 'set' + throw new Error( + `All provided fields are read-only and cannot be ${verb}: ${requestedKeys.join(', ')}` + ) + } + + return Object.fromEntries(updatableEntries) +} + export function cleanODataMetadata(obj: T): T { if (!obj || typeof obj !== 'object') return obj From f33d325a880c38e4c728ac6b0a3f1805bb751e5a Mon Sep 17 00:00:00 2001 From: Waleed Date: Thu, 2 Jul 2026 10:46:50 -0700 Subject: [PATCH 15/28] fix(deps): bump echarts to 6.1.0 to patch XSS vulnerability (#5374) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes GHSA-fgmj-fm8m-jvvx / CVE-2026-45249 — Lines series tooltip rendering could execute raw HTML from series.data[i].name when no custom tooltip.formatter is set. --- apps/sim/package.json | 2 +- bun.lock | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/sim/package.json b/apps/sim/package.json index b89a1fed3d7..1fd1b983bc4 100644 --- a/apps/sim/package.json +++ b/apps/sim/package.json @@ -140,7 +140,7 @@ "docx": "^9.6.1", "docx-preview": "^0.3.7", "drizzle-orm": "^0.45.2", - "echarts": "6.0.0", + "echarts": "6.1.0", "es-toolkit": "1.45.1", "ffmpeg-static": "5.3.0", "fluent-ffmpeg": "2.1.3", diff --git a/bun.lock b/bun.lock index cc225c5ee5c..f26ccca3066 100644 --- a/bun.lock +++ b/bun.lock @@ -208,7 +208,7 @@ "docx": "^9.6.1", "docx-preview": "^0.3.7", "drizzle-orm": "^0.45.2", - "echarts": "6.0.0", + "echarts": "6.1.0", "es-toolkit": "1.45.1", "ffmpeg-static": "5.3.0", "fluent-ffmpeg": "2.1.3", @@ -2439,7 +2439,7 @@ "ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="], - "echarts": ["echarts@6.0.0", "", { "dependencies": { "tslib": "2.3.0", "zrender": "6.0.0" } }, "sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ=="], + "echarts": ["echarts@6.1.0", "", { "dependencies": { "tslib": "2.3.0", "zrender": "6.1.0" } }, "sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA=="], "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], @@ -4043,7 +4043,7 @@ "zod-validation-error": ["zod-validation-error@1.5.0", "", { "peerDependencies": { "zod": "^3.18.0" } }, "sha512-/7eFkAI4qV0tcxMBB/3+d2c1P6jzzZYdYSlBuAklzMuCrJu5bzJfHS0yVAS87dRHVlhftd6RFJDIvv03JgkSbw=="], - "zrender": ["zrender@6.0.0", "", { "dependencies": { "tslib": "2.3.0" } }, "sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg=="], + "zrender": ["zrender@6.1.0", "", { "dependencies": { "tslib": "2.3.0" } }, "sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ=="], "zustand": ["zustand@5.0.14", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g=="], From 7a31871471cffcdd5368a20fe8c45617f2a1a29e Mon Sep 17 00:00:00 2001 From: Waleed Date: Thu, 2 Jul 2026 10:47:08 -0700 Subject: [PATCH 16/28] feat(clerk): expand Clerk integration with org, membership, moderation, and security tools (#5364) * feat(clerk): expand Clerk integration with org, membership, moderation, and security tools - fix 4 validate-integration warnings: missing .trim() on org/session IDs, incomplete session-status dropdown, missing list_users/list_organizations filter subBlocks - add organization update/delete tools - add organization membership CRUD (list, add, update role, remove) - add organization invitation create/list - add user ban/unban/lock/unlock and OAuth access token retrieval - add allowlist/blocklist identifier management - add JWT template list/get - add actor token create/revoke (impersonation) - add matching webhook triggers for session ended/removed/revoked, organization updated/deleted, and membership updated/deleted - wire all 23 new tools into the block, tool registry, and trigger registry * fix(clerk): I/O completeness fixes from final validation pass - remove dead limit/offset params from list_blocklist_identifiers (Clerk API accepts zero params on this endpoint, verified across 6 spec versions) - expose publicMetadata on OAuth access token output (was silently dropped) - expose inviter email/first/last name (public_inviter_data) on organization invitation create/list outputs - add missing orderBy param to list_organization_invitations --- apps/sim/blocks/blocks/clerk.ts | 526 +++++++++++++++- .../tools/clerk/add_organization_member.ts | 123 ++++ apps/sim/tools/clerk/ban_user.ts | 87 +++ apps/sim/tools/clerk/create_actor_token.ts | 121 ++++ .../clerk/create_allowlist_identifier.ts | 101 ++++ .../clerk/create_blocklist_identifier.ts | 87 +++ .../clerk/create_organization_invitation.ts | 167 ++++++ .../clerk/delete_allowlist_identifier.ts | 80 +++ .../clerk/delete_blocklist_identifier.ts | 80 +++ apps/sim/tools/clerk/delete_organization.ts | 78 +++ apps/sim/tools/clerk/get_jwt_template.ts | 94 +++ apps/sim/tools/clerk/get_organization.ts | 2 +- apps/sim/tools/clerk/get_session.ts | 2 +- apps/sim/tools/clerk/get_user_oauth_token.ts | 116 ++++ apps/sim/tools/clerk/index.ts | 23 + .../tools/clerk/list_allowlist_identifiers.ts | 119 ++++ .../tools/clerk/list_blocklist_identifiers.ts | 94 +++ apps/sim/tools/clerk/list_jwt_templates.ts | 101 ++++ .../clerk/list_organization_invitations.ts | 162 +++++ .../clerk/list_organization_memberships.ts | 167 ++++++ apps/sim/tools/clerk/lock_user.ts | 89 +++ .../tools/clerk/remove_organization_member.ts | 114 ++++ apps/sim/tools/clerk/revoke_actor_token.ts | 89 +++ apps/sim/tools/clerk/revoke_session.ts | 2 +- apps/sim/tools/clerk/types.ts | 561 ++++++++++++++++++ apps/sim/tools/clerk/unban_user.ts | 89 +++ apps/sim/tools/clerk/unlock_user.ts | 89 +++ apps/sim/tools/clerk/update_organization.ts | 138 +++++ .../clerk/update_organization_membership.ts | 123 ++++ apps/sim/tools/registry.ts | 46 ++ apps/sim/triggers/clerk/index.ts | 7 + .../triggers/clerk/organization_deleted.ts | 38 ++ .../clerk/organization_membership_deleted.ts | 38 ++ .../clerk/organization_membership_updated.ts | 38 ++ .../triggers/clerk/organization_updated.ts | 38 ++ apps/sim/triggers/clerk/session_ended.ts | 38 ++ apps/sim/triggers/clerk/session_removed.ts | 38 ++ apps/sim/triggers/clerk/session_revoked.ts | 38 ++ apps/sim/triggers/clerk/utils.ts | 43 +- apps/sim/triggers/registry.ts | 14 + 40 files changed, 3968 insertions(+), 32 deletions(-) create mode 100644 apps/sim/tools/clerk/add_organization_member.ts create mode 100644 apps/sim/tools/clerk/ban_user.ts create mode 100644 apps/sim/tools/clerk/create_actor_token.ts create mode 100644 apps/sim/tools/clerk/create_allowlist_identifier.ts create mode 100644 apps/sim/tools/clerk/create_blocklist_identifier.ts create mode 100644 apps/sim/tools/clerk/create_organization_invitation.ts create mode 100644 apps/sim/tools/clerk/delete_allowlist_identifier.ts create mode 100644 apps/sim/tools/clerk/delete_blocklist_identifier.ts create mode 100644 apps/sim/tools/clerk/delete_organization.ts create mode 100644 apps/sim/tools/clerk/get_jwt_template.ts create mode 100644 apps/sim/tools/clerk/get_user_oauth_token.ts create mode 100644 apps/sim/tools/clerk/list_allowlist_identifiers.ts create mode 100644 apps/sim/tools/clerk/list_blocklist_identifiers.ts create mode 100644 apps/sim/tools/clerk/list_jwt_templates.ts create mode 100644 apps/sim/tools/clerk/list_organization_invitations.ts create mode 100644 apps/sim/tools/clerk/list_organization_memberships.ts create mode 100644 apps/sim/tools/clerk/lock_user.ts create mode 100644 apps/sim/tools/clerk/remove_organization_member.ts create mode 100644 apps/sim/tools/clerk/revoke_actor_token.ts create mode 100644 apps/sim/tools/clerk/unban_user.ts create mode 100644 apps/sim/tools/clerk/unlock_user.ts create mode 100644 apps/sim/tools/clerk/update_organization.ts create mode 100644 apps/sim/tools/clerk/update_organization_membership.ts create mode 100644 apps/sim/triggers/clerk/organization_deleted.ts create mode 100644 apps/sim/triggers/clerk/organization_membership_deleted.ts create mode 100644 apps/sim/triggers/clerk/organization_membership_updated.ts create mode 100644 apps/sim/triggers/clerk/organization_updated.ts create mode 100644 apps/sim/triggers/clerk/session_ended.ts create mode 100644 apps/sim/triggers/clerk/session_removed.ts create mode 100644 apps/sim/triggers/clerk/session_revoked.ts diff --git a/apps/sim/blocks/blocks/clerk.ts b/apps/sim/blocks/blocks/clerk.ts index ebdb7309157..b383283d005 100644 --- a/apps/sim/blocks/blocks/clerk.ts +++ b/apps/sim/blocks/blocks/clerk.ts @@ -9,7 +9,7 @@ export const ClerkBlock: BlockConfig = { name: 'Clerk', description: 'Manage users, organizations, and sessions in Clerk', longDescription: - 'Integrate Clerk authentication and user management into your workflow. Create, update, delete, and list users. Manage organizations and their memberships. Monitor and control user sessions.', + 'Integrate Clerk authentication and user management into your workflow. Create, update, delete, ban, lock, and list users. Manage organizations, their memberships, and invitations. Monitor and control user sessions. Maintain allowlist/blocklist identifiers, JWT templates, and actor tokens.', docsLink: 'https://docs.sim.ai/integrations/clerk', category: 'tools', integrationType: IntegrationType.Security, @@ -27,12 +27,35 @@ export const ClerkBlock: BlockConfig = { { label: 'Create User', id: 'clerk_create_user' }, { label: 'Update User', id: 'clerk_update_user' }, { label: 'Delete User', id: 'clerk_delete_user' }, + { label: 'Ban User', id: 'clerk_ban_user' }, + { label: 'Unban User', id: 'clerk_unban_user' }, + { label: 'Lock User', id: 'clerk_lock_user' }, + { label: 'Unlock User', id: 'clerk_unlock_user' }, + { label: 'Get User OAuth Token', id: 'clerk_get_user_oauth_token' }, { label: 'List Organizations', id: 'clerk_list_organizations' }, { label: 'Get Organization', id: 'clerk_get_organization' }, { label: 'Create Organization', id: 'clerk_create_organization' }, + { label: 'Update Organization', id: 'clerk_update_organization' }, + { label: 'Delete Organization', id: 'clerk_delete_organization' }, + { label: 'List Organization Memberships', id: 'clerk_list_organization_memberships' }, + { label: 'Add Organization Member', id: 'clerk_add_organization_member' }, + { label: 'Update Organization Membership', id: 'clerk_update_organization_membership' }, + { label: 'Remove Organization Member', id: 'clerk_remove_organization_member' }, + { label: 'Create Organization Invitation', id: 'clerk_create_organization_invitation' }, + { label: 'List Organization Invitations', id: 'clerk_list_organization_invitations' }, { label: 'List Sessions', id: 'clerk_list_sessions' }, { label: 'Get Session', id: 'clerk_get_session' }, { label: 'Revoke Session', id: 'clerk_revoke_session' }, + { label: 'List Allowlist Identifiers', id: 'clerk_list_allowlist_identifiers' }, + { label: 'Create Allowlist Identifier', id: 'clerk_create_allowlist_identifier' }, + { label: 'Delete Allowlist Identifier', id: 'clerk_delete_allowlist_identifier' }, + { label: 'List Blocklist Identifiers', id: 'clerk_list_blocklist_identifiers' }, + { label: 'Create Blocklist Identifier', id: 'clerk_create_blocklist_identifier' }, + { label: 'Delete Blocklist Identifier', id: 'clerk_delete_blocklist_identifier' }, + { label: 'List JWT Templates', id: 'clerk_list_jwt_templates' }, + { label: 'Get JWT Template', id: 'clerk_get_jwt_template' }, + { label: 'Create Actor Token', id: 'clerk_create_actor_token' }, + { label: 'Revoke Actor Token', id: 'clerk_revoke_actor_token' }, ], value: () => 'clerk_list_users', }, @@ -68,7 +91,47 @@ export const ClerkBlock: BlockConfig = { condition: { field: 'operation', value: 'clerk_list_users' }, mode: 'advanced', }, - // Get User params + { + id: 'phoneNumberFilter', + title: 'Phone Filter', + type: 'short-input', + placeholder: 'Filter by phone number (comma-separated)', + condition: { field: 'operation', value: 'clerk_list_users' }, + mode: 'advanced', + }, + { + id: 'externalIdFilter', + title: 'External ID Filter', + type: 'short-input', + placeholder: 'Filter by external ID (comma-separated)', + condition: { field: 'operation', value: 'clerk_list_users' }, + mode: 'advanced', + }, + { + id: 'userIdFilter', + title: 'User ID Filter', + type: 'short-input', + placeholder: 'Filter by user ID (comma-separated)', + condition: { field: 'operation', value: 'clerk_list_users' }, + mode: 'advanced', + }, + { + id: 'orderBy', + title: 'Sort By', + type: 'short-input', + placeholder: 'e.g. -created_at', + condition: { + field: 'operation', + value: [ + 'clerk_list_users', + 'clerk_list_organizations', + 'clerk_list_organization_memberships', + 'clerk_list_organization_invitations', + ], + }, + mode: 'advanced', + }, + // Get/Update/Delete/Ban/Unban/Lock/Unlock User, OAuth token, and Actor Token params { id: 'userId', title: 'User ID', @@ -76,20 +139,58 @@ export const ClerkBlock: BlockConfig = { placeholder: 'user_...', condition: { field: 'operation', - value: ['clerk_get_user', 'clerk_update_user', 'clerk_delete_user'], + value: [ + 'clerk_get_user', + 'clerk_update_user', + 'clerk_delete_user', + 'clerk_ban_user', + 'clerk_unban_user', + 'clerk_lock_user', + 'clerk_unlock_user', + 'clerk_get_user_oauth_token', + 'clerk_add_organization_member', + 'clerk_update_organization_membership', + 'clerk_remove_organization_member', + 'clerk_create_actor_token', + ], }, required: { field: 'operation', - value: ['clerk_get_user', 'clerk_update_user', 'clerk_delete_user'], + value: [ + 'clerk_get_user', + 'clerk_update_user', + 'clerk_delete_user', + 'clerk_ban_user', + 'clerk_unban_user', + 'clerk_lock_user', + 'clerk_unlock_user', + 'clerk_get_user_oauth_token', + 'clerk_add_organization_member', + 'clerk_update_organization_membership', + 'clerk_remove_organization_member', + 'clerk_create_actor_token', + ], }, }, + { + id: 'provider', + title: 'OAuth Provider', + type: 'short-input', + placeholder: 'google, github, microsoft...', + condition: { field: 'operation', value: 'clerk_get_user_oauth_token' }, + required: { field: 'operation', value: 'clerk_get_user_oauth_token' }, + }, // Create/Update User params { id: 'emailAddress', title: 'Email Address', type: 'short-input', placeholder: 'user@example.com (comma-separated for multiple)', - condition: { field: 'operation', value: 'clerk_create_user' }, + condition: { + field: 'operation', + value: ['clerk_create_user', 'clerk_create_organization_invitation'], + }, + required: { field: 'operation', value: 'clerk_create_organization_invitation' }, }, { id: 'phoneNumber', @@ -143,7 +244,15 @@ export const ClerkBlock: BlockConfig = { type: 'code', language: 'json', placeholder: '{"role": "admin"}', - condition: { field: 'operation', value: ['clerk_create_user', 'clerk_update_user'] }, + condition: { + field: 'operation', + value: [ + 'clerk_create_user', + 'clerk_update_user', + 'clerk_create_organization', + 'clerk_create_organization_invitation', + ], + }, mode: 'advanced', }, { @@ -152,7 +261,15 @@ export const ClerkBlock: BlockConfig = { type: 'code', language: 'json', placeholder: '{"internalId": "123"}', - condition: { field: 'operation', value: ['clerk_create_user', 'clerk_update_user'] }, + condition: { + field: 'operation', + value: [ + 'clerk_create_user', + 'clerk_update_user', + 'clerk_create_organization', + 'clerk_create_organization_invitation', + ], + }, mode: 'advanced', }, // Organization params @@ -176,15 +293,44 @@ export const ClerkBlock: BlockConfig = { title: 'Organization ID', type: 'short-input', placeholder: 'org_... or slug', - condition: { field: 'operation', value: 'clerk_get_organization' }, - required: { field: 'operation', value: 'clerk_get_organization' }, + condition: { + field: 'operation', + value: [ + 'clerk_get_organization', + 'clerk_update_organization', + 'clerk_delete_organization', + 'clerk_list_organization_memberships', + 'clerk_add_organization_member', + 'clerk_update_organization_membership', + 'clerk_remove_organization_member', + 'clerk_create_organization_invitation', + 'clerk_list_organization_invitations', + ], + }, + required: { + field: 'operation', + value: [ + 'clerk_get_organization', + 'clerk_update_organization', + 'clerk_delete_organization', + 'clerk_list_organization_memberships', + 'clerk_add_organization_member', + 'clerk_update_organization_membership', + 'clerk_remove_organization_member', + 'clerk_create_organization_invitation', + 'clerk_list_organization_invitations', + ], + }, }, { id: 'orgName', title: 'Organization Name', type: 'short-input', placeholder: 'Acme Corp', - condition: { field: 'operation', value: 'clerk_create_organization' }, + condition: { + field: 'operation', + value: ['clerk_create_organization', 'clerk_update_organization'], + }, required: { field: 'operation', value: 'clerk_create_organization' }, }, { @@ -200,7 +346,10 @@ export const ClerkBlock: BlockConfig = { title: 'Slug', type: 'short-input', placeholder: 'acme-corp', - condition: { field: 'operation', value: 'clerk_create_organization' }, + condition: { + field: 'operation', + value: ['clerk_create_organization', 'clerk_update_organization'], + }, mode: 'advanced', }, { @@ -208,7 +357,95 @@ export const ClerkBlock: BlockConfig = { title: 'Max Members', type: 'short-input', placeholder: '0 for unlimited', - condition: { field: 'operation', value: 'clerk_create_organization' }, + condition: { + field: 'operation', + value: ['clerk_create_organization', 'clerk_update_organization'], + }, + mode: 'advanced', + }, + { + id: 'adminDeleteEnabled', + title: 'Admin Delete Enabled', + type: 'switch', + condition: { field: 'operation', value: 'clerk_update_organization' }, + mode: 'advanced', + }, + // Organization Membership / Invitation params + { + id: 'role', + title: 'Role', + type: 'short-input', + placeholder: 'org:admin or org:member', + condition: { + field: 'operation', + value: [ + 'clerk_add_organization_member', + 'clerk_update_organization_membership', + 'clerk_create_organization_invitation', + 'clerk_list_organization_memberships', + ], + }, + required: { + field: 'operation', + value: [ + 'clerk_add_organization_member', + 'clerk_update_organization_membership', + 'clerk_create_organization_invitation', + ], + }, + }, + { + id: 'inviterUserId', + title: 'Inviter User ID', + type: 'short-input', + placeholder: 'user_... (who sent the invite)', + condition: { field: 'operation', value: 'clerk_create_organization_invitation' }, + mode: 'advanced', + }, + { + id: 'redirectUrl', + title: 'Redirect URL', + type: 'short-input', + placeholder: 'https://yourapp.com/accept-invite', + condition: { field: 'operation', value: 'clerk_create_organization_invitation' }, + mode: 'advanced', + }, + { + id: 'expiresInDays', + title: 'Expires In (Days)', + type: 'short-input', + placeholder: '1-365, default: 30', + condition: { field: 'operation', value: 'clerk_create_organization_invitation' }, + mode: 'advanced', + }, + { + id: 'notifyInvitation', + title: 'Send Invitation Email', + type: 'switch', + condition: { field: 'operation', value: 'clerk_create_organization_invitation' }, + mode: 'advanced', + }, + { + id: 'invitationEmailFilter', + title: 'Email Filter', + type: 'short-input', + placeholder: 'Filter by invited email', + condition: { field: 'operation', value: 'clerk_list_organization_invitations' }, + mode: 'advanced', + }, + { + id: 'invitationStatus', + title: 'Status', + type: 'dropdown', + options: [ + { label: 'All', id: '' }, + { label: 'Pending', id: 'pending' }, + { label: 'Accepted', id: 'accepted' }, + { label: 'Revoked', id: 'revoked' }, + { label: 'Expired', id: 'expired' }, + ], + value: () => '', + condition: { field: 'operation', value: 'clerk_list_organization_invitations' }, mode: 'advanced', }, // Session params @@ -238,6 +475,8 @@ export const ClerkBlock: BlockConfig = { { label: 'Ended', id: 'ended' }, { label: 'Expired', id: 'expired' }, { label: 'Revoked', id: 'revoked' }, + { label: 'Removed', id: 'removed' }, + { label: 'Replaced', id: 'replaced' }, { label: 'Abandoned', id: 'abandoned' }, { label: 'Pending', id: 'pending' }, ], @@ -253,6 +492,85 @@ export const ClerkBlock: BlockConfig = { condition: { field: 'operation', value: ['clerk_get_session', 'clerk_revoke_session'] }, required: { field: 'operation', value: ['clerk_get_session', 'clerk_revoke_session'] }, }, + // Allowlist / Blocklist params + { + id: 'identifier', + title: 'Identifier', + type: 'short-input', + placeholder: 'user@example.com, +1234567890, or a web3 wallet', + condition: { + field: 'operation', + value: ['clerk_create_allowlist_identifier', 'clerk_create_blocklist_identifier'], + }, + required: { + field: 'operation', + value: ['clerk_create_allowlist_identifier', 'clerk_create_blocklist_identifier'], + }, + }, + { + id: 'allowlistNotify', + title: 'Notify Identifier', + type: 'switch', + condition: { field: 'operation', value: 'clerk_create_allowlist_identifier' }, + mode: 'advanced', + }, + { + id: 'identifierId', + title: 'Identifier ID', + type: 'short-input', + placeholder: 'The ID of the allowlist/blocklist identifier', + condition: { + field: 'operation', + value: ['clerk_delete_allowlist_identifier', 'clerk_delete_blocklist_identifier'], + }, + required: { + field: 'operation', + value: ['clerk_delete_allowlist_identifier', 'clerk_delete_blocklist_identifier'], + }, + }, + // JWT Template params + { + id: 'templateId', + title: 'Template ID', + type: 'short-input', + placeholder: 'The ID of the JWT template', + condition: { field: 'operation', value: 'clerk_get_jwt_template' }, + required: { field: 'operation', value: 'clerk_get_jwt_template' }, + }, + // Actor Token params + { + id: 'actor', + title: 'Actor', + type: 'code', + language: 'json', + placeholder: '{"sub": "user_support_agent_id"}', + condition: { field: 'operation', value: 'clerk_create_actor_token' }, + required: { field: 'operation', value: 'clerk_create_actor_token' }, + }, + { + id: 'expiresInSeconds', + title: 'Expires In (Seconds)', + type: 'short-input', + placeholder: 'Default: 3600', + condition: { field: 'operation', value: 'clerk_create_actor_token' }, + mode: 'advanced', + }, + { + id: 'sessionMaxDurationInSeconds', + title: 'Session Max Duration (Seconds)', + type: 'short-input', + placeholder: 'Default: 1800', + condition: { field: 'operation', value: 'clerk_create_actor_token' }, + mode: 'advanced', + }, + { + id: 'actorTokenId', + title: 'Actor Token ID', + type: 'short-input', + placeholder: 'The ID of the actor token to revoke', + condition: { field: 'operation', value: 'clerk_revoke_actor_token' }, + required: { field: 'operation', value: 'clerk_revoke_actor_token' }, + }, // Pagination params (common) { id: 'limit', @@ -261,7 +579,14 @@ export const ClerkBlock: BlockConfig = { placeholder: 'Results per page (1-500, default: 10)', condition: { field: 'operation', - value: ['clerk_list_users', 'clerk_list_organizations', 'clerk_list_sessions'], + value: [ + 'clerk_list_users', + 'clerk_list_organizations', + 'clerk_list_sessions', + 'clerk_list_organization_memberships', + 'clerk_list_organization_invitations', + 'clerk_list_allowlist_identifiers', + ], }, mode: 'advanced', }, @@ -272,7 +597,14 @@ export const ClerkBlock: BlockConfig = { placeholder: 'Skip N results for pagination', condition: { field: 'operation', - value: ['clerk_list_users', 'clerk_list_organizations', 'clerk_list_sessions'], + value: [ + 'clerk_list_users', + 'clerk_list_organizations', + 'clerk_list_sessions', + 'clerk_list_organization_memberships', + 'clerk_list_organization_invitations', + 'clerk_list_allowlist_identifiers', + ], }, mode: 'advanced', }, @@ -280,8 +612,15 @@ export const ClerkBlock: BlockConfig = { ...getTrigger('clerk_user_updated').subBlocks, ...getTrigger('clerk_user_deleted').subBlocks, ...getTrigger('clerk_session_created').subBlocks, + ...getTrigger('clerk_session_ended').subBlocks, + ...getTrigger('clerk_session_removed').subBlocks, + ...getTrigger('clerk_session_revoked').subBlocks, ...getTrigger('clerk_organization_created').subBlocks, + ...getTrigger('clerk_organization_updated').subBlocks, + ...getTrigger('clerk_organization_deleted').subBlocks, ...getTrigger('clerk_organization_membership_created').subBlocks, + ...getTrigger('clerk_organization_membership_updated').subBlocks, + ...getTrigger('clerk_organization_membership_deleted').subBlocks, ...getTrigger('clerk_webhook').subBlocks, ], @@ -292,8 +631,15 @@ export const ClerkBlock: BlockConfig = { 'clerk_user_updated', 'clerk_user_deleted', 'clerk_session_created', + 'clerk_session_ended', + 'clerk_session_removed', + 'clerk_session_revoked', 'clerk_organization_created', + 'clerk_organization_updated', + 'clerk_organization_deleted', 'clerk_organization_membership_created', + 'clerk_organization_membership_updated', + 'clerk_organization_membership_deleted', 'clerk_webhook', ], }, @@ -305,12 +651,35 @@ export const ClerkBlock: BlockConfig = { 'clerk_create_user', 'clerk_update_user', 'clerk_delete_user', + 'clerk_ban_user', + 'clerk_unban_user', + 'clerk_lock_user', + 'clerk_unlock_user', + 'clerk_get_user_oauth_token', 'clerk_list_organizations', 'clerk_get_organization', 'clerk_create_organization', + 'clerk_update_organization', + 'clerk_delete_organization', + 'clerk_list_organization_memberships', + 'clerk_add_organization_member', + 'clerk_update_organization_membership', + 'clerk_remove_organization_member', + 'clerk_create_organization_invitation', + 'clerk_list_organization_invitations', 'clerk_list_sessions', 'clerk_get_session', 'clerk_revoke_session', + 'clerk_list_allowlist_identifiers', + 'clerk_create_allowlist_identifier', + 'clerk_delete_allowlist_identifier', + 'clerk_list_blocklist_identifiers', + 'clerk_create_blocklist_identifier', + 'clerk_delete_blocklist_identifier', + 'clerk_list_jwt_templates', + 'clerk_get_jwt_template', + 'clerk_create_actor_token', + 'clerk_revoke_actor_token', ], config: { tool: (params) => params.operation as string, @@ -320,13 +689,20 @@ export const ClerkBlock: BlockConfig = { secretKey, emailAddressFilter, usernameFilter, + phoneNumberFilter, + externalIdFilter, + userIdFilter, orgQuery, orgName, sessionUserId, sessionStatus, + invitationEmailFilter, + invitationStatus, + notifyInvitation, + allowlistNotify, publicMetadata, privateMetadata, - maxAllowedMemberships, + actor, ...rest } = params @@ -339,9 +715,14 @@ export const ClerkBlock: BlockConfig = { case 'clerk_list_users': if (emailAddressFilter) cleanParams.emailAddress = emailAddressFilter if (usernameFilter) cleanParams.username = usernameFilter + if (phoneNumberFilter) cleanParams.phoneNumber = phoneNumberFilter + if (externalIdFilter) cleanParams.externalId = externalIdFilter + if (userIdFilter) cleanParams.userId = userIdFilter break case 'clerk_create_user': case 'clerk_update_user': + case 'clerk_create_organization_invitation': + case 'clerk_create_organization': if (publicMetadata) { cleanParams.publicMetadata = typeof publicMetadata === 'string' ? JSON.parse(publicMetadata) : publicMetadata @@ -350,25 +731,54 @@ export const ClerkBlock: BlockConfig = { cleanParams.privateMetadata = typeof privateMetadata === 'string' ? JSON.parse(privateMetadata) : privateMetadata } + if ( + operation === 'clerk_create_organization_invitation' && + notifyInvitation !== undefined + ) { + cleanParams.notify = notifyInvitation + } + if (operation === 'clerk_create_organization' && orgName) { + cleanParams.name = orgName + } break case 'clerk_list_organizations': if (orgQuery) cleanParams.query = orgQuery break - case 'clerk_create_organization': + case 'clerk_update_organization': if (orgName) cleanParams.name = orgName - if (maxAllowedMemberships) - cleanParams.maxAllowedMemberships = Number(maxAllowedMemberships) break case 'clerk_list_sessions': if (sessionUserId) cleanParams.userId = sessionUserId if (sessionStatus) cleanParams.status = sessionStatus break + case 'clerk_list_organization_invitations': + if (invitationEmailFilter) cleanParams.emailAddress = invitationEmailFilter + if (invitationStatus) cleanParams.status = invitationStatus + break + case 'clerk_create_allowlist_identifier': + if (allowlistNotify !== undefined) cleanParams.notify = allowlistNotify + break + case 'clerk_create_actor_token': + if (actor !== undefined) { + cleanParams.actor = typeof actor === 'string' ? JSON.parse(actor) : actor + } + break } + // Fields that arrive as strings from short-input UI but must be numbers at execution time + const numericFields = new Set([ + 'limit', + 'offset', + 'maxAllowedMemberships', + 'expiresInDays', + 'expiresInSeconds', + 'sessionMaxDurationInSeconds', + ]) + // Add remaining params that don't need mapping Object.entries(rest).forEach(([key, value]) => { if (value !== undefined && value !== null && value !== '') { - cleanParams[key] = value + cleanParams[key] = numericFields.has(key) ? Number(value) : value } }) @@ -383,6 +793,7 @@ export const ClerkBlock: BlockConfig = { userId: { type: 'string', description: 'User ID' }, organizationId: { type: 'string', description: 'Organization ID or slug' }, sessionId: { type: 'string', description: 'Session ID' }, + role: { type: 'string', description: 'Organization role, e.g. org:admin or org:member' }, query: { type: 'string', description: 'Search query' }, limit: { type: 'number', description: 'Results per page' }, offset: { type: 'number', description: 'Pagination offset' }, @@ -393,8 +804,13 @@ export const ClerkBlock: BlockConfig = { users: { type: 'json', description: 'Array of user objects' }, organizations: { type: 'json', description: 'Array of organization objects' }, sessions: { type: 'json', description: 'Array of session objects' }, + memberships: { type: 'json', description: 'Array of organization membership objects' }, + invitations: { type: 'json', description: 'Array of organization invitation objects' }, + identifiers: { type: 'json', description: 'Array of allowlist/blocklist identifier objects' }, + templates: { type: 'json', description: 'Array of JWT template objects' }, + accessTokens: { type: 'json', description: 'Array of OAuth access token objects' }, // Single entity fields (destructured from get/create/update operations) - id: { type: 'string', description: 'Resource ID (user, organization, or session)' }, + id: { type: 'string', description: 'Resource ID (user, organization, session, etc.)' }, name: { type: 'string', description: 'Organization name' }, slug: { type: 'string', description: 'Organization slug' }, username: { type: 'string', description: 'Username' }, @@ -404,23 +820,70 @@ export const ClerkBlock: BlockConfig = { hasImage: { type: 'boolean', description: 'Whether resource has an image' }, emailAddresses: { type: 'json', description: 'User email addresses' }, phoneNumbers: { type: 'json', description: 'User phone numbers' }, + emailAddress: { type: 'string', description: 'Email address (for invitations)' }, primaryEmailAddressId: { type: 'string', description: 'Primary email address ID' }, primaryPhoneNumberId: { type: 'string', description: 'Primary phone number ID' }, + primaryWeb3WalletId: { type: 'string', description: 'Primary Web3 wallet ID' }, externalId: { type: 'string', description: 'External system ID' }, passwordEnabled: { type: 'boolean', description: 'Whether password is enabled' }, twoFactorEnabled: { type: 'boolean', description: 'Whether 2FA is enabled' }, + totpEnabled: { type: 'boolean', description: 'Whether TOTP is enabled' }, + backupCodeEnabled: { type: 'boolean', description: 'Whether backup codes are enabled' }, + deleteSelfEnabled: { type: 'boolean', description: 'Whether user can delete themselves' }, + createOrganizationEnabled: { + type: 'boolean', + description: 'Whether user can create organizations', + }, banned: { type: 'boolean', description: 'Whether user is banned' }, locked: { type: 'boolean', description: 'Whether user is locked' }, - userId: { type: 'string', description: 'User ID (for sessions)' }, + lockoutExpiresInSeconds: { type: 'number', description: 'Seconds until lockout expires' }, + userId: { type: 'string', description: 'User ID (for sessions and memberships)' }, clientId: { type: 'string', description: 'Client ID (for sessions)' }, - status: { type: 'string', description: 'Session status' }, + status: { type: 'string', description: 'Session or invitation status' }, lastActiveAt: { type: 'number', description: 'Last activity timestamp' }, + lastActiveOrganizationId: { + type: 'string', + description: 'Last active organization ID (for sessions)', + }, lastSignInAt: { type: 'number', description: 'Last sign-in timestamp' }, membersCount: { type: 'number', description: 'Number of members' }, + pendingInvitationsCount: { type: 'number', description: 'Number of pending invitations' }, maxAllowedMemberships: { type: 'number', description: 'Max allowed memberships' }, adminDeleteEnabled: { type: 'boolean', description: 'Whether admin delete is enabled' }, createdBy: { type: 'string', description: 'Creator user ID' }, publicMetadata: { type: 'json', description: 'Public metadata' }, + privateMetadata: { type: 'json', description: 'Private metadata' }, + unsafeMetadata: { type: 'json', description: 'Unsafe metadata' }, + organizationId: { + type: 'string', + description: 'Organization ID (for memberships/invitations)', + }, + role: { type: 'string', description: 'Organization membership role' }, + roleName: { type: 'string', description: 'Human-readable role name' }, + permissions: { type: 'json', description: 'Permissions granted by the role' }, + identifier: { type: 'string', description: 'Allowlist/blocklist identifier value' }, + identifierType: { type: 'string', description: 'Identifier type (email, phone, web3 wallet)' }, + invitationId: { type: 'string', description: 'Allowlist invitation ID' }, + inviterId: { type: 'string', description: 'User ID of the invitation inviter' }, + inviterEmail: { type: 'string', description: "Inviter's email address" }, + inviterFirstName: { type: 'string', description: "Inviter's first name" }, + inviterLastName: { type: 'string', description: "Inviter's last name" }, + expiresAt: { type: 'number', description: 'Expiration timestamp (invitation, actor token)' }, + expireAt: { type: 'number', description: 'Expiration timestamp (session)' }, + abandonAt: { type: 'number', description: 'Session abandon timestamp' }, + url: { type: 'string', description: 'Invitation or actor token URL' }, + token: { type: 'string', description: 'OAuth access token or actor token' }, + provider: { type: 'string', description: 'OAuth provider' }, + scopes: { type: 'json', description: 'OAuth scopes granted to the token' }, + claims: { type: 'json', description: 'JWT template claims' }, + lifetime: { type: 'number', description: 'JWT template lifetime in seconds' }, + allowedClockSkew: { type: 'number', description: 'JWT template allowed clock skew in seconds' }, + customSigningKey: { + type: 'boolean', + description: 'Whether the JWT template uses a custom signing key', + }, + signingAlgorithm: { type: 'string', description: 'JWT template signing algorithm' }, + actor: { type: 'json', description: 'Actor object identifying who is impersonating' }, // Common outputs totalCount: { type: 'number', description: 'Total count for paginated results' }, deleted: { type: 'boolean', description: 'Whether the resource was deleted' }, @@ -469,7 +932,7 @@ export const ClerkBlockMeta = { icon: ClerkIcon, title: 'Clerk org-management automator', prompt: - 'Create a workflow that on a new enterprise plan via Stripe creates a Clerk organization, invites the admin, and writes the Clerk org ID back to the Stripe customer.', + 'Create a workflow that on a new enterprise plan via Stripe creates a Clerk organization, invites the admin by email, and writes the Clerk org ID back to the Stripe customer.', modules: ['agent', 'workflows'], category: 'operations', tags: ['enterprise', 'automation'], @@ -479,7 +942,7 @@ export const ClerkBlockMeta = { icon: ClerkIcon, title: 'Clerk inactive-user cleaner', prompt: - 'Build a scheduled workflow that finds Clerk users with no sign-ins in 180 days, sends a re-engagement email, and removes accounts after a grace period.', + 'Build a scheduled workflow that finds Clerk users with no sign-ins in 180 days, sends a re-engagement email, and bans accounts that stay inactive after a grace period.', modules: ['scheduled', 'agent', 'workflows'], category: 'operations', tags: ['automation', 'enterprise'], @@ -489,7 +952,7 @@ export const ClerkBlockMeta = { icon: ClerkIcon, title: 'Clerk access-review automator', prompt: - 'Create a scheduled quarterly workflow that lists Clerk organizations and their users, requires owner re-attestation, and writes the review trail to a compliance table.', + 'Create a scheduled quarterly workflow that lists Clerk organizations and their memberships, requires owner re-attestation, and writes the review trail to a compliance table.', modules: ['scheduled', 'tables', 'agent', 'workflows'], category: 'operations', tags: ['legal', 'enterprise'], @@ -511,7 +974,7 @@ export const ClerkBlockMeta = { description: 'Look up a Clerk user by email, username, or name and return their profile. Use to resolve a user before acting on their account or syncing them elsewhere.', content: - '# Find User\n\nLocate a Clerk user account.\n\n## Steps\n1. Use List Users with a Search Query (matches email, phone, username, or name), or the email/username filters for an exact match.\n2. If you already have the Clerk user id (user_...), use Get User instead for the full record.\n3. Review the returned profile: id, primary email, name, externalId, and flags like banned, locked, and twoFactorEnabled.\n\n## Output\nReturn the matched user id, primary email, name, and key status flags. If multiple users match, list the candidates with their emails so the right one can be confirmed; if none match, say so.', + '# Find User\n\nLocate a Clerk user account.\n\n## Steps\n1. Use List Users with a Search Query (matches email, phone, username, or name), or the email/username/phone/external ID filters for an exact match.\n2. If you already have the Clerk user id (user_...), use Get User instead for the full record.\n3. Review the returned profile: id, primary email, name, externalId, and flags like banned, locked, and twoFactorEnabled.\n\n## Output\nReturn the matched user id, primary email, name, and key status flags. If multiple users match, list the candidates with their emails so the right one can be confirmed; if none match, say so.', }, { name: 'provision-user', @@ -520,6 +983,13 @@ export const ClerkBlockMeta = { content: '# Provision User\n\nCreate or update a Clerk user.\n\n## Steps\n1. To create, use Create User with at least an email address (and optionally phone, username, password, first/last name).\n2. To set application roles or app data, pass Public Metadata (visible to the frontend) and Private Metadata (server-only) as JSON.\n3. Set External ID to link the Clerk user to your own system id.\n4. To modify an existing user, use Update User with the user id and only the fields that change.\n\n## Output\nReturn the user id, primary email, and the metadata that was set. Confirm whether the user was created or updated. If a required field is missing or the email already exists, report it clearly.', }, + { + name: 'moderate-user-access', + description: + 'Ban, unban, lock, or unlock a Clerk user to control their ability to sign in. Use for abuse response, suspicious-activity containment, or manual account recovery.', + content: + '# Moderate User Access\n\nControl whether a Clerk user can sign in.\n\n## Steps\n1. Resolve the target user id first (see find-user) if you only have an email or name.\n2. Use Ban User to immediately block all sign-in attempts (e.g. for confirmed abuse); use Unban User to lift it once resolved.\n3. Use Lock User for a temporary, reversible hold (e.g. suspicious login pattern under review); use Unlock User to restore access.\n4. For a full audit trail, use Audit User Sessions afterward to revoke any sessions that should not continue.\n\n## Output\nReturn the user id and the resulting banned/locked flags after the action. State clearly which control was applied and why, so the moderation trail is auditable.', + }, { name: 'audit-user-sessions', description: @@ -530,9 +1000,9 @@ export const ClerkBlockMeta = { { name: 'manage-organization', description: - 'Create a Clerk organization or look up its details and membership. Use when provisioning a new team or tenant in a multi-tenant app.', + 'Create or update a Clerk organization, manage its memberships, and invite new members. Use when provisioning a new team or tenant in a multi-tenant app, or when onboarding/offboarding members.', content: - '# Manage Organization\n\nCreate or inspect a Clerk organization.\n\n## Steps\n1. To create, use Create Organization with the organization name and the Creator User ID (that user becomes the admin); optionally set a slug and max members.\n2. To inspect, use Get Organization by org id or slug, or List Organizations with a search query and include members count.\n3. Read back the org id, slug, members count, and limits.\n\n## Output\nReturn the organization id, name, slug, and member count. When creating, confirm the admin user and echo the org id so it can be linked back to your billing or CRM record.', + "# Manage Organization\n\nCreate, inspect, and staff a Clerk organization.\n\n## Steps\n1. To create, use Create Organization with the organization name and the Creator User ID (that user becomes the admin); optionally set a slug and max members. Use Update Organization to rename, re-slug, or change membership limits later.\n2. To inspect, use Get Organization by org id or slug, or List Organizations with a search query and include members count.\n3. To staff the org, use Add Organization Member with an existing user id and role, or Create Organization Invitation to invite someone by email who does not have an account yet.\n4. Use List Organization Memberships to see current members and their roles, Update Organization Membership to change a member's role, and Remove Organization Member to offboard someone.\n5. Use List Organization Invitations to check on pending invites.\n\n## Output\nReturn the organization id, name, slug, and member count. When adding or inviting a member, confirm the user id or email and the assigned role. When creating, confirm the admin user and echo the org id so it can be linked back to your billing or CRM record.", }, ], } as const satisfies BlockMeta diff --git a/apps/sim/tools/clerk/add_organization_member.ts b/apps/sim/tools/clerk/add_organization_member.ts new file mode 100644 index 00000000000..fdf55734496 --- /dev/null +++ b/apps/sim/tools/clerk/add_organization_member.ts @@ -0,0 +1,123 @@ +import { createLogger } from '@sim/logger' +import type { + ClerkAddOrganizationMemberParams, + ClerkAddOrganizationMemberResponse, + ClerkApiError, + ClerkOrganizationMembership, +} from '@/tools/clerk/types' +import type { ToolConfig } from '@/tools/types' + +const logger = createLogger('ClerkAddOrganizationMember') + +export const clerkAddOrganizationMemberTool: ToolConfig< + ClerkAddOrganizationMemberParams, + ClerkAddOrganizationMemberResponse +> = { + id: 'clerk_add_organization_member', + name: 'Add Organization Member in Clerk', + description: 'Add a user as a member of a Clerk organization with a given role', + version: '1.0.0', + + params: { + secretKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'The Clerk Secret Key for API authentication', + }, + organizationId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The ID of the organization (e.g., org_2NNEqL2nrIRdJ194ndJqAHwEfxC)', + }, + userId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the user to add as a member', + }, + role: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Role to assign, e.g. org:admin or org:member', + }, + }, + + request: { + url: (params) => + `https://api.clerk.com/v1/organizations/${params.organizationId?.trim()}/memberships`, + method: 'POST', + headers: (params) => { + if (!params.secretKey) { + throw new Error('Clerk Secret Key is required') + } + return { + Authorization: `Bearer ${params.secretKey}`, + 'Content-Type': 'application/json', + } + }, + body: (params) => ({ + user_id: params.userId?.trim(), + role: params.role, + }), + }, + + transformResponse: async (response: Response) => { + const data: ClerkOrganizationMembership | ClerkApiError = await response.json() + + if (!response.ok) { + logger.error('Clerk API request failed', { data, status: response.status }) + throw new Error( + (data as ClerkApiError).errors?.[0]?.message || 'Failed to add organization member in Clerk' + ) + } + + const membership = data as ClerkOrganizationMembership + return { + success: true, + output: { + id: membership.id, + role: membership.role, + roleName: membership.role_name ?? null, + permissions: membership.permissions ?? [], + organizationId: membership.organization.id, + userId: membership.public_user_data.user_id, + firstName: membership.public_user_data.first_name ?? null, + lastName: membership.public_user_data.last_name ?? null, + imageUrl: membership.public_user_data.image_url ?? null, + identifier: membership.public_user_data.identifier ?? null, + username: membership.public_user_data.username ?? null, + banned: membership.public_user_data.banned ?? false, + publicMetadata: membership.public_metadata ?? {}, + createdAt: membership.created_at, + updatedAt: membership.updated_at, + success: true, + }, + } + }, + + outputs: { + id: { type: 'string', description: 'Membership ID' }, + role: { type: 'string', description: 'Member role' }, + roleName: { type: 'string', description: 'Human-readable role name', optional: true }, + permissions: { + type: 'array', + description: 'Permissions granted by the role', + items: { type: 'string' }, + }, + organizationId: { type: 'string', description: 'Organization ID' }, + userId: { type: 'string', description: 'Member user ID' }, + firstName: { type: 'string', description: 'Member first name', optional: true }, + lastName: { type: 'string', description: 'Member last name', optional: true }, + imageUrl: { type: 'string', description: 'Member profile image URL', optional: true }, + identifier: { type: 'string', description: 'Member identifier (e.g., email)', optional: true }, + username: { type: 'string', description: 'Member username', optional: true }, + banned: { type: 'boolean', description: 'Whether the member is banned' }, + publicMetadata: { type: 'json', description: 'Public metadata' }, + createdAt: { type: 'number', description: 'Creation timestamp' }, + updatedAt: { type: 'number', description: 'Last update timestamp' }, + success: { type: 'boolean', description: 'Operation success status' }, + }, +} diff --git a/apps/sim/tools/clerk/ban_user.ts b/apps/sim/tools/clerk/ban_user.ts new file mode 100644 index 00000000000..be902a68758 --- /dev/null +++ b/apps/sim/tools/clerk/ban_user.ts @@ -0,0 +1,87 @@ +import { createLogger } from '@sim/logger' +import type { + ClerkApiError, + ClerkBanUserParams, + ClerkBanUserResponse, + ClerkUser, +} from '@/tools/clerk/types' +import type { ToolConfig } from '@/tools/types' + +const logger = createLogger('ClerkBanUser') + +export const clerkBanUserTool: ToolConfig = { + id: 'clerk_ban_user', + name: 'Ban User in Clerk', + description: 'Ban a user, preventing them from signing in', + version: '1.0.0', + + params: { + secretKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'The Clerk Secret Key for API authentication', + }, + userId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The ID of the user to ban (e.g., user_2NNEqL2nrIRdJ194ndJqAHwEfxC)', + }, + }, + + request: { + url: (params) => `https://api.clerk.com/v1/users/${params.userId?.trim()}/ban`, + method: 'POST', + headers: (params) => { + if (!params.secretKey) { + throw new Error('Clerk Secret Key is required') + } + return { + Authorization: `Bearer ${params.secretKey}`, + 'Content-Type': 'application/json', + } + }, + }, + + transformResponse: async (response: Response) => { + const data: ClerkUser | ClerkApiError = await response.json() + + if (!response.ok) { + logger.error('Clerk API request failed', { data, status: response.status }) + throw new Error((data as ClerkApiError).errors?.[0]?.message || 'Failed to ban user in Clerk') + } + + const user = data as ClerkUser + return { + success: true, + output: { + id: user.id, + username: user.username ?? null, + firstName: user.first_name ?? null, + lastName: user.last_name ?? null, + banned: user.banned ?? false, + locked: user.locked ?? false, + lockoutExpiresInSeconds: user.lockout_expires_in_seconds ?? null, + updatedAt: user.updated_at, + success: true, + }, + } + }, + + outputs: { + id: { type: 'string', description: 'User ID' }, + username: { type: 'string', description: 'Username', optional: true }, + firstName: { type: 'string', description: 'First name', optional: true }, + lastName: { type: 'string', description: 'Last name', optional: true }, + banned: { type: 'boolean', description: 'Whether the user is banned' }, + locked: { type: 'boolean', description: 'Whether the user is locked' }, + lockoutExpiresInSeconds: { + type: 'number', + description: 'Seconds until lockout expires', + optional: true, + }, + updatedAt: { type: 'number', description: 'Last update timestamp' }, + success: { type: 'boolean', description: 'Operation success status' }, + }, +} diff --git a/apps/sim/tools/clerk/create_actor_token.ts b/apps/sim/tools/clerk/create_actor_token.ts new file mode 100644 index 00000000000..02c936c8820 --- /dev/null +++ b/apps/sim/tools/clerk/create_actor_token.ts @@ -0,0 +1,121 @@ +import { createLogger } from '@sim/logger' +import type { + ClerkActorToken, + ClerkApiError, + ClerkCreateActorTokenParams, + ClerkCreateActorTokenResponse, +} from '@/tools/clerk/types' +import type { ToolConfig } from '@/tools/types' + +const logger = createLogger('ClerkCreateActorToken') + +export const clerkCreateActorTokenTool: ToolConfig< + ClerkCreateActorTokenParams, + ClerkCreateActorTokenResponse +> = { + id: 'clerk_create_actor_token', + name: 'Create Actor Token in Clerk', + description: + 'Create an actor token to impersonate a user (God Mode / act-as-user), e.g. for support tooling', + version: '1.0.0', + + params: { + secretKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'The Clerk Secret Key for API authentication', + }, + userId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the user to impersonate', + }, + actor: { + type: 'json', + required: true, + visibility: 'user-or-llm', + description: + 'Actor JSON object identifying who is impersonating, must include a "sub" field, e.g. {"sub": "user_support_agent_id"}', + }, + expiresInSeconds: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Seconds until the token expires (default 3600)', + }, + sessionMaxDurationInSeconds: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Max duration in seconds for sessions created with this token (default 1800)', + }, + }, + + request: { + url: () => 'https://api.clerk.com/v1/actor_tokens', + method: 'POST', + headers: (params) => { + if (!params.secretKey) { + throw new Error('Clerk Secret Key is required') + } + return { + Authorization: `Bearer ${params.secretKey}`, + 'Content-Type': 'application/json', + } + }, + body: (params) => { + const body: Record = { + user_id: params.userId?.trim(), + actor: params.actor, + } + + if (params.expiresInSeconds !== undefined) body.expires_in_seconds = params.expiresInSeconds + if (params.sessionMaxDurationInSeconds !== undefined) + body.session_max_duration_in_seconds = params.sessionMaxDurationInSeconds + + return body + }, + }, + + transformResponse: async (response: Response) => { + const data: ClerkActorToken | ClerkApiError = await response.json() + + if (!response.ok) { + logger.error('Clerk API request failed', { data, status: response.status }) + throw new Error( + (data as ClerkApiError).errors?.[0]?.message || 'Failed to create actor token in Clerk' + ) + } + + const actorToken = data as ClerkActorToken + + return { + success: true, + output: { + id: actorToken.id, + status: actorToken.status, + userId: actorToken.user_id, + actor: actorToken.actor ?? {}, + token: actorToken.token ?? null, + url: actorToken.url ?? null, + createdAt: actorToken.created_at, + updatedAt: actorToken.updated_at, + success: true, + }, + } + }, + + outputs: { + id: { type: 'string', description: 'Actor token ID' }, + status: { type: 'string', description: 'Actor token status' }, + userId: { type: 'string', description: 'ID of the impersonated user' }, + actor: { type: 'json', description: 'Actor object identifying who is impersonating' }, + token: { type: 'string', description: 'Signed actor token (JWT)', optional: true }, + url: { type: 'string', description: 'Sign-in URL for the actor token', optional: true }, + createdAt: { type: 'number', description: 'Creation timestamp' }, + updatedAt: { type: 'number', description: 'Last update timestamp' }, + success: { type: 'boolean', description: 'Operation success status' }, + }, +} diff --git a/apps/sim/tools/clerk/create_allowlist_identifier.ts b/apps/sim/tools/clerk/create_allowlist_identifier.ts new file mode 100644 index 00000000000..bd7d4746e68 --- /dev/null +++ b/apps/sim/tools/clerk/create_allowlist_identifier.ts @@ -0,0 +1,101 @@ +import { createLogger } from '@sim/logger' +import type { + ClerkAllowlistIdentifier, + ClerkApiError, + ClerkCreateAllowlistIdentifierParams, + ClerkCreateAllowlistIdentifierResponse, +} from '@/tools/clerk/types' +import type { ToolConfig } from '@/tools/types' + +const logger = createLogger('ClerkCreateAllowlistIdentifier') + +export const clerkCreateAllowlistIdentifierTool: ToolConfig< + ClerkCreateAllowlistIdentifierParams, + ClerkCreateAllowlistIdentifierResponse +> = { + id: 'clerk_create_allowlist_identifier', + name: 'Create Allowlist Identifier in Clerk', + description: 'Add an email, phone number, or web3 wallet to your Clerk instance allowlist', + version: '1.0.0', + + params: { + secretKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'The Clerk Secret Key for API authentication', + }, + identifier: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + 'Email address, phone number, or web3 wallet to allow (wildcards like *@example.com supported for email)', + }, + notify: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether to notify the identifier owner by email (default false)', + }, + }, + + request: { + url: () => 'https://api.clerk.com/v1/allowlist_identifiers', + method: 'POST', + headers: (params) => { + if (!params.secretKey) { + throw new Error('Clerk Secret Key is required') + } + return { + Authorization: `Bearer ${params.secretKey}`, + 'Content-Type': 'application/json', + } + }, + body: (params) => { + const body: Record = { + identifier: params.identifier?.trim(), + } + + if (params.notify !== undefined) body.notify = params.notify + + return body + }, + }, + + transformResponse: async (response: Response) => { + const data: ClerkAllowlistIdentifier | ClerkApiError = await response.json() + + if (!response.ok) { + logger.error('Clerk API request failed', { data, status: response.status }) + throw new Error( + (data as ClerkApiError).errors?.[0]?.message || + 'Failed to create allowlist identifier in Clerk' + ) + } + + const identifier = data as ClerkAllowlistIdentifier + return { + success: true, + output: { + id: identifier.id, + identifier: identifier.identifier, + identifierType: identifier.identifier_type, + invitationId: identifier.invitation_id ?? null, + createdAt: identifier.created_at, + updatedAt: identifier.updated_at, + success: true, + }, + } + }, + + outputs: { + id: { type: 'string', description: 'Allowlist identifier ID' }, + identifier: { type: 'string', description: 'Email, phone, or web3 wallet identifier' }, + identifierType: { type: 'string', description: 'Type of identifier' }, + invitationId: { type: 'string', description: 'Associated invitation ID', optional: true }, + createdAt: { type: 'number', description: 'Creation timestamp' }, + updatedAt: { type: 'number', description: 'Last update timestamp' }, + success: { type: 'boolean', description: 'Operation success status' }, + }, +} diff --git a/apps/sim/tools/clerk/create_blocklist_identifier.ts b/apps/sim/tools/clerk/create_blocklist_identifier.ts new file mode 100644 index 00000000000..ad1da860253 --- /dev/null +++ b/apps/sim/tools/clerk/create_blocklist_identifier.ts @@ -0,0 +1,87 @@ +import { createLogger } from '@sim/logger' +import type { + ClerkApiError, + ClerkBlocklistIdentifier, + ClerkCreateBlocklistIdentifierParams, + ClerkCreateBlocklistIdentifierResponse, +} from '@/tools/clerk/types' +import type { ToolConfig } from '@/tools/types' + +const logger = createLogger('ClerkCreateBlocklistIdentifier') + +export const clerkCreateBlocklistIdentifierTool: ToolConfig< + ClerkCreateBlocklistIdentifierParams, + ClerkCreateBlocklistIdentifierResponse +> = { + id: 'clerk_create_blocklist_identifier', + name: 'Create Blocklist Identifier in Clerk', + description: + 'Add an email, phone number, or web3 wallet to your Clerk instance blocklist to prevent sign-ups', + version: '1.0.0', + + params: { + secretKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'The Clerk Secret Key for API authentication', + }, + identifier: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Email address, phone number, or web3 wallet to block', + }, + }, + + request: { + url: () => 'https://api.clerk.com/v1/blocklist_identifiers', + method: 'POST', + headers: (params) => { + if (!params.secretKey) { + throw new Error('Clerk Secret Key is required') + } + return { + Authorization: `Bearer ${params.secretKey}`, + 'Content-Type': 'application/json', + } + }, + body: (params) => ({ + identifier: params.identifier?.trim(), + }), + }, + + transformResponse: async (response: Response) => { + const data: ClerkBlocklistIdentifier | ClerkApiError = await response.json() + + if (!response.ok) { + logger.error('Clerk API request failed', { data, status: response.status }) + throw new Error( + (data as ClerkApiError).errors?.[0]?.message || + 'Failed to create blocklist identifier in Clerk' + ) + } + + const identifier = data as ClerkBlocklistIdentifier + return { + success: true, + output: { + id: identifier.id, + identifier: identifier.identifier, + identifierType: identifier.identifier_type, + createdAt: identifier.created_at, + updatedAt: identifier.updated_at, + success: true, + }, + } + }, + + outputs: { + id: { type: 'string', description: 'Blocklist identifier ID' }, + identifier: { type: 'string', description: 'Email, phone, or web3 wallet identifier' }, + identifierType: { type: 'string', description: 'Type of identifier' }, + createdAt: { type: 'number', description: 'Creation timestamp' }, + updatedAt: { type: 'number', description: 'Last update timestamp' }, + success: { type: 'boolean', description: 'Operation success status' }, + }, +} diff --git a/apps/sim/tools/clerk/create_organization_invitation.ts b/apps/sim/tools/clerk/create_organization_invitation.ts new file mode 100644 index 00000000000..cc33019257c --- /dev/null +++ b/apps/sim/tools/clerk/create_organization_invitation.ts @@ -0,0 +1,167 @@ +import { createLogger } from '@sim/logger' +import type { + ClerkApiError, + ClerkCreateOrganizationInvitationParams, + ClerkCreateOrganizationInvitationResponse, + ClerkOrganizationInvitation, +} from '@/tools/clerk/types' +import type { ToolConfig } from '@/tools/types' + +const logger = createLogger('ClerkCreateOrganizationInvitation') + +export const clerkCreateOrganizationInvitationTool: ToolConfig< + ClerkCreateOrganizationInvitationParams, + ClerkCreateOrganizationInvitationResponse +> = { + id: 'clerk_create_organization_invitation', + name: 'Create Organization Invitation in Clerk', + description: 'Invite a user by email to join a Clerk organization with a given role', + version: '1.0.0', + + params: { + secretKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'The Clerk Secret Key for API authentication', + }, + organizationId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The ID of the organization (e.g., org_2NNEqL2nrIRdJ194ndJqAHwEfxC)', + }, + emailAddress: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Email address of the user to invite', + }, + role: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Role to assign on acceptance, e.g. org:admin or org:member', + }, + inviterUserId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'User ID of the inviter', + }, + redirectUrl: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'URL to redirect to after the invitation is accepted', + }, + expiresInDays: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Days until the invitation expires (1-365, default 30)', + }, + publicMetadata: { + type: 'json', + required: false, + visibility: 'user-or-llm', + description: 'Public metadata (JSON object)', + }, + privateMetadata: { + type: 'json', + required: false, + visibility: 'user-or-llm', + description: 'Private metadata (JSON object)', + }, + notify: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether Clerk sends the invitation email (default true)', + }, + }, + + request: { + url: (params) => + `https://api.clerk.com/v1/organizations/${params.organizationId?.trim()}/invitations`, + method: 'POST', + headers: (params) => { + if (!params.secretKey) { + throw new Error('Clerk Secret Key is required') + } + return { + Authorization: `Bearer ${params.secretKey}`, + 'Content-Type': 'application/json', + } + }, + body: (params) => { + const body: Record = { + email_address: params.emailAddress, + role: params.role, + } + + if (params.inviterUserId !== undefined) body.inviter_user_id = params.inviterUserId + if (params.redirectUrl !== undefined) body.redirect_url = params.redirectUrl + if (params.expiresInDays !== undefined) body.expires_in_days = params.expiresInDays + if (params.publicMetadata !== undefined) body.public_metadata = params.publicMetadata + if (params.privateMetadata !== undefined) body.private_metadata = params.privateMetadata + if (params.notify !== undefined) body.notify = params.notify + + return body + }, + }, + + transformResponse: async (response: Response) => { + const data: ClerkOrganizationInvitation | ClerkApiError = await response.json() + + if (!response.ok) { + logger.error('Clerk API request failed', { data, status: response.status }) + throw new Error( + (data as ClerkApiError).errors?.[0]?.message || + 'Failed to create organization invitation in Clerk' + ) + } + + const invitation = data as ClerkOrganizationInvitation + return { + success: true, + output: { + id: invitation.id, + emailAddress: invitation.email_address, + role: invitation.role, + roleName: invitation.role_name ?? null, + organizationId: invitation.organization_id, + inviterId: invitation.inviter_id ?? null, + inviterEmail: invitation.public_inviter_data?.identifier ?? null, + inviterFirstName: invitation.public_inviter_data?.first_name ?? null, + inviterLastName: invitation.public_inviter_data?.last_name ?? null, + status: invitation.status, + url: invitation.url ?? null, + expiresAt: invitation.expires_at ?? null, + publicMetadata: invitation.public_metadata ?? {}, + createdAt: invitation.created_at, + updatedAt: invitation.updated_at, + success: true, + }, + } + }, + + outputs: { + id: { type: 'string', description: 'Invitation ID' }, + emailAddress: { type: 'string', description: 'Invited email address' }, + role: { type: 'string', description: 'Role to assign on acceptance' }, + roleName: { type: 'string', description: 'Human-readable role name', optional: true }, + organizationId: { type: 'string', description: 'Organization ID' }, + inviterId: { type: 'string', description: 'User ID of the inviter', optional: true }, + inviterEmail: { type: 'string', description: "Inviter's email address", optional: true }, + inviterFirstName: { type: 'string', description: "Inviter's first name", optional: true }, + inviterLastName: { type: 'string', description: "Inviter's last name", optional: true }, + status: { type: 'string', description: 'Invitation status' }, + url: { type: 'string', description: 'Invitation URL', optional: true }, + expiresAt: { type: 'number', description: 'Expiration timestamp', optional: true }, + publicMetadata: { type: 'json', description: 'Public metadata' }, + createdAt: { type: 'number', description: 'Creation timestamp' }, + updatedAt: { type: 'number', description: 'Last update timestamp' }, + success: { type: 'boolean', description: 'Operation success status' }, + }, +} diff --git a/apps/sim/tools/clerk/delete_allowlist_identifier.ts b/apps/sim/tools/clerk/delete_allowlist_identifier.ts new file mode 100644 index 00000000000..a23606557b5 --- /dev/null +++ b/apps/sim/tools/clerk/delete_allowlist_identifier.ts @@ -0,0 +1,80 @@ +import { createLogger } from '@sim/logger' +import type { + ClerkApiError, + ClerkDeleteAllowlistIdentifierParams, + ClerkDeleteAllowlistIdentifierResponse, + ClerkDeleteResponse, +} from '@/tools/clerk/types' +import type { ToolConfig } from '@/tools/types' + +const logger = createLogger('ClerkDeleteAllowlistIdentifier') + +export const clerkDeleteAllowlistIdentifierTool: ToolConfig< + ClerkDeleteAllowlistIdentifierParams, + ClerkDeleteAllowlistIdentifierResponse +> = { + id: 'clerk_delete_allowlist_identifier', + name: 'Delete Allowlist Identifier from Clerk', + description: 'Remove an identifier from your Clerk instance allowlist', + version: '1.0.0', + + params: { + secretKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'The Clerk Secret Key for API authentication', + }, + identifierId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the allowlist identifier to delete', + }, + }, + + request: { + url: (params) => + `https://api.clerk.com/v1/allowlist_identifiers/${params.identifierId?.trim()}`, + method: 'DELETE', + headers: (params) => { + if (!params.secretKey) { + throw new Error('Clerk Secret Key is required') + } + return { + Authorization: `Bearer ${params.secretKey}`, + 'Content-Type': 'application/json', + } + }, + }, + + transformResponse: async (response: Response) => { + const data: ClerkDeleteResponse | ClerkApiError = await response.json() + + if (!response.ok) { + logger.error('Clerk API request failed', { data, status: response.status }) + throw new Error( + (data as ClerkApiError).errors?.[0]?.message || + 'Failed to delete allowlist identifier from Clerk' + ) + } + + const deleteData = data as ClerkDeleteResponse + return { + success: true, + output: { + id: deleteData.id, + object: deleteData.object ?? 'allowlist_identifier', + deleted: deleteData.deleted ?? true, + success: true, + }, + } + }, + + outputs: { + id: { type: 'string', description: 'Deleted allowlist identifier ID' }, + object: { type: 'string', description: 'Object type (allowlist_identifier)' }, + deleted: { type: 'boolean', description: 'Whether the identifier was deleted' }, + success: { type: 'boolean', description: 'Operation success status' }, + }, +} diff --git a/apps/sim/tools/clerk/delete_blocklist_identifier.ts b/apps/sim/tools/clerk/delete_blocklist_identifier.ts new file mode 100644 index 00000000000..a915de0e6d1 --- /dev/null +++ b/apps/sim/tools/clerk/delete_blocklist_identifier.ts @@ -0,0 +1,80 @@ +import { createLogger } from '@sim/logger' +import type { + ClerkApiError, + ClerkDeleteBlocklistIdentifierParams, + ClerkDeleteBlocklistIdentifierResponse, + ClerkDeleteResponse, +} from '@/tools/clerk/types' +import type { ToolConfig } from '@/tools/types' + +const logger = createLogger('ClerkDeleteBlocklistIdentifier') + +export const clerkDeleteBlocklistIdentifierTool: ToolConfig< + ClerkDeleteBlocklistIdentifierParams, + ClerkDeleteBlocklistIdentifierResponse +> = { + id: 'clerk_delete_blocklist_identifier', + name: 'Delete Blocklist Identifier from Clerk', + description: 'Remove an identifier from your Clerk instance blocklist', + version: '1.0.0', + + params: { + secretKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'The Clerk Secret Key for API authentication', + }, + identifierId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the blocklist identifier to delete', + }, + }, + + request: { + url: (params) => + `https://api.clerk.com/v1/blocklist_identifiers/${params.identifierId?.trim()}`, + method: 'DELETE', + headers: (params) => { + if (!params.secretKey) { + throw new Error('Clerk Secret Key is required') + } + return { + Authorization: `Bearer ${params.secretKey}`, + 'Content-Type': 'application/json', + } + }, + }, + + transformResponse: async (response: Response) => { + const data: ClerkDeleteResponse | ClerkApiError = await response.json() + + if (!response.ok) { + logger.error('Clerk API request failed', { data, status: response.status }) + throw new Error( + (data as ClerkApiError).errors?.[0]?.message || + 'Failed to delete blocklist identifier from Clerk' + ) + } + + const deleteData = data as ClerkDeleteResponse + return { + success: true, + output: { + id: deleteData.id, + object: deleteData.object ?? 'blocklist_identifier', + deleted: deleteData.deleted ?? true, + success: true, + }, + } + }, + + outputs: { + id: { type: 'string', description: 'Deleted blocklist identifier ID' }, + object: { type: 'string', description: 'Object type (blocklist_identifier)' }, + deleted: { type: 'boolean', description: 'Whether the identifier was deleted' }, + success: { type: 'boolean', description: 'Operation success status' }, + }, +} diff --git a/apps/sim/tools/clerk/delete_organization.ts b/apps/sim/tools/clerk/delete_organization.ts new file mode 100644 index 00000000000..e14aaca1b40 --- /dev/null +++ b/apps/sim/tools/clerk/delete_organization.ts @@ -0,0 +1,78 @@ +import { createLogger } from '@sim/logger' +import type { + ClerkApiError, + ClerkDeleteOrganizationParams, + ClerkDeleteOrganizationResponse, + ClerkDeleteResponse, +} from '@/tools/clerk/types' +import type { ToolConfig } from '@/tools/types' + +const logger = createLogger('ClerkDeleteOrganization') + +export const clerkDeleteOrganizationTool: ToolConfig< + ClerkDeleteOrganizationParams, + ClerkDeleteOrganizationResponse +> = { + id: 'clerk_delete_organization', + name: 'Delete Organization from Clerk', + description: 'Delete an organization from your Clerk application', + version: '1.0.0', + + params: { + secretKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'The Clerk Secret Key for API authentication', + }, + organizationId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The ID of the organization to delete (e.g., org_2NNEqL2nrIRdJ194ndJqAHwEfxC)', + }, + }, + + request: { + url: (params) => `https://api.clerk.com/v1/organizations/${params.organizationId?.trim()}`, + method: 'DELETE', + headers: (params) => { + if (!params.secretKey) { + throw new Error('Clerk Secret Key is required') + } + return { + Authorization: `Bearer ${params.secretKey}`, + 'Content-Type': 'application/json', + } + }, + }, + + transformResponse: async (response: Response) => { + const data: ClerkDeleteResponse | ClerkApiError = await response.json() + + if (!response.ok) { + logger.error('Clerk API request failed', { data, status: response.status }) + throw new Error( + (data as ClerkApiError).errors?.[0]?.message || 'Failed to delete organization from Clerk' + ) + } + + const deleteData = data as ClerkDeleteResponse + return { + success: true, + output: { + id: deleteData.id, + object: deleteData.object ?? 'organization', + deleted: deleteData.deleted ?? true, + success: true, + }, + } + }, + + outputs: { + id: { type: 'string', description: 'Deleted organization ID' }, + object: { type: 'string', description: 'Object type (organization)' }, + deleted: { type: 'boolean', description: 'Whether the organization was deleted' }, + success: { type: 'boolean', description: 'Operation success status' }, + }, +} diff --git a/apps/sim/tools/clerk/get_jwt_template.ts b/apps/sim/tools/clerk/get_jwt_template.ts new file mode 100644 index 00000000000..311e21aa61e --- /dev/null +++ b/apps/sim/tools/clerk/get_jwt_template.ts @@ -0,0 +1,94 @@ +import { createLogger } from '@sim/logger' +import type { + ClerkApiError, + ClerkGetJwtTemplateParams, + ClerkGetJwtTemplateResponse, + ClerkJwtTemplate, +} from '@/tools/clerk/types' +import type { ToolConfig } from '@/tools/types' + +const logger = createLogger('ClerkGetJwtTemplate') + +export const clerkGetJwtTemplateTool: ToolConfig< + ClerkGetJwtTemplateParams, + ClerkGetJwtTemplateResponse +> = { + id: 'clerk_get_jwt_template', + name: 'Get JWT Template from Clerk', + description: 'Retrieve a single custom JWT template by ID from Clerk', + version: '1.0.0', + + params: { + secretKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'The Clerk Secret Key for API authentication', + }, + templateId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the JWT template to retrieve', + }, + }, + + request: { + url: (params) => `https://api.clerk.com/v1/jwt_templates/${params.templateId?.trim()}`, + method: 'GET', + headers: (params) => { + if (!params.secretKey) { + throw new Error('Clerk Secret Key is required') + } + return { + Authorization: `Bearer ${params.secretKey}`, + 'Content-Type': 'application/json', + } + }, + }, + + transformResponse: async (response: Response) => { + const data: ClerkJwtTemplate | ClerkApiError = await response.json() + + if (!response.ok) { + logger.error('Clerk API request failed', { data, status: response.status }) + throw new Error( + (data as ClerkApiError).errors?.[0]?.message || 'Failed to get JWT template from Clerk' + ) + } + + const template = data as ClerkJwtTemplate + + return { + success: true, + output: { + id: template.id, + name: template.name, + claims: template.claims ?? {}, + lifetime: template.lifetime, + allowedClockSkew: template.allowed_clock_skew, + customSigningKey: template.custom_signing_key ?? false, + signingAlgorithm: template.signing_algorithm, + createdAt: template.created_at, + updatedAt: template.updated_at, + success: true, + }, + } + }, + + outputs: { + id: { type: 'string', description: 'JWT template ID' }, + name: { type: 'string', description: 'JWT template name' }, + claims: { type: 'json', description: 'Custom claims defined on the template' }, + lifetime: { type: 'number', description: 'Token lifetime in seconds' }, + allowedClockSkew: { type: 'number', description: 'Allowed clock skew in seconds' }, + customSigningKey: { + type: 'boolean', + description: 'Whether a custom signing key is configured', + }, + signingAlgorithm: { type: 'string', description: 'Signing algorithm used' }, + createdAt: { type: 'number', description: 'Creation timestamp' }, + updatedAt: { type: 'number', description: 'Last update timestamp' }, + success: { type: 'boolean', description: 'Operation success status' }, + }, +} diff --git a/apps/sim/tools/clerk/get_organization.ts b/apps/sim/tools/clerk/get_organization.ts index 501f221eade..14b3f1a6ffb 100644 --- a/apps/sim/tools/clerk/get_organization.ts +++ b/apps/sim/tools/clerk/get_organization.ts @@ -35,7 +35,7 @@ export const clerkGetOrganizationTool: ToolConfig< }, request: { - url: (params) => `https://api.clerk.com/v1/organizations/${params.organizationId}`, + url: (params) => `https://api.clerk.com/v1/organizations/${params.organizationId?.trim()}`, method: 'GET', headers: (params) => { if (!params.secretKey) { diff --git a/apps/sim/tools/clerk/get_session.ts b/apps/sim/tools/clerk/get_session.ts index 7d3b58499da..ed271e09d16 100644 --- a/apps/sim/tools/clerk/get_session.ts +++ b/apps/sim/tools/clerk/get_session.ts @@ -31,7 +31,7 @@ export const clerkGetSessionTool: ToolConfig `https://api.clerk.com/v1/sessions/${params.sessionId}`, + url: (params) => `https://api.clerk.com/v1/sessions/${params.sessionId?.trim()}`, method: 'GET', headers: (params) => { if (!params.secretKey) { diff --git a/apps/sim/tools/clerk/get_user_oauth_token.ts b/apps/sim/tools/clerk/get_user_oauth_token.ts new file mode 100644 index 00000000000..2c6e8602f8f --- /dev/null +++ b/apps/sim/tools/clerk/get_user_oauth_token.ts @@ -0,0 +1,116 @@ +import { createLogger } from '@sim/logger' +import type { + ClerkApiError, + ClerkGetUserOauthTokenParams, + ClerkGetUserOauthTokenResponse, + ClerkOAuthAccessToken, +} from '@/tools/clerk/types' +import type { ToolConfig } from '@/tools/types' + +const logger = createLogger('ClerkGetUserOauthToken') + +export const clerkGetUserOauthTokenTool: ToolConfig< + ClerkGetUserOauthTokenParams, + ClerkGetUserOauthTokenResponse +> = { + id: 'clerk_get_user_oauth_token', + name: 'Get User OAuth Access Token from Clerk', + description: + "Retrieve a user's OAuth access token for a connected external provider (e.g. Google, GitHub, Microsoft) obtained via Clerk SSO", + version: '1.0.0', + + params: { + secretKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'The Clerk Secret Key for API authentication', + }, + userId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The ID of the user (e.g., user_2NNEqL2nrIRdJ194ndJqAHwEfxC)', + }, + provider: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + 'OAuth provider slug, e.g. google, github, microsoft, discord (without the oauth_ prefix)', + }, + }, + + request: { + url: (params) => { + const providerSlug = params.provider?.trim().replace(/^oauth_/, '') + return `https://api.clerk.com/v1/users/${params.userId?.trim()}/oauth_access_tokens/oauth_${providerSlug}` + }, + method: 'GET', + headers: (params) => { + if (!params.secretKey) { + throw new Error('Clerk Secret Key is required') + } + return { + Authorization: `Bearer ${params.secretKey}`, + 'Content-Type': 'application/json', + } + }, + }, + + transformResponse: async (response: Response) => { + const data: ClerkOAuthAccessToken[] | ClerkApiError = await response.json() + + if (!response.ok) { + logger.error('Clerk API request failed', { data, status: response.status }) + throw new Error( + (data as ClerkApiError).errors?.[0]?.message || + 'Failed to get user OAuth access token from Clerk' + ) + } + + const tokens = data as ClerkOAuthAccessToken[] + return { + success: true, + output: { + accessTokens: tokens.map((token) => ({ + externalAccountId: token.external_account_id, + token: token.token, + expiresAt: token.expires_at ?? null, + provider: token.provider, + label: token.label ?? null, + scopes: token.scopes ?? [], + publicMetadata: token.public_metadata ?? {}, + })), + success: true, + }, + } + }, + + outputs: { + accessTokens: { + type: 'array', + description: 'OAuth access tokens for the connected provider', + items: { + type: 'object', + properties: { + externalAccountId: { type: 'string', description: 'External account ID' }, + token: { type: 'string', description: 'OAuth access token' }, + expiresAt: { type: 'number', description: 'Expiration timestamp', optional: true }, + provider: { type: 'string', description: 'OAuth provider slug' }, + label: { type: 'string', description: 'Token label', optional: true }, + scopes: { + type: 'array', + description: 'OAuth scopes granted to the token', + items: { type: 'string' }, + }, + publicMetadata: { + type: 'json', + description: 'Public metadata associated with the token', + }, + }, + }, + }, + success: { type: 'boolean', description: 'Operation success status' }, + }, +} diff --git a/apps/sim/tools/clerk/index.ts b/apps/sim/tools/clerk/index.ts index eda1978ee43..7c81a840012 100644 --- a/apps/sim/tools/clerk/index.ts +++ b/apps/sim/tools/clerk/index.ts @@ -1,11 +1,34 @@ +export { clerkAddOrganizationMemberTool } from './add_organization_member' +export { clerkBanUserTool } from './ban_user' +export { clerkCreateActorTokenTool } from './create_actor_token' +export { clerkCreateAllowlistIdentifierTool } from './create_allowlist_identifier' +export { clerkCreateBlocklistIdentifierTool } from './create_blocklist_identifier' export { clerkCreateOrganizationTool } from './create_organization' +export { clerkCreateOrganizationInvitationTool } from './create_organization_invitation' export { clerkCreateUserTool } from './create_user' +export { clerkDeleteAllowlistIdentifierTool } from './delete_allowlist_identifier' +export { clerkDeleteBlocklistIdentifierTool } from './delete_blocklist_identifier' +export { clerkDeleteOrganizationTool } from './delete_organization' export { clerkDeleteUserTool } from './delete_user' +export { clerkGetJwtTemplateTool } from './get_jwt_template' export { clerkGetOrganizationTool } from './get_organization' export { clerkGetSessionTool } from './get_session' export { clerkGetUserTool } from './get_user' +export { clerkGetUserOauthTokenTool } from './get_user_oauth_token' +export { clerkListAllowlistIdentifiersTool } from './list_allowlist_identifiers' +export { clerkListBlocklistIdentifiersTool } from './list_blocklist_identifiers' +export { clerkListJwtTemplatesTool } from './list_jwt_templates' +export { clerkListOrganizationInvitationsTool } from './list_organization_invitations' +export { clerkListOrganizationMembershipsTool } from './list_organization_memberships' export { clerkListOrganizationsTool } from './list_organizations' export { clerkListSessionsTool } from './list_sessions' export { clerkListUsersTool } from './list_users' +export { clerkLockUserTool } from './lock_user' +export { clerkRemoveOrganizationMemberTool } from './remove_organization_member' +export { clerkRevokeActorTokenTool } from './revoke_actor_token' export { clerkRevokeSessionTool } from './revoke_session' +export { clerkUnbanUserTool } from './unban_user' +export { clerkUnlockUserTool } from './unlock_user' +export { clerkUpdateOrganizationTool } from './update_organization' +export { clerkUpdateOrganizationMembershipTool } from './update_organization_membership' export { clerkUpdateUserTool } from './update_user' diff --git a/apps/sim/tools/clerk/list_allowlist_identifiers.ts b/apps/sim/tools/clerk/list_allowlist_identifiers.ts new file mode 100644 index 00000000000..0f5f1ce0986 --- /dev/null +++ b/apps/sim/tools/clerk/list_allowlist_identifiers.ts @@ -0,0 +1,119 @@ +import { createLogger } from '@sim/logger' +import type { + ClerkAllowlistIdentifier, + ClerkApiError, + ClerkListAllowlistIdentifiersParams, + ClerkListAllowlistIdentifiersResponse, +} from '@/tools/clerk/types' +import type { ToolConfig } from '@/tools/types' + +const logger = createLogger('ClerkListAllowlistIdentifiers') + +export const clerkListAllowlistIdentifiersTool: ToolConfig< + ClerkListAllowlistIdentifiersParams, + ClerkListAllowlistIdentifiersResponse +> = { + id: 'clerk_list_allowlist_identifiers', + name: 'List Allowlist Identifiers from Clerk', + description: 'List email/phone/web3-wallet identifiers on your Clerk instance allowlist', + version: '1.0.0', + + params: { + secretKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'The Clerk Secret Key for API authentication', + }, + limit: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Number of results per page (e.g., 10, 50, 100; range: 1-500, default: 10)', + }, + offset: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Number of results to skip for pagination (e.g., 0, 10, 20)', + }, + }, + + request: { + url: (params) => { + const queryParams = new URLSearchParams() + + if (params.limit) queryParams.append('limit', params.limit.toString()) + if (params.offset) queryParams.append('offset', params.offset.toString()) + + const queryString = queryParams.toString() + return queryString + ? `https://api.clerk.com/v1/allowlist_identifiers?${queryString}` + : 'https://api.clerk.com/v1/allowlist_identifiers' + }, + method: 'GET', + headers: (params) => { + if (!params.secretKey) { + throw new Error('Clerk Secret Key is required') + } + return { + Authorization: `Bearer ${params.secretKey}`, + 'Content-Type': 'application/json', + } + }, + }, + + transformResponse: async (response: Response) => { + const data: ClerkAllowlistIdentifier[] | ClerkApiError = await response.json() + + if (!response.ok) { + logger.error('Clerk API request failed', { data, status: response.status }) + throw new Error( + (data as ClerkApiError).errors?.[0]?.message || + 'Failed to list allowlist identifiers from Clerk' + ) + } + + const identifiers = (data as ClerkAllowlistIdentifier[]).map((identifier) => ({ + id: identifier.id, + identifier: identifier.identifier, + identifierType: identifier.identifier_type, + invitationId: identifier.invitation_id ?? null, + createdAt: identifier.created_at, + updatedAt: identifier.updated_at, + })) + + return { + success: true, + output: { + identifiers, + totalCount: identifiers.length, + success: true, + }, + } + }, + + outputs: { + identifiers: { + type: 'array', + description: 'Array of Clerk allowlist identifier objects', + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'Allowlist identifier ID' }, + identifier: { type: 'string', description: 'Email, phone, or web3 wallet identifier' }, + identifierType: { type: 'string', description: 'Type of identifier' }, + invitationId: { + type: 'string', + description: 'Associated invitation ID', + optional: true, + }, + createdAt: { type: 'number', description: 'Creation timestamp' }, + updatedAt: { type: 'number', description: 'Last update timestamp' }, + }, + }, + }, + totalCount: { type: 'number', description: 'Total number of allowlist identifiers' }, + success: { type: 'boolean', description: 'Operation success status' }, + }, +} diff --git a/apps/sim/tools/clerk/list_blocklist_identifiers.ts b/apps/sim/tools/clerk/list_blocklist_identifiers.ts new file mode 100644 index 00000000000..ddc06ab6df6 --- /dev/null +++ b/apps/sim/tools/clerk/list_blocklist_identifiers.ts @@ -0,0 +1,94 @@ +import { createLogger } from '@sim/logger' +import type { + ClerkApiError, + ClerkBlocklistIdentifier, + ClerkListBlocklistIdentifiersParams, + ClerkListBlocklistIdentifiersResponse, +} from '@/tools/clerk/types' +import type { ToolConfig } from '@/tools/types' + +const logger = createLogger('ClerkListBlocklistIdentifiers') + +export const clerkListBlocklistIdentifiersTool: ToolConfig< + ClerkListBlocklistIdentifiersParams, + ClerkListBlocklistIdentifiersResponse +> = { + id: 'clerk_list_blocklist_identifiers', + name: 'List Blocklist Identifiers from Clerk', + description: 'List email/phone/web3-wallet identifiers on your Clerk instance blocklist', + version: '1.0.0', + + params: { + secretKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'The Clerk Secret Key for API authentication', + }, + }, + + request: { + url: () => 'https://api.clerk.com/v1/blocklist_identifiers', + method: 'GET', + headers: (params) => { + if (!params.secretKey) { + throw new Error('Clerk Secret Key is required') + } + return { + Authorization: `Bearer ${params.secretKey}`, + 'Content-Type': 'application/json', + } + }, + }, + + transformResponse: async (response: Response) => { + const json: { data: ClerkBlocklistIdentifier[]; total_count: number } | ClerkApiError = + await response.json() + + if (!response.ok) { + logger.error('Clerk API request failed', { data: json, status: response.status }) + throw new Error( + (json as ClerkApiError).errors?.[0]?.message || + 'Failed to list blocklist identifiers from Clerk' + ) + } + + const responseData = json as { data: ClerkBlocklistIdentifier[]; total_count: number } + + const identifiers = responseData.data.map((identifier) => ({ + id: identifier.id, + identifier: identifier.identifier, + identifierType: identifier.identifier_type, + createdAt: identifier.created_at, + updatedAt: identifier.updated_at, + })) + + return { + success: true, + output: { + identifiers, + totalCount: responseData.total_count ?? identifiers.length, + success: true, + }, + } + }, + + outputs: { + identifiers: { + type: 'array', + description: 'Array of Clerk blocklist identifier objects', + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'Blocklist identifier ID' }, + identifier: { type: 'string', description: 'Email, phone, or web3 wallet identifier' }, + identifierType: { type: 'string', description: 'Type of identifier' }, + createdAt: { type: 'number', description: 'Creation timestamp' }, + updatedAt: { type: 'number', description: 'Last update timestamp' }, + }, + }, + }, + totalCount: { type: 'number', description: 'Total number of blocklist identifiers' }, + success: { type: 'boolean', description: 'Operation success status' }, + }, +} diff --git a/apps/sim/tools/clerk/list_jwt_templates.ts b/apps/sim/tools/clerk/list_jwt_templates.ts new file mode 100644 index 00000000000..7be7a58c5da --- /dev/null +++ b/apps/sim/tools/clerk/list_jwt_templates.ts @@ -0,0 +1,101 @@ +import { createLogger } from '@sim/logger' +import type { + ClerkApiError, + ClerkJwtTemplate, + ClerkListJwtTemplatesParams, + ClerkListJwtTemplatesResponse, +} from '@/tools/clerk/types' +import type { ToolConfig } from '@/tools/types' + +const logger = createLogger('ClerkListJwtTemplates') + +export const clerkListJwtTemplatesTool: ToolConfig< + ClerkListJwtTemplatesParams, + ClerkListJwtTemplatesResponse +> = { + id: 'clerk_list_jwt_templates', + name: 'List JWT Templates from Clerk', + description: 'List custom JWT templates configured on your Clerk instance', + version: '1.0.0', + + params: { + secretKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'The Clerk Secret Key for API authentication', + }, + }, + + request: { + url: () => 'https://api.clerk.com/v1/jwt_templates', + method: 'GET', + headers: (params) => { + if (!params.secretKey) { + throw new Error('Clerk Secret Key is required') + } + return { + Authorization: `Bearer ${params.secretKey}`, + 'Content-Type': 'application/json', + } + }, + }, + + transformResponse: async (response: Response) => { + const data: ClerkJwtTemplate[] | ClerkApiError = await response.json() + + if (!response.ok) { + logger.error('Clerk API request failed', { data, status: response.status }) + throw new Error( + (data as ClerkApiError).errors?.[0]?.message || 'Failed to list JWT templates from Clerk' + ) + } + + const templates = (data as ClerkJwtTemplate[]).map((template) => ({ + id: template.id, + name: template.name, + claims: template.claims ?? {}, + lifetime: template.lifetime, + allowedClockSkew: template.allowed_clock_skew, + customSigningKey: template.custom_signing_key ?? false, + signingAlgorithm: template.signing_algorithm, + createdAt: template.created_at, + updatedAt: template.updated_at, + })) + + return { + success: true, + output: { + templates, + totalCount: templates.length, + success: true, + }, + } + }, + + outputs: { + templates: { + type: 'array', + description: 'Array of Clerk JWT template objects', + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'JWT template ID' }, + name: { type: 'string', description: 'JWT template name' }, + claims: { type: 'json', description: 'Custom claims defined on the template' }, + lifetime: { type: 'number', description: 'Token lifetime in seconds' }, + allowedClockSkew: { type: 'number', description: 'Allowed clock skew in seconds' }, + customSigningKey: { + type: 'boolean', + description: 'Whether a custom signing key is configured', + }, + signingAlgorithm: { type: 'string', description: 'Signing algorithm used' }, + createdAt: { type: 'number', description: 'Creation timestamp' }, + updatedAt: { type: 'number', description: 'Last update timestamp' }, + }, + }, + }, + totalCount: { type: 'number', description: 'Total number of JWT templates' }, + success: { type: 'boolean', description: 'Operation success status' }, + }, +} diff --git a/apps/sim/tools/clerk/list_organization_invitations.ts b/apps/sim/tools/clerk/list_organization_invitations.ts new file mode 100644 index 00000000000..9aa02429a45 --- /dev/null +++ b/apps/sim/tools/clerk/list_organization_invitations.ts @@ -0,0 +1,162 @@ +import { createLogger } from '@sim/logger' +import type { + ClerkApiError, + ClerkListOrganizationInvitationsParams, + ClerkListOrganizationInvitationsResponse, + ClerkOrganizationInvitation, +} from '@/tools/clerk/types' +import type { ToolConfig } from '@/tools/types' + +const logger = createLogger('ClerkListOrganizationInvitations') + +export const clerkListOrganizationInvitationsTool: ToolConfig< + ClerkListOrganizationInvitationsParams, + ClerkListOrganizationInvitationsResponse +> = { + id: 'clerk_list_organization_invitations', + name: 'List Organization Invitations from Clerk', + description: 'List pending and past invitations for a Clerk organization', + version: '1.0.0', + + params: { + secretKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'The Clerk Secret Key for API authentication', + }, + organizationId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The ID of the organization (e.g., org_2NNEqL2nrIRdJ194ndJqAHwEfxC)', + }, + status: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Filter by status: pending, accepted, revoked, or expired', + }, + emailAddress: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Filter by invited email address', + }, + orderBy: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Sort field (created_at, email_address) with +/- prefix (default: -created_at)', + }, + limit: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Number of results per page (e.g., 10, 50, 100; range: 1-500, default: 10)', + }, + offset: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Number of results to skip for pagination (e.g., 0, 10, 20)', + }, + }, + + request: { + url: (params) => { + const queryParams = new URLSearchParams() + + if (params.status) queryParams.append('status', params.status) + if (params.emailAddress) queryParams.append('email_address', params.emailAddress) + if (params.orderBy) queryParams.append('order_by', params.orderBy) + if (params.limit) queryParams.append('limit', params.limit.toString()) + if (params.offset) queryParams.append('offset', params.offset.toString()) + + const queryString = queryParams.toString() + const base = `https://api.clerk.com/v1/organizations/${params.organizationId?.trim()}/invitations` + return queryString ? `${base}?${queryString}` : base + }, + method: 'GET', + headers: (params) => { + if (!params.secretKey) { + throw new Error('Clerk Secret Key is required') + } + return { + Authorization: `Bearer ${params.secretKey}`, + 'Content-Type': 'application/json', + } + }, + }, + + transformResponse: async (response: Response) => { + const json: { data: ClerkOrganizationInvitation[]; total_count: number } | ClerkApiError = + await response.json() + + if (!response.ok) { + logger.error('Clerk API request failed', { data: json, status: response.status }) + throw new Error( + (json as ClerkApiError).errors?.[0]?.message || + 'Failed to list organization invitations from Clerk' + ) + } + + const responseData = json as { data: ClerkOrganizationInvitation[]; total_count: number } + + const invitations = responseData.data.map((invitation) => ({ + id: invitation.id, + emailAddress: invitation.email_address, + role: invitation.role, + roleName: invitation.role_name ?? null, + organizationId: invitation.organization_id, + inviterId: invitation.inviter_id ?? null, + inviterEmail: invitation.public_inviter_data?.identifier ?? null, + inviterFirstName: invitation.public_inviter_data?.first_name ?? null, + inviterLastName: invitation.public_inviter_data?.last_name ?? null, + status: invitation.status, + url: invitation.url ?? null, + expiresAt: invitation.expires_at ?? null, + publicMetadata: invitation.public_metadata ?? {}, + createdAt: invitation.created_at, + updatedAt: invitation.updated_at, + })) + + return { + success: true, + output: { + invitations, + totalCount: responseData.total_count ?? invitations.length, + success: true, + }, + } + }, + + outputs: { + invitations: { + type: 'array', + description: 'Array of Clerk organization invitation objects', + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'Invitation ID' }, + emailAddress: { type: 'string', description: 'Invited email address' }, + role: { type: 'string', description: 'Role to assign on acceptance' }, + roleName: { type: 'string', description: 'Human-readable role name', optional: true }, + organizationId: { type: 'string', description: 'Organization ID' }, + inviterId: { type: 'string', description: 'User ID of the inviter', optional: true }, + inviterEmail: { type: 'string', description: "Inviter's email address", optional: true }, + inviterFirstName: { type: 'string', description: "Inviter's first name", optional: true }, + inviterLastName: { type: 'string', description: "Inviter's last name", optional: true }, + status: { type: 'string', description: 'Invitation status' }, + url: { type: 'string', description: 'Invitation URL', optional: true }, + expiresAt: { type: 'number', description: 'Expiration timestamp', optional: true }, + publicMetadata: { type: 'json', description: 'Public metadata' }, + createdAt: { type: 'number', description: 'Creation timestamp' }, + updatedAt: { type: 'number', description: 'Last update timestamp' }, + }, + }, + }, + totalCount: { type: 'number', description: 'Total number of invitations' }, + success: { type: 'boolean', description: 'Operation success status' }, + }, +} diff --git a/apps/sim/tools/clerk/list_organization_memberships.ts b/apps/sim/tools/clerk/list_organization_memberships.ts new file mode 100644 index 00000000000..5c1bac5f3b9 --- /dev/null +++ b/apps/sim/tools/clerk/list_organization_memberships.ts @@ -0,0 +1,167 @@ +import { createLogger } from '@sim/logger' +import type { + ClerkApiError, + ClerkListOrganizationMembershipsParams, + ClerkListOrganizationMembershipsResponse, + ClerkOrganizationMembership, +} from '@/tools/clerk/types' +import type { ToolConfig } from '@/tools/types' + +const logger = createLogger('ClerkListOrganizationMemberships') + +export const clerkListOrganizationMembershipsTool: ToolConfig< + ClerkListOrganizationMembershipsParams, + ClerkListOrganizationMembershipsResponse +> = { + id: 'clerk_list_organization_memberships', + name: 'List Organization Memberships from Clerk', + description: 'List members of a Clerk organization with optional filtering and pagination', + version: '1.0.0', + + params: { + secretKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'The Clerk Secret Key for API authentication', + }, + organizationId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The ID of the organization (e.g., org_2NNEqL2nrIRdJ194ndJqAHwEfxC)', + }, + limit: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Number of results per page (e.g., 10, 50, 100; range: 1-500, default: 10)', + }, + offset: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Number of results to skip for pagination (e.g., 0, 10, 20)', + }, + orderBy: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Sort field (e.g., created_at) with +/- prefix for direction', + }, + role: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Filter by role, comma-separated for multiple (e.g., org:admin,org:member)', + }, + }, + + request: { + url: (params) => { + const queryParams = new URLSearchParams() + + if (params.limit) queryParams.append('limit', params.limit.toString()) + if (params.offset) queryParams.append('offset', params.offset.toString()) + if (params.orderBy) queryParams.append('order_by', params.orderBy) + if (params.role) { + params.role.split(',').forEach((role) => { + queryParams.append('role', role.trim()) + }) + } + + const queryString = queryParams.toString() + const base = `https://api.clerk.com/v1/organizations/${params.organizationId?.trim()}/memberships` + return queryString ? `${base}?${queryString}` : base + }, + method: 'GET', + headers: (params) => { + if (!params.secretKey) { + throw new Error('Clerk Secret Key is required') + } + return { + Authorization: `Bearer ${params.secretKey}`, + 'Content-Type': 'application/json', + } + }, + }, + + transformResponse: async (response: Response) => { + const json: { data: ClerkOrganizationMembership[]; total_count: number } | ClerkApiError = + await response.json() + + if (!response.ok) { + logger.error('Clerk API request failed', { data: json, status: response.status }) + throw new Error( + (json as ClerkApiError).errors?.[0]?.message || + 'Failed to list organization memberships from Clerk' + ) + } + + const responseData = json as { data: ClerkOrganizationMembership[]; total_count: number } + + const memberships = responseData.data.map((membership) => ({ + id: membership.id, + role: membership.role, + roleName: membership.role_name ?? null, + permissions: membership.permissions ?? [], + organizationId: membership.organization.id, + userId: membership.public_user_data.user_id, + firstName: membership.public_user_data.first_name ?? null, + lastName: membership.public_user_data.last_name ?? null, + imageUrl: membership.public_user_data.image_url ?? null, + identifier: membership.public_user_data.identifier ?? null, + username: membership.public_user_data.username ?? null, + banned: membership.public_user_data.banned ?? false, + publicMetadata: membership.public_metadata ?? {}, + createdAt: membership.created_at, + updatedAt: membership.updated_at, + })) + + return { + success: true, + output: { + memberships, + totalCount: responseData.total_count ?? memberships.length, + success: true, + }, + } + }, + + outputs: { + memberships: { + type: 'array', + description: 'Array of Clerk organization membership objects', + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'Membership ID' }, + role: { type: 'string', description: 'Member role' }, + roleName: { type: 'string', description: 'Human-readable role name', optional: true }, + permissions: { + type: 'array', + description: 'Permissions granted by the role', + items: { type: 'string' }, + }, + organizationId: { type: 'string', description: 'Organization ID' }, + userId: { type: 'string', description: 'Member user ID' }, + firstName: { type: 'string', description: 'Member first name', optional: true }, + lastName: { type: 'string', description: 'Member last name', optional: true }, + imageUrl: { type: 'string', description: 'Member profile image URL', optional: true }, + identifier: { + type: 'string', + description: 'Member identifier (e.g., email)', + optional: true, + }, + username: { type: 'string', description: 'Member username', optional: true }, + banned: { type: 'boolean', description: 'Whether the member is banned' }, + publicMetadata: { type: 'json', description: 'Public metadata' }, + createdAt: { type: 'number', description: 'Creation timestamp' }, + updatedAt: { type: 'number', description: 'Last update timestamp' }, + }, + }, + }, + totalCount: { type: 'number', description: 'Total number of memberships' }, + success: { type: 'boolean', description: 'Operation success status' }, + }, +} diff --git a/apps/sim/tools/clerk/lock_user.ts b/apps/sim/tools/clerk/lock_user.ts new file mode 100644 index 00000000000..290cbc496e4 --- /dev/null +++ b/apps/sim/tools/clerk/lock_user.ts @@ -0,0 +1,89 @@ +import { createLogger } from '@sim/logger' +import type { + ClerkApiError, + ClerkLockUserParams, + ClerkLockUserResponse, + ClerkUser, +} from '@/tools/clerk/types' +import type { ToolConfig } from '@/tools/types' + +const logger = createLogger('ClerkLockUser') + +export const clerkLockUserTool: ToolConfig = { + id: 'clerk_lock_user', + name: 'Lock User in Clerk', + description: 'Lock a user account, blocking sign-in attempts', + version: '1.0.0', + + params: { + secretKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'The Clerk Secret Key for API authentication', + }, + userId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The ID of the user to lock (e.g., user_2NNEqL2nrIRdJ194ndJqAHwEfxC)', + }, + }, + + request: { + url: (params) => `https://api.clerk.com/v1/users/${params.userId?.trim()}/lock`, + method: 'POST', + headers: (params) => { + if (!params.secretKey) { + throw new Error('Clerk Secret Key is required') + } + return { + Authorization: `Bearer ${params.secretKey}`, + 'Content-Type': 'application/json', + } + }, + }, + + transformResponse: async (response: Response) => { + const data: ClerkUser | ClerkApiError = await response.json() + + if (!response.ok) { + logger.error('Clerk API request failed', { data, status: response.status }) + throw new Error( + (data as ClerkApiError).errors?.[0]?.message || 'Failed to lock user in Clerk' + ) + } + + const user = data as ClerkUser + return { + success: true, + output: { + id: user.id, + username: user.username ?? null, + firstName: user.first_name ?? null, + lastName: user.last_name ?? null, + banned: user.banned ?? false, + locked: user.locked ?? false, + lockoutExpiresInSeconds: user.lockout_expires_in_seconds ?? null, + updatedAt: user.updated_at, + success: true, + }, + } + }, + + outputs: { + id: { type: 'string', description: 'User ID' }, + username: { type: 'string', description: 'Username', optional: true }, + firstName: { type: 'string', description: 'First name', optional: true }, + lastName: { type: 'string', description: 'Last name', optional: true }, + banned: { type: 'boolean', description: 'Whether the user is banned' }, + locked: { type: 'boolean', description: 'Whether the user is locked' }, + lockoutExpiresInSeconds: { + type: 'number', + description: 'Seconds until lockout expires', + optional: true, + }, + updatedAt: { type: 'number', description: 'Last update timestamp' }, + success: { type: 'boolean', description: 'Operation success status' }, + }, +} diff --git a/apps/sim/tools/clerk/remove_organization_member.ts b/apps/sim/tools/clerk/remove_organization_member.ts new file mode 100644 index 00000000000..9b35396661d --- /dev/null +++ b/apps/sim/tools/clerk/remove_organization_member.ts @@ -0,0 +1,114 @@ +import { createLogger } from '@sim/logger' +import type { + ClerkApiError, + ClerkOrganizationMembership, + ClerkRemoveOrganizationMemberParams, + ClerkRemoveOrganizationMemberResponse, +} from '@/tools/clerk/types' +import type { ToolConfig } from '@/tools/types' + +const logger = createLogger('ClerkRemoveOrganizationMember') + +export const clerkRemoveOrganizationMemberTool: ToolConfig< + ClerkRemoveOrganizationMemberParams, + ClerkRemoveOrganizationMemberResponse +> = { + id: 'clerk_remove_organization_member', + name: 'Remove Organization Member from Clerk', + description: 'Remove a member from a Clerk organization', + version: '1.0.0', + + params: { + secretKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'The Clerk Secret Key for API authentication', + }, + organizationId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The ID of the organization (e.g., org_2NNEqL2nrIRdJ194ndJqAHwEfxC)', + }, + userId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the member to remove', + }, + }, + + request: { + url: (params) => + `https://api.clerk.com/v1/organizations/${params.organizationId?.trim()}/memberships/${params.userId?.trim()}`, + method: 'DELETE', + headers: (params) => { + if (!params.secretKey) { + throw new Error('Clerk Secret Key is required') + } + return { + Authorization: `Bearer ${params.secretKey}`, + 'Content-Type': 'application/json', + } + }, + }, + + transformResponse: async (response: Response) => { + const data: ClerkOrganizationMembership | ClerkApiError = await response.json() + + if (!response.ok) { + logger.error('Clerk API request failed', { data, status: response.status }) + throw new Error( + (data as ClerkApiError).errors?.[0]?.message || + 'Failed to remove organization member from Clerk' + ) + } + + const membership = data as ClerkOrganizationMembership + return { + success: true, + output: { + id: membership.id, + role: membership.role, + roleName: membership.role_name ?? null, + permissions: membership.permissions ?? [], + organizationId: membership.organization.id, + userId: membership.public_user_data.user_id, + firstName: membership.public_user_data.first_name ?? null, + lastName: membership.public_user_data.last_name ?? null, + imageUrl: membership.public_user_data.image_url ?? null, + identifier: membership.public_user_data.identifier ?? null, + username: membership.public_user_data.username ?? null, + banned: membership.public_user_data.banned ?? false, + publicMetadata: membership.public_metadata ?? {}, + createdAt: membership.created_at, + updatedAt: membership.updated_at, + success: true, + }, + } + }, + + outputs: { + id: { type: 'string', description: 'Membership ID' }, + role: { type: 'string', description: 'Member role' }, + roleName: { type: 'string', description: 'Human-readable role name', optional: true }, + permissions: { + type: 'array', + description: 'Permissions granted by the role', + items: { type: 'string' }, + }, + organizationId: { type: 'string', description: 'Organization ID' }, + userId: { type: 'string', description: 'Member user ID' }, + firstName: { type: 'string', description: 'Member first name', optional: true }, + lastName: { type: 'string', description: 'Member last name', optional: true }, + imageUrl: { type: 'string', description: 'Member profile image URL', optional: true }, + identifier: { type: 'string', description: 'Member identifier (e.g., email)', optional: true }, + username: { type: 'string', description: 'Member username', optional: true }, + banned: { type: 'boolean', description: 'Whether the member is banned' }, + publicMetadata: { type: 'json', description: 'Public metadata' }, + createdAt: { type: 'number', description: 'Creation timestamp' }, + updatedAt: { type: 'number', description: 'Last update timestamp' }, + success: { type: 'boolean', description: 'Operation success status' }, + }, +} diff --git a/apps/sim/tools/clerk/revoke_actor_token.ts b/apps/sim/tools/clerk/revoke_actor_token.ts new file mode 100644 index 00000000000..2457dbcb330 --- /dev/null +++ b/apps/sim/tools/clerk/revoke_actor_token.ts @@ -0,0 +1,89 @@ +import { createLogger } from '@sim/logger' +import type { + ClerkActorToken, + ClerkApiError, + ClerkRevokeActorTokenParams, + ClerkRevokeActorTokenResponse, +} from '@/tools/clerk/types' +import type { ToolConfig } from '@/tools/types' + +const logger = createLogger('ClerkRevokeActorToken') + +export const clerkRevokeActorTokenTool: ToolConfig< + ClerkRevokeActorTokenParams, + ClerkRevokeActorTokenResponse +> = { + id: 'clerk_revoke_actor_token', + name: 'Revoke Actor Token in Clerk', + description: 'Revoke an actor token before it is used or expires', + version: '1.0.0', + + params: { + secretKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'The Clerk Secret Key for API authentication', + }, + actorTokenId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the actor token to revoke', + }, + }, + + request: { + url: (params) => `https://api.clerk.com/v1/actor_tokens/${params.actorTokenId?.trim()}/revoke`, + method: 'POST', + headers: (params) => { + if (!params.secretKey) { + throw new Error('Clerk Secret Key is required') + } + return { + Authorization: `Bearer ${params.secretKey}`, + 'Content-Type': 'application/json', + } + }, + }, + + transformResponse: async (response: Response) => { + const data: ClerkActorToken | ClerkApiError = await response.json() + + if (!response.ok) { + logger.error('Clerk API request failed', { data, status: response.status }) + throw new Error( + (data as ClerkApiError).errors?.[0]?.message || 'Failed to revoke actor token in Clerk' + ) + } + + const actorToken = data as ClerkActorToken + + return { + success: true, + output: { + id: actorToken.id, + status: actorToken.status, + userId: actorToken.user_id, + actor: actorToken.actor ?? {}, + token: actorToken.token ?? null, + url: actorToken.url ?? null, + createdAt: actorToken.created_at, + updatedAt: actorToken.updated_at, + success: true, + }, + } + }, + + outputs: { + id: { type: 'string', description: 'Actor token ID' }, + status: { type: 'string', description: 'Actor token status (should be revoked)' }, + userId: { type: 'string', description: 'ID of the impersonated user' }, + actor: { type: 'json', description: 'Actor object identifying who is impersonating' }, + token: { type: 'string', description: 'Signed actor token (JWT)', optional: true }, + url: { type: 'string', description: 'Sign-in URL for the actor token', optional: true }, + createdAt: { type: 'number', description: 'Creation timestamp' }, + updatedAt: { type: 'number', description: 'Last update timestamp' }, + success: { type: 'boolean', description: 'Operation success status' }, + }, +} diff --git a/apps/sim/tools/clerk/revoke_session.ts b/apps/sim/tools/clerk/revoke_session.ts index 3a1e9a00c5d..311ead427e8 100644 --- a/apps/sim/tools/clerk/revoke_session.ts +++ b/apps/sim/tools/clerk/revoke_session.ts @@ -34,7 +34,7 @@ export const clerkRevokeSessionTool: ToolConfig< }, request: { - url: (params) => `https://api.clerk.com/v1/sessions/${params.sessionId}/revoke`, + url: (params) => `https://api.clerk.com/v1/sessions/${params.sessionId?.trim()}/revoke`, method: 'POST', headers: (params) => { if (!params.secretKey) { diff --git a/apps/sim/tools/clerk/types.ts b/apps/sim/tools/clerk/types.ts index 7d09f59a8a9..72ea1e8a23d 100644 --- a/apps/sim/tools/clerk/types.ts +++ b/apps/sim/tools/clerk/types.ts @@ -537,6 +537,544 @@ export interface ClerkRevokeSessionResponse extends ToolResponse { } } +// Update Organization +export interface ClerkUpdateOrganizationParams { + secretKey: string + organizationId: string + name?: string + slug?: string + maxAllowedMemberships?: number + adminDeleteEnabled?: boolean +} + +export interface ClerkUpdateOrganizationResponse extends ToolResponse { + output: { + id: string + name: string + slug: string | null + imageUrl: string | null + hasImage: boolean + membersCount: number | null + pendingInvitationsCount: number | null + maxAllowedMemberships: number + adminDeleteEnabled: boolean + createdBy: string | null + createdAt: number + updatedAt: number + publicMetadata: Record + success: boolean + } +} + +// Delete Organization +export interface ClerkDeleteOrganizationParams { + secretKey: string + organizationId: string +} + +export interface ClerkDeleteOrganizationResponse extends ToolResponse { + output: { + id: string + object: string + deleted: boolean + success: boolean + } +} + +/** + * Clerk Organization Membership object. + * `public_user_data` mirrors the OpenAPI spec (richer than the @clerk/backend SDK's + * resource class, which omits `username`/`banned`/the deprecated `profile_image_url`). + */ +export interface ClerkOrganizationMembershipPublicUserData { + user_id: string + first_name: string | null + last_name: string | null + image_url: string + has_image: boolean + identifier: string | null + username?: string | null + banned?: boolean +} + +export interface ClerkOrganizationMembership { + id: string + object: 'organization_membership' + role: string + role_name?: string + permissions: string[] + public_metadata: Record + private_metadata?: Record + organization: ClerkOrganization + public_user_data: ClerkOrganizationMembershipPublicUserData + created_at: number + updated_at: number +} + +interface ClerkOrganizationMembershipOutput { + id: string + role: string + roleName: string | null + permissions: string[] + organizationId: string + userId: string + firstName: string | null + lastName: string | null + imageUrl: string | null + identifier: string | null + username: string | null + banned: boolean + publicMetadata: Record + createdAt: number + updatedAt: number +} + +// List Organization Memberships +export interface ClerkListOrganizationMembershipsParams { + secretKey: string + organizationId: string + limit?: number + offset?: number + orderBy?: string + role?: string +} + +export interface ClerkListOrganizationMembershipsResponse extends ToolResponse { + output: { + memberships: ClerkOrganizationMembershipOutput[] + totalCount: number + success: boolean + } +} + +// Add Organization Member (create membership) +export interface ClerkAddOrganizationMemberParams { + secretKey: string + organizationId: string + userId: string + role: string +} + +export interface ClerkAddOrganizationMemberResponse extends ToolResponse { + output: ClerkOrganizationMembershipOutput & { success: boolean } +} + +// Update Organization Membership (change role) +export interface ClerkUpdateOrganizationMembershipParams { + secretKey: string + organizationId: string + userId: string + role: string +} + +export interface ClerkUpdateOrganizationMembershipResponse extends ToolResponse { + output: ClerkOrganizationMembershipOutput & { success: boolean } +} + +// Remove Organization Member (delete membership) +export interface ClerkRemoveOrganizationMemberParams { + secretKey: string + organizationId: string + userId: string +} + +export interface ClerkRemoveOrganizationMemberResponse extends ToolResponse { + output: ClerkOrganizationMembershipOutput & { success: boolean } +} + +/** + * Clerk Organization Invitation object. + */ +export interface ClerkOrganizationInvitationPublicUserData { + user_id: string + first_name: string | null + last_name: string | null + image_url: string + has_image: boolean + identifier: string +} + +export interface ClerkOrganizationInvitation { + id: string + object: 'organization_invitation' + email_address: string + role: string + role_name?: string + organization_id: string + inviter_id: string | null + public_inviter_data: ClerkOrganizationInvitationPublicUserData | null + status: 'pending' | 'accepted' | 'revoked' | 'expired' + public_metadata: Record + private_metadata?: Record + url: string | null + expires_at: number | null + created_at: number + updated_at: number +} + +interface ClerkOrganizationInvitationOutput { + id: string + emailAddress: string + role: string + roleName: string | null + organizationId: string + inviterId: string | null + inviterEmail: string | null + inviterFirstName: string | null + inviterLastName: string | null + status: string + url: string | null + expiresAt: number | null + publicMetadata: Record + createdAt: number + updatedAt: number +} + +// Create Organization Invitation +export interface ClerkCreateOrganizationInvitationParams { + secretKey: string + organizationId: string + emailAddress: string + role: string + inviterUserId?: string + redirectUrl?: string + expiresInDays?: number + publicMetadata?: Record + privateMetadata?: Record + notify?: boolean +} + +export interface ClerkCreateOrganizationInvitationResponse extends ToolResponse { + output: ClerkOrganizationInvitationOutput & { success: boolean } +} + +// List Organization Invitations +export interface ClerkListOrganizationInvitationsParams { + secretKey: string + organizationId: string + status?: 'pending' | 'accepted' | 'revoked' | 'expired' + emailAddress?: string + orderBy?: string + limit?: number + offset?: number +} + +export interface ClerkListOrganizationInvitationsResponse extends ToolResponse { + output: { + invitations: ClerkOrganizationInvitationOutput[] + totalCount: number + success: boolean + } +} + +interface ClerkUserModerationOutput { + id: string + username: string | null + firstName: string | null + lastName: string | null + banned: boolean + locked: boolean + lockoutExpiresInSeconds: number | null + updatedAt: number +} + +// Ban User +export interface ClerkBanUserParams { + secretKey: string + userId: string +} + +export interface ClerkBanUserResponse extends ToolResponse { + output: ClerkUserModerationOutput & { success: boolean } +} + +// Unban User +export interface ClerkUnbanUserParams { + secretKey: string + userId: string +} + +export interface ClerkUnbanUserResponse extends ToolResponse { + output: ClerkUserModerationOutput & { success: boolean } +} + +// Lock User +export interface ClerkLockUserParams { + secretKey: string + userId: string +} + +export interface ClerkLockUserResponse extends ToolResponse { + output: ClerkUserModerationOutput & { success: boolean } +} + +// Unlock User +export interface ClerkUnlockUserParams { + secretKey: string + userId: string +} + +export interface ClerkUnlockUserResponse extends ToolResponse { + output: ClerkUserModerationOutput & { success: boolean } +} + +/** + * Clerk OAuth Access Token object. + */ +export interface ClerkOAuthAccessToken { + object: 'oauth_access_token' + external_account_id: string + token: string + expires_at: number | null + provider: string + public_metadata: Record + label: string | null + scopes?: string[] +} + +// Get User OAuth Access Token +export interface ClerkGetUserOauthTokenParams { + secretKey: string + userId: string + provider: string +} + +export interface ClerkGetUserOauthTokenResponse extends ToolResponse { + output: { + accessTokens: { + externalAccountId: string + token: string + expiresAt: number | null + provider: string + label: string | null + scopes: string[] + publicMetadata: Record + }[] + success: boolean + } +} + +/** + * Clerk Allowlist/Blocklist Identifier objects. + */ +export interface ClerkAllowlistIdentifier { + id: string + object: 'allowlist_identifier' + identifier: string + identifier_type: string + invitation_id?: string | null + instance_id?: string + created_at: number + updated_at: number +} + +export interface ClerkBlocklistIdentifier { + id: string + object: 'blocklist_identifier' + identifier: string + identifier_type: string + instance_id?: string + created_at: number + updated_at: number +} + +interface ClerkAllowlistIdentifierOutput { + id: string + identifier: string + identifierType: string + invitationId: string | null + createdAt: number + updatedAt: number +} + +interface ClerkBlocklistIdentifierOutput { + id: string + identifier: string + identifierType: string + createdAt: number + updatedAt: number +} + +// List Allowlist Identifiers +export interface ClerkListAllowlistIdentifiersParams { + secretKey: string + limit?: number + offset?: number +} + +export interface ClerkListAllowlistIdentifiersResponse extends ToolResponse { + output: { + identifiers: ClerkAllowlistIdentifierOutput[] + totalCount: number + success: boolean + } +} + +// Create Allowlist Identifier +export interface ClerkCreateAllowlistIdentifierParams { + secretKey: string + identifier: string + notify?: boolean +} + +export interface ClerkCreateAllowlistIdentifierResponse extends ToolResponse { + output: ClerkAllowlistIdentifierOutput & { success: boolean } +} + +// Delete Allowlist Identifier +export interface ClerkDeleteAllowlistIdentifierParams { + secretKey: string + identifierId: string +} + +export interface ClerkDeleteAllowlistIdentifierResponse extends ToolResponse { + output: { + id: string + object: string + deleted: boolean + success: boolean + } +} + +// List Blocklist Identifiers +export interface ClerkListBlocklistIdentifiersParams { + secretKey: string +} + +export interface ClerkListBlocklistIdentifiersResponse extends ToolResponse { + output: { + identifiers: ClerkBlocklistIdentifierOutput[] + totalCount: number + success: boolean + } +} + +// Create Blocklist Identifier +export interface ClerkCreateBlocklistIdentifierParams { + secretKey: string + identifier: string +} + +export interface ClerkCreateBlocklistIdentifierResponse extends ToolResponse { + output: ClerkBlocklistIdentifierOutput & { success: boolean } +} + +// Delete Blocklist Identifier +export interface ClerkDeleteBlocklistIdentifierParams { + secretKey: string + identifierId: string +} + +export interface ClerkDeleteBlocklistIdentifierResponse extends ToolResponse { + output: { + id: string + object: string + deleted: boolean + success: boolean + } +} + +/** + * Clerk JWT Template object. The signing key is write-only and never echoed back. + */ +export interface ClerkJwtTemplate { + id: string + object: 'jwt_template' + name: string + claims: Record + lifetime: number + allowed_clock_skew: number + custom_signing_key: boolean + signing_algorithm: string + created_at: number + updated_at: number +} + +interface ClerkJwtTemplateOutput { + id: string + name: string + claims: Record + lifetime: number + allowedClockSkew: number + customSigningKey: boolean + signingAlgorithm: string + createdAt: number + updatedAt: number +} + +// List JWT Templates +export interface ClerkListJwtTemplatesParams { + secretKey: string +} + +export interface ClerkListJwtTemplatesResponse extends ToolResponse { + output: { + templates: ClerkJwtTemplateOutput[] + totalCount: number + success: boolean + } +} + +// Get JWT Template +export interface ClerkGetJwtTemplateParams { + secretKey: string + templateId: string +} + +export interface ClerkGetJwtTemplateResponse extends ToolResponse { + output: ClerkJwtTemplateOutput & { success: boolean } +} + +/** + * Clerk Actor Token object. `token`/`url` are only present on creation, + * not once the token has been consumed or revoked. + */ +export interface ClerkActorToken { + id: string + object: 'actor_token' + status: 'pending' | 'accepted' | 'revoked' + user_id: string + actor: Record + token?: string | null + url?: string | null + created_at: number + updated_at: number +} + +interface ClerkActorTokenOutput { + id: string + status: string + userId: string + actor: Record + token: string | null + url: string | null + createdAt: number + updatedAt: number +} + +// Create Actor Token +export interface ClerkCreateActorTokenParams { + secretKey: string + userId: string + actor: Record + expiresInSeconds?: number + sessionMaxDurationInSeconds?: number +} + +export interface ClerkCreateActorTokenResponse extends ToolResponse { + output: ClerkActorTokenOutput & { success: boolean } +} + +// Revoke Actor Token +export interface ClerkRevokeActorTokenParams { + secretKey: string + actorTokenId: string +} + +export interface ClerkRevokeActorTokenResponse extends ToolResponse { + output: ClerkActorTokenOutput & { success: boolean } +} + // Generic response type for the block export type ClerkResponse = | ClerkListUsersResponse @@ -547,6 +1085,29 @@ export type ClerkResponse = | ClerkListOrganizationsResponse | ClerkGetOrganizationResponse | ClerkCreateOrganizationResponse + | ClerkUpdateOrganizationResponse + | ClerkDeleteOrganizationResponse | ClerkListSessionsResponse | ClerkGetSessionResponse | ClerkRevokeSessionResponse + | ClerkListOrganizationMembershipsResponse + | ClerkAddOrganizationMemberResponse + | ClerkUpdateOrganizationMembershipResponse + | ClerkRemoveOrganizationMemberResponse + | ClerkCreateOrganizationInvitationResponse + | ClerkListOrganizationInvitationsResponse + | ClerkBanUserResponse + | ClerkUnbanUserResponse + | ClerkLockUserResponse + | ClerkUnlockUserResponse + | ClerkGetUserOauthTokenResponse + | ClerkListAllowlistIdentifiersResponse + | ClerkCreateAllowlistIdentifierResponse + | ClerkDeleteAllowlistIdentifierResponse + | ClerkListBlocklistIdentifiersResponse + | ClerkCreateBlocklistIdentifierResponse + | ClerkDeleteBlocklistIdentifierResponse + | ClerkListJwtTemplatesResponse + | ClerkGetJwtTemplateResponse + | ClerkCreateActorTokenResponse + | ClerkRevokeActorTokenResponse diff --git a/apps/sim/tools/clerk/unban_user.ts b/apps/sim/tools/clerk/unban_user.ts new file mode 100644 index 00000000000..f025775e1c6 --- /dev/null +++ b/apps/sim/tools/clerk/unban_user.ts @@ -0,0 +1,89 @@ +import { createLogger } from '@sim/logger' +import type { + ClerkApiError, + ClerkUnbanUserParams, + ClerkUnbanUserResponse, + ClerkUser, +} from '@/tools/clerk/types' +import type { ToolConfig } from '@/tools/types' + +const logger = createLogger('ClerkUnbanUser') + +export const clerkUnbanUserTool: ToolConfig = { + id: 'clerk_unban_user', + name: 'Unban User in Clerk', + description: 'Remove a ban from a user, allowing them to sign in again', + version: '1.0.0', + + params: { + secretKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'The Clerk Secret Key for API authentication', + }, + userId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The ID of the user to unban (e.g., user_2NNEqL2nrIRdJ194ndJqAHwEfxC)', + }, + }, + + request: { + url: (params) => `https://api.clerk.com/v1/users/${params.userId?.trim()}/unban`, + method: 'POST', + headers: (params) => { + if (!params.secretKey) { + throw new Error('Clerk Secret Key is required') + } + return { + Authorization: `Bearer ${params.secretKey}`, + 'Content-Type': 'application/json', + } + }, + }, + + transformResponse: async (response: Response) => { + const data: ClerkUser | ClerkApiError = await response.json() + + if (!response.ok) { + logger.error('Clerk API request failed', { data, status: response.status }) + throw new Error( + (data as ClerkApiError).errors?.[0]?.message || 'Failed to unban user in Clerk' + ) + } + + const user = data as ClerkUser + return { + success: true, + output: { + id: user.id, + username: user.username ?? null, + firstName: user.first_name ?? null, + lastName: user.last_name ?? null, + banned: user.banned ?? false, + locked: user.locked ?? false, + lockoutExpiresInSeconds: user.lockout_expires_in_seconds ?? null, + updatedAt: user.updated_at, + success: true, + }, + } + }, + + outputs: { + id: { type: 'string', description: 'User ID' }, + username: { type: 'string', description: 'Username', optional: true }, + firstName: { type: 'string', description: 'First name', optional: true }, + lastName: { type: 'string', description: 'Last name', optional: true }, + banned: { type: 'boolean', description: 'Whether the user is banned' }, + locked: { type: 'boolean', description: 'Whether the user is locked' }, + lockoutExpiresInSeconds: { + type: 'number', + description: 'Seconds until lockout expires', + optional: true, + }, + updatedAt: { type: 'number', description: 'Last update timestamp' }, + success: { type: 'boolean', description: 'Operation success status' }, + }, +} diff --git a/apps/sim/tools/clerk/unlock_user.ts b/apps/sim/tools/clerk/unlock_user.ts new file mode 100644 index 00000000000..9c0ef1b8018 --- /dev/null +++ b/apps/sim/tools/clerk/unlock_user.ts @@ -0,0 +1,89 @@ +import { createLogger } from '@sim/logger' +import type { + ClerkApiError, + ClerkUnlockUserParams, + ClerkUnlockUserResponse, + ClerkUser, +} from '@/tools/clerk/types' +import type { ToolConfig } from '@/tools/types' + +const logger = createLogger('ClerkUnlockUser') + +export const clerkUnlockUserTool: ToolConfig = { + id: 'clerk_unlock_user', + name: 'Unlock User in Clerk', + description: 'Unlock a previously locked user account', + version: '1.0.0', + + params: { + secretKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'The Clerk Secret Key for API authentication', + }, + userId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The ID of the user to unlock (e.g., user_2NNEqL2nrIRdJ194ndJqAHwEfxC)', + }, + }, + + request: { + url: (params) => `https://api.clerk.com/v1/users/${params.userId?.trim()}/unlock`, + method: 'POST', + headers: (params) => { + if (!params.secretKey) { + throw new Error('Clerk Secret Key is required') + } + return { + Authorization: `Bearer ${params.secretKey}`, + 'Content-Type': 'application/json', + } + }, + }, + + transformResponse: async (response: Response) => { + const data: ClerkUser | ClerkApiError = await response.json() + + if (!response.ok) { + logger.error('Clerk API request failed', { data, status: response.status }) + throw new Error( + (data as ClerkApiError).errors?.[0]?.message || 'Failed to unlock user in Clerk' + ) + } + + const user = data as ClerkUser + return { + success: true, + output: { + id: user.id, + username: user.username ?? null, + firstName: user.first_name ?? null, + lastName: user.last_name ?? null, + banned: user.banned ?? false, + locked: user.locked ?? false, + lockoutExpiresInSeconds: user.lockout_expires_in_seconds ?? null, + updatedAt: user.updated_at, + success: true, + }, + } + }, + + outputs: { + id: { type: 'string', description: 'User ID' }, + username: { type: 'string', description: 'Username', optional: true }, + firstName: { type: 'string', description: 'First name', optional: true }, + lastName: { type: 'string', description: 'Last name', optional: true }, + banned: { type: 'boolean', description: 'Whether the user is banned' }, + locked: { type: 'boolean', description: 'Whether the user is locked' }, + lockoutExpiresInSeconds: { + type: 'number', + description: 'Seconds until lockout expires', + optional: true, + }, + updatedAt: { type: 'number', description: 'Last update timestamp' }, + success: { type: 'boolean', description: 'Operation success status' }, + }, +} diff --git a/apps/sim/tools/clerk/update_organization.ts b/apps/sim/tools/clerk/update_organization.ts new file mode 100644 index 00000000000..201fc650b22 --- /dev/null +++ b/apps/sim/tools/clerk/update_organization.ts @@ -0,0 +1,138 @@ +import { createLogger } from '@sim/logger' +import type { + ClerkApiError, + ClerkOrganization, + ClerkUpdateOrganizationParams, + ClerkUpdateOrganizationResponse, +} from '@/tools/clerk/types' +import type { ToolConfig } from '@/tools/types' + +const logger = createLogger('ClerkUpdateOrganization') + +export const clerkUpdateOrganizationTool: ToolConfig< + ClerkUpdateOrganizationParams, + ClerkUpdateOrganizationResponse +> = { + id: 'clerk_update_organization', + name: 'Update Organization in Clerk', + description: 'Update an existing organization in your Clerk application', + version: '1.0.0', + + params: { + secretKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'The Clerk Secret Key for API authentication', + }, + organizationId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The ID of the organization to update (e.g., org_2NNEqL2nrIRdJ194ndJqAHwEfxC)', + }, + name: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Name of the organization', + }, + slug: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Slug identifier for the organization', + }, + maxAllowedMemberships: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Maximum member capacity (0 for unlimited)', + }, + adminDeleteEnabled: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether admins can delete the organization', + }, + }, + + request: { + url: (params) => `https://api.clerk.com/v1/organizations/${params.organizationId?.trim()}`, + method: 'PATCH', + headers: (params) => { + if (!params.secretKey) { + throw new Error('Clerk Secret Key is required') + } + return { + Authorization: `Bearer ${params.secretKey}`, + 'Content-Type': 'application/json', + } + }, + body: (params) => { + const body: Record = {} + + if (params.name !== undefined) body.name = params.name + if (params.slug !== undefined) body.slug = params.slug + if (params.maxAllowedMemberships !== undefined) + body.max_allowed_memberships = params.maxAllowedMemberships + if (params.adminDeleteEnabled !== undefined) + body.admin_delete_enabled = params.adminDeleteEnabled + + return body + }, + }, + + transformResponse: async (response: Response) => { + const data: ClerkOrganization | ClerkApiError = await response.json() + + if (!response.ok) { + logger.error('Clerk API request failed', { data, status: response.status }) + throw new Error( + (data as ClerkApiError).errors?.[0]?.message || 'Failed to update organization in Clerk' + ) + } + + const org = data as ClerkOrganization + return { + success: true, + output: { + id: org.id, + name: org.name, + slug: org.slug ?? null, + imageUrl: org.image_url ?? null, + hasImage: org.has_image ?? false, + membersCount: org.members_count ?? null, + pendingInvitationsCount: org.pending_invitations_count ?? null, + maxAllowedMemberships: org.max_allowed_memberships ?? 0, + adminDeleteEnabled: org.admin_delete_enabled ?? false, + createdBy: org.created_by ?? null, + createdAt: org.created_at, + updatedAt: org.updated_at, + publicMetadata: org.public_metadata ?? {}, + success: true, + }, + } + }, + + outputs: { + id: { type: 'string', description: 'Organization ID' }, + name: { type: 'string', description: 'Organization name' }, + slug: { type: 'string', description: 'Organization slug', optional: true }, + imageUrl: { type: 'string', description: 'Organization image URL', optional: true }, + hasImage: { type: 'boolean', description: 'Whether organization has an image' }, + membersCount: { type: 'number', description: 'Number of members', optional: true }, + pendingInvitationsCount: { + type: 'number', + description: 'Number of pending invitations', + optional: true, + }, + maxAllowedMemberships: { type: 'number', description: 'Max allowed memberships' }, + adminDeleteEnabled: { type: 'boolean', description: 'Whether admin delete is enabled' }, + createdBy: { type: 'string', description: 'Creator user ID', optional: true }, + createdAt: { type: 'number', description: 'Creation timestamp' }, + updatedAt: { type: 'number', description: 'Last update timestamp' }, + publicMetadata: { type: 'json', description: 'Public metadata' }, + success: { type: 'boolean', description: 'Operation success status' }, + }, +} diff --git a/apps/sim/tools/clerk/update_organization_membership.ts b/apps/sim/tools/clerk/update_organization_membership.ts new file mode 100644 index 00000000000..d7cb8f8ae21 --- /dev/null +++ b/apps/sim/tools/clerk/update_organization_membership.ts @@ -0,0 +1,123 @@ +import { createLogger } from '@sim/logger' +import type { + ClerkApiError, + ClerkOrganizationMembership, + ClerkUpdateOrganizationMembershipParams, + ClerkUpdateOrganizationMembershipResponse, +} from '@/tools/clerk/types' +import type { ToolConfig } from '@/tools/types' + +const logger = createLogger('ClerkUpdateOrganizationMembership') + +export const clerkUpdateOrganizationMembershipTool: ToolConfig< + ClerkUpdateOrganizationMembershipParams, + ClerkUpdateOrganizationMembershipResponse +> = { + id: 'clerk_update_organization_membership', + name: 'Update Organization Membership in Clerk', + description: "Change a member's role within a Clerk organization", + version: '1.0.0', + + params: { + secretKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'The Clerk Secret Key for API authentication', + }, + organizationId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The ID of the organization (e.g., org_2NNEqL2nrIRdJ194ndJqAHwEfxC)', + }, + userId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the member whose role is being changed', + }, + role: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'New role to assign, e.g. org:admin or org:member', + }, + }, + + request: { + url: (params) => + `https://api.clerk.com/v1/organizations/${params.organizationId?.trim()}/memberships/${params.userId?.trim()}`, + method: 'PATCH', + headers: (params) => { + if (!params.secretKey) { + throw new Error('Clerk Secret Key is required') + } + return { + Authorization: `Bearer ${params.secretKey}`, + 'Content-Type': 'application/json', + } + }, + body: (params) => ({ + role: params.role, + }), + }, + + transformResponse: async (response: Response) => { + const data: ClerkOrganizationMembership | ClerkApiError = await response.json() + + if (!response.ok) { + logger.error('Clerk API request failed', { data, status: response.status }) + throw new Error( + (data as ClerkApiError).errors?.[0]?.message || + 'Failed to update organization membership in Clerk' + ) + } + + const membership = data as ClerkOrganizationMembership + return { + success: true, + output: { + id: membership.id, + role: membership.role, + roleName: membership.role_name ?? null, + permissions: membership.permissions ?? [], + organizationId: membership.organization.id, + userId: membership.public_user_data.user_id, + firstName: membership.public_user_data.first_name ?? null, + lastName: membership.public_user_data.last_name ?? null, + imageUrl: membership.public_user_data.image_url ?? null, + identifier: membership.public_user_data.identifier ?? null, + username: membership.public_user_data.username ?? null, + banned: membership.public_user_data.banned ?? false, + publicMetadata: membership.public_metadata ?? {}, + createdAt: membership.created_at, + updatedAt: membership.updated_at, + success: true, + }, + } + }, + + outputs: { + id: { type: 'string', description: 'Membership ID' }, + role: { type: 'string', description: 'Member role' }, + roleName: { type: 'string', description: 'Human-readable role name', optional: true }, + permissions: { + type: 'array', + description: 'Permissions granted by the role', + items: { type: 'string' }, + }, + organizationId: { type: 'string', description: 'Organization ID' }, + userId: { type: 'string', description: 'Member user ID' }, + firstName: { type: 'string', description: 'Member first name', optional: true }, + lastName: { type: 'string', description: 'Member last name', optional: true }, + imageUrl: { type: 'string', description: 'Member profile image URL', optional: true }, + identifier: { type: 'string', description: 'Member identifier (e.g., email)', optional: true }, + username: { type: 'string', description: 'Member username', optional: true }, + banned: { type: 'boolean', description: 'Whether the member is banned' }, + publicMetadata: { type: 'json', description: 'Public metadata' }, + createdAt: { type: 'number', description: 'Creation timestamp' }, + updatedAt: { type: 'number', description: 'Last update timestamp' }, + success: { type: 'boolean', description: 'Operation success status' }, + }, +} diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index 4b61c4e77c8..50c24a36e4b 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -399,16 +399,39 @@ import { } from '@/tools/calendly' import { clayPopulateTool } from '@/tools/clay' import { + clerkAddOrganizationMemberTool, + clerkBanUserTool, + clerkCreateActorTokenTool, + clerkCreateAllowlistIdentifierTool, + clerkCreateBlocklistIdentifierTool, + clerkCreateOrganizationInvitationTool, clerkCreateOrganizationTool, clerkCreateUserTool, + clerkDeleteAllowlistIdentifierTool, + clerkDeleteBlocklistIdentifierTool, + clerkDeleteOrganizationTool, clerkDeleteUserTool, + clerkGetJwtTemplateTool, clerkGetOrganizationTool, clerkGetSessionTool, + clerkGetUserOauthTokenTool, clerkGetUserTool, + clerkListAllowlistIdentifiersTool, + clerkListBlocklistIdentifiersTool, + clerkListJwtTemplatesTool, + clerkListOrganizationInvitationsTool, + clerkListOrganizationMembershipsTool, clerkListOrganizationsTool, clerkListSessionsTool, clerkListUsersTool, + clerkLockUserTool, + clerkRemoveOrganizationMemberTool, + clerkRevokeActorTokenTool, clerkRevokeSessionTool, + clerkUnbanUserTool, + clerkUnlockUserTool, + clerkUpdateOrganizationMembershipTool, + clerkUpdateOrganizationTool, clerkUpdateUserTool, } from '@/tools/clerk' import { @@ -7049,12 +7072,35 @@ export const tools: Record = { clerk_create_user: clerkCreateUserTool, clerk_update_user: clerkUpdateUserTool, clerk_delete_user: clerkDeleteUserTool, + clerk_ban_user: clerkBanUserTool, + clerk_unban_user: clerkUnbanUserTool, + clerk_lock_user: clerkLockUserTool, + clerk_unlock_user: clerkUnlockUserTool, + clerk_get_user_oauth_token: clerkGetUserOauthTokenTool, clerk_list_organizations: clerkListOrganizationsTool, clerk_get_organization: clerkGetOrganizationTool, clerk_create_organization: clerkCreateOrganizationTool, + clerk_update_organization: clerkUpdateOrganizationTool, + clerk_delete_organization: clerkDeleteOrganizationTool, + clerk_list_organization_memberships: clerkListOrganizationMembershipsTool, + clerk_add_organization_member: clerkAddOrganizationMemberTool, + clerk_update_organization_membership: clerkUpdateOrganizationMembershipTool, + clerk_remove_organization_member: clerkRemoveOrganizationMemberTool, + clerk_create_organization_invitation: clerkCreateOrganizationInvitationTool, + clerk_list_organization_invitations: clerkListOrganizationInvitationsTool, clerk_list_sessions: clerkListSessionsTool, clerk_get_session: clerkGetSessionTool, clerk_revoke_session: clerkRevokeSessionTool, + clerk_list_allowlist_identifiers: clerkListAllowlistIdentifiersTool, + clerk_create_allowlist_identifier: clerkCreateAllowlistIdentifierTool, + clerk_delete_allowlist_identifier: clerkDeleteAllowlistIdentifierTool, + clerk_list_blocklist_identifiers: clerkListBlocklistIdentifiersTool, + clerk_create_blocklist_identifier: clerkCreateBlocklistIdentifierTool, + clerk_delete_blocklist_identifier: clerkDeleteBlocklistIdentifierTool, + clerk_list_jwt_templates: clerkListJwtTemplatesTool, + clerk_get_jwt_template: clerkGetJwtTemplateTool, + clerk_create_actor_token: clerkCreateActorTokenTool, + clerk_revoke_actor_token: clerkRevokeActorTokenTool, cloudflare_list_zones: cloudflareListZonesTool, cloudflare_get_zone: cloudflareGetZoneTool, cloudflare_create_zone: cloudflareCreateZoneTool, diff --git a/apps/sim/triggers/clerk/index.ts b/apps/sim/triggers/clerk/index.ts index 88eb2a8acd3..55f149730e8 100644 --- a/apps/sim/triggers/clerk/index.ts +++ b/apps/sim/triggers/clerk/index.ts @@ -1,6 +1,13 @@ export { clerkOrganizationCreatedTrigger } from './organization_created' +export { clerkOrganizationDeletedTrigger } from './organization_deleted' export { clerkOrganizationMembershipCreatedTrigger } from './organization_membership_created' +export { clerkOrganizationMembershipDeletedTrigger } from './organization_membership_deleted' +export { clerkOrganizationMembershipUpdatedTrigger } from './organization_membership_updated' +export { clerkOrganizationUpdatedTrigger } from './organization_updated' export { clerkSessionCreatedTrigger } from './session_created' +export { clerkSessionEndedTrigger } from './session_ended' +export { clerkSessionRemovedTrigger } from './session_removed' +export { clerkSessionRevokedTrigger } from './session_revoked' export { clerkUserCreatedTrigger } from './user_created' export { clerkUserDeletedTrigger } from './user_deleted' export { clerkUserUpdatedTrigger } from './user_updated' diff --git a/apps/sim/triggers/clerk/organization_deleted.ts b/apps/sim/triggers/clerk/organization_deleted.ts new file mode 100644 index 00000000000..3ce87d4442d --- /dev/null +++ b/apps/sim/triggers/clerk/organization_deleted.ts @@ -0,0 +1,38 @@ +import { ClerkIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildClerkExtraFields, + buildOrganizationDeletedOutputs, + clerkSetupInstructions, + clerkTriggerOptions, +} from '@/triggers/clerk/utils' +import type { TriggerConfig } from '@/triggers/types' + +/** + * Clerk Organization Deleted Trigger. + * Triggers when an organization is deleted. + */ +export const clerkOrganizationDeletedTrigger: TriggerConfig = { + id: 'clerk_organization_deleted', + name: 'Clerk Organization Deleted', + provider: 'clerk', + description: 'Trigger workflow when a Clerk organization is deleted', + version: '1.0.0', + icon: ClerkIcon, + + subBlocks: buildTriggerSubBlocks({ + triggerId: 'clerk_organization_deleted', + triggerOptions: clerkTriggerOptions, + setupInstructions: clerkSetupInstructions('organization.deleted'), + extraFields: buildClerkExtraFields('clerk_organization_deleted'), + }), + + outputs: buildOrganizationDeletedOutputs(), + + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }, +} diff --git a/apps/sim/triggers/clerk/organization_membership_deleted.ts b/apps/sim/triggers/clerk/organization_membership_deleted.ts new file mode 100644 index 00000000000..9f8462edffc --- /dev/null +++ b/apps/sim/triggers/clerk/organization_membership_deleted.ts @@ -0,0 +1,38 @@ +import { ClerkIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildClerkExtraFields, + buildOrganizationMembershipDeletedOutputs, + clerkSetupInstructions, + clerkTriggerOptions, +} from '@/triggers/clerk/utils' +import type { TriggerConfig } from '@/triggers/types' + +/** + * Clerk Organization Membership Deleted Trigger. + * Triggers when a member is removed from an organization. + */ +export const clerkOrganizationMembershipDeletedTrigger: TriggerConfig = { + id: 'clerk_organization_membership_deleted', + name: 'Clerk Organization Membership Deleted', + provider: 'clerk', + description: 'Trigger workflow when a Clerk organization membership is deleted', + version: '1.0.0', + icon: ClerkIcon, + + subBlocks: buildTriggerSubBlocks({ + triggerId: 'clerk_organization_membership_deleted', + triggerOptions: clerkTriggerOptions, + setupInstructions: clerkSetupInstructions('organizationMembership.deleted'), + extraFields: buildClerkExtraFields('clerk_organization_membership_deleted'), + }), + + outputs: buildOrganizationMembershipDeletedOutputs(), + + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }, +} diff --git a/apps/sim/triggers/clerk/organization_membership_updated.ts b/apps/sim/triggers/clerk/organization_membership_updated.ts new file mode 100644 index 00000000000..5e41e9e336c --- /dev/null +++ b/apps/sim/triggers/clerk/organization_membership_updated.ts @@ -0,0 +1,38 @@ +import { ClerkIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildClerkExtraFields, + buildOrganizationMembershipOutputs, + clerkSetupInstructions, + clerkTriggerOptions, +} from '@/triggers/clerk/utils' +import type { TriggerConfig } from '@/triggers/types' + +/** + * Clerk Organization Membership Updated Trigger. + * Triggers when a member's role within an organization changes. + */ +export const clerkOrganizationMembershipUpdatedTrigger: TriggerConfig = { + id: 'clerk_organization_membership_updated', + name: 'Clerk Organization Membership Updated', + provider: 'clerk', + description: 'Trigger workflow when a Clerk organization membership is updated', + version: '1.0.0', + icon: ClerkIcon, + + subBlocks: buildTriggerSubBlocks({ + triggerId: 'clerk_organization_membership_updated', + triggerOptions: clerkTriggerOptions, + setupInstructions: clerkSetupInstructions('organizationMembership.updated'), + extraFields: buildClerkExtraFields('clerk_organization_membership_updated'), + }), + + outputs: buildOrganizationMembershipOutputs(), + + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }, +} diff --git a/apps/sim/triggers/clerk/organization_updated.ts b/apps/sim/triggers/clerk/organization_updated.ts new file mode 100644 index 00000000000..feda7b46f92 --- /dev/null +++ b/apps/sim/triggers/clerk/organization_updated.ts @@ -0,0 +1,38 @@ +import { ClerkIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildClerkExtraFields, + buildOrganizationOutputs, + clerkSetupInstructions, + clerkTriggerOptions, +} from '@/triggers/clerk/utils' +import type { TriggerConfig } from '@/triggers/types' + +/** + * Clerk Organization Updated Trigger. + * Triggers when an organization's details are updated. + */ +export const clerkOrganizationUpdatedTrigger: TriggerConfig = { + id: 'clerk_organization_updated', + name: 'Clerk Organization Updated', + provider: 'clerk', + description: 'Trigger workflow when a Clerk organization is updated', + version: '1.0.0', + icon: ClerkIcon, + + subBlocks: buildTriggerSubBlocks({ + triggerId: 'clerk_organization_updated', + triggerOptions: clerkTriggerOptions, + setupInstructions: clerkSetupInstructions('organization.updated'), + extraFields: buildClerkExtraFields('clerk_organization_updated'), + }), + + outputs: buildOrganizationOutputs(), + + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }, +} diff --git a/apps/sim/triggers/clerk/session_ended.ts b/apps/sim/triggers/clerk/session_ended.ts new file mode 100644 index 00000000000..faa59ddd245 --- /dev/null +++ b/apps/sim/triggers/clerk/session_ended.ts @@ -0,0 +1,38 @@ +import { ClerkIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildClerkExtraFields, + buildSessionOutputs, + clerkSetupInstructions, + clerkTriggerOptions, +} from '@/triggers/clerk/utils' +import type { TriggerConfig } from '@/triggers/types' + +/** + * Clerk Session Ended Trigger. + * Triggers when a user signs out and the session ends. + */ +export const clerkSessionEndedTrigger: TriggerConfig = { + id: 'clerk_session_ended', + name: 'Clerk Session Ended', + provider: 'clerk', + description: 'Trigger workflow when a Clerk session ends', + version: '1.0.0', + icon: ClerkIcon, + + subBlocks: buildTriggerSubBlocks({ + triggerId: 'clerk_session_ended', + triggerOptions: clerkTriggerOptions, + setupInstructions: clerkSetupInstructions('session.ended'), + extraFields: buildClerkExtraFields('clerk_session_ended'), + }), + + outputs: buildSessionOutputs(), + + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }, +} diff --git a/apps/sim/triggers/clerk/session_removed.ts b/apps/sim/triggers/clerk/session_removed.ts new file mode 100644 index 00000000000..c19c8fad1e2 --- /dev/null +++ b/apps/sim/triggers/clerk/session_removed.ts @@ -0,0 +1,38 @@ +import { ClerkIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildClerkExtraFields, + buildSessionOutputs, + clerkSetupInstructions, + clerkTriggerOptions, +} from '@/triggers/clerk/utils' +import type { TriggerConfig } from '@/triggers/types' + +/** + * Clerk Session Removed Trigger. + * Triggers when a session is removed, e.g. because the associated user was deleted. + */ +export const clerkSessionRemovedTrigger: TriggerConfig = { + id: 'clerk_session_removed', + name: 'Clerk Session Removed', + provider: 'clerk', + description: 'Trigger workflow when a Clerk session is removed', + version: '1.0.0', + icon: ClerkIcon, + + subBlocks: buildTriggerSubBlocks({ + triggerId: 'clerk_session_removed', + triggerOptions: clerkTriggerOptions, + setupInstructions: clerkSetupInstructions('session.removed'), + extraFields: buildClerkExtraFields('clerk_session_removed'), + }), + + outputs: buildSessionOutputs(), + + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }, +} diff --git a/apps/sim/triggers/clerk/session_revoked.ts b/apps/sim/triggers/clerk/session_revoked.ts new file mode 100644 index 00000000000..0f5d1b661ab --- /dev/null +++ b/apps/sim/triggers/clerk/session_revoked.ts @@ -0,0 +1,38 @@ +import { ClerkIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildClerkExtraFields, + buildSessionOutputs, + clerkSetupInstructions, + clerkTriggerOptions, +} from '@/triggers/clerk/utils' +import type { TriggerConfig } from '@/triggers/types' + +/** + * Clerk Session Revoked Trigger. + * Triggers when a session is revoked, e.g. via the Revoke Session API or Dashboard. + */ +export const clerkSessionRevokedTrigger: TriggerConfig = { + id: 'clerk_session_revoked', + name: 'Clerk Session Revoked', + provider: 'clerk', + description: 'Trigger workflow when a Clerk session is revoked', + version: '1.0.0', + icon: ClerkIcon, + + subBlocks: buildTriggerSubBlocks({ + triggerId: 'clerk_session_revoked', + triggerOptions: clerkTriggerOptions, + setupInstructions: clerkSetupInstructions('session.revoked'), + extraFields: buildClerkExtraFields('clerk_session_revoked'), + }), + + outputs: buildSessionOutputs(), + + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }, +} diff --git a/apps/sim/triggers/clerk/utils.ts b/apps/sim/triggers/clerk/utils.ts index e1a4f733288..38772722027 100644 --- a/apps/sim/triggers/clerk/utils.ts +++ b/apps/sim/triggers/clerk/utils.ts @@ -12,8 +12,15 @@ export const CLERK_TRIGGER_TO_EVENT_TYPE: Record = { clerk_user_updated: 'user.updated', clerk_user_deleted: 'user.deleted', clerk_session_created: 'session.created', + clerk_session_ended: 'session.ended', + clerk_session_removed: 'session.removed', + clerk_session_revoked: 'session.revoked', clerk_organization_created: 'organization.created', + clerk_organization_updated: 'organization.updated', + clerk_organization_deleted: 'organization.deleted', clerk_organization_membership_created: 'organizationMembership.created', + clerk_organization_membership_updated: 'organizationMembership.updated', + clerk_organization_membership_deleted: 'organizationMembership.deleted', } /** @@ -24,8 +31,15 @@ export const clerkTriggerOptions = [ { label: 'User Updated', id: 'clerk_user_updated' }, { label: 'User Deleted', id: 'clerk_user_deleted' }, { label: 'Session Created', id: 'clerk_session_created' }, + { label: 'Session Ended', id: 'clerk_session_ended' }, + { label: 'Session Removed', id: 'clerk_session_removed' }, + { label: 'Session Revoked', id: 'clerk_session_revoked' }, { label: 'Organization Created', id: 'clerk_organization_created' }, + { label: 'Organization Updated', id: 'clerk_organization_updated' }, + { label: 'Organization Deleted', id: 'clerk_organization_deleted' }, { label: 'Organization Membership Created', id: 'clerk_organization_membership_created' }, + { label: 'Organization Membership Updated', id: 'clerk_organization_membership_updated' }, + { label: 'Organization Membership Deleted', id: 'clerk_organization_membership_deleted' }, { label: 'Generic Webhook (All Events)', id: 'clerk_webhook' }, ] @@ -159,7 +173,7 @@ export function buildOrganizationOutputs(): Record { } /** - * Build outputs for `organizationMembership.created` events. + * Build outputs for `organizationMembership.created` and `.updated` events. * The `data` object is the Clerk OrganizationMembership object. */ export function buildOrganizationMembershipOutputs(): Record { @@ -179,6 +193,33 @@ export function buildOrganizationMembershipOutputs(): Record { + return { + ...commonEventOutputs, + organizationId: { type: 'string', description: 'Deleted Clerk organization ID (data.id)' }, + deleted: { + type: 'boolean', + description: 'Whether the organization was deleted (data.deleted)', + }, + } +} + +/** + * Build outputs for `organizationMembership.deleted` events. + * The `data` object is a deleted-object marker: `{ id, deleted, object }`. + */ +export function buildOrganizationMembershipDeletedOutputs(): Record { + return { + ...commonEventOutputs, + membershipId: { type: 'string', description: 'Deleted membership ID (data.id)' }, + deleted: { type: 'boolean', description: 'Whether the membership was deleted (data.deleted)' }, + } +} + /** * Build outputs for the generic webhook (all events). * Only the fields common to every Clerk event are guaranteed; use `data` diff --git a/apps/sim/triggers/registry.ts b/apps/sim/triggers/registry.ts index c13704ed89f..67416d77974 100644 --- a/apps/sim/triggers/registry.ts +++ b/apps/sim/triggers/registry.ts @@ -60,8 +60,15 @@ import { } from '@/triggers/circleback' import { clerkOrganizationCreatedTrigger, + clerkOrganizationDeletedTrigger, clerkOrganizationMembershipCreatedTrigger, + clerkOrganizationMembershipDeletedTrigger, + clerkOrganizationMembershipUpdatedTrigger, + clerkOrganizationUpdatedTrigger, clerkSessionCreatedTrigger, + clerkSessionEndedTrigger, + clerkSessionRemovedTrigger, + clerkSessionRevokedTrigger, clerkUserCreatedTrigger, clerkUserDeletedTrigger, clerkUserUpdatedTrigger, @@ -724,8 +731,15 @@ export const TRIGGER_REGISTRY: TriggerRegistry = { clerk_user_updated: clerkUserUpdatedTrigger, clerk_user_deleted: clerkUserDeletedTrigger, clerk_session_created: clerkSessionCreatedTrigger, + clerk_session_ended: clerkSessionEndedTrigger, + clerk_session_removed: clerkSessionRemovedTrigger, + clerk_session_revoked: clerkSessionRevokedTrigger, clerk_organization_created: clerkOrganizationCreatedTrigger, + clerk_organization_updated: clerkOrganizationUpdatedTrigger, + clerk_organization_deleted: clerkOrganizationDeletedTrigger, clerk_organization_membership_created: clerkOrganizationMembershipCreatedTrigger, + clerk_organization_membership_updated: clerkOrganizationMembershipUpdatedTrigger, + clerk_organization_membership_deleted: clerkOrganizationMembershipDeletedTrigger, clerk_webhook: clerkWebhookTrigger, incidentio_incident_created: incidentioIncidentCreatedTrigger, incidentio_incident_updated: incidentioIncidentUpdatedTrigger, From a08da86dff1cbd2d6a19aadee89cdef8b7fe5ef9 Mon Sep 17 00:00:00 2001 From: Waleed Date: Thu, 2 Jul 2026 10:47:31 -0700 Subject: [PATCH 17/28] feat(wordpress): add category/tag CRUD tools, fix delete-status and dead-field bugs (#5360) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(wordpress): add category/tag CRUD tools, fix delete-status and dead-field bugs - Add wordpress_{get,update,delete}_category and _tag tools for full taxonomy parity - Fix `deleted: data.deleted || true` always evaluating true across all 6 delete tools (posts/pages/media/comments/categories/tags) - Remove dead `force` param from delete_media (endpoint always force-deletes; param had zero effect) - Remove unwired `hideEmpty` block input - Normalize search_content perPage/page visibility to user-or-llm for consistency * fix(wordpress): use ?? instead of || for zero-valued numeric fields in delete_category/tag count and parent can legitimately be 0 (empty term, top-level category); || was dropping those values, same antipattern already fixed for `deleted` in this PR. Flagged independently by Greptile and Cursor Bugbot. * fix(wordpress): use ?? for zero-valued numeric fields across all delete tools Same || antipattern already fixed for category/tag delete tools was still present in delete_post/page/comment/media for id, author, featured_media, menu_order, parent, post — all legitimately 0 in common cases (no featured image, top-level page/comment). Found by a final independent validation pass. * fix(wordpress): don't drop categoryParent=0 (root-level category) in block param mapping Truthy check on params.categoryParent treated a resolved numeric 0 (root-level, no parent) as unset. Flagged by Cursor Bugbot on create_category/update_category. * fix(wordpress): don't clear category/tag description on update when field left blank description used !== undefined (numeric-field convention) instead of a truthy check (string-field convention used everywhere else in this codebase, e.g. update_post/update_page excerpt), so an untouched empty description field silently wiped existing text on every update. Flagged by Cursor Bugbot. * fix(wordpress): fix search type/subtype mislabeling, complete I/O exposure gaps - search_content.ts: type param was mislabeled with subtype's vocabulary (post/page/attachment); real WP type enum is post/term/post-format. Rewired the block's Content Type dropdown to map to subtype (which is what post/page/attachment actually filter), not type. - Widened subBlock conditions so params already read by tools.config.params are actually reachable in the UI: commentPostId for list_comments, categories/tags for list_posts, parent for list_pages. - Added missing subBlocks for tool params with no UI path: comment parent/authorName/authorEmail/authorUrl (create_comment), media description (upload_media), author filter (list_posts). - Extended the ?? / !== undefined fix (already applied to categoryParent) to the same class of param across the block: featuredMedia, page parent, menuOrder, and the new commentParent/listAuthor mappings. - Fixed featuredMedia/parent truthy-check inconsistency in create_post, update_post, create_page, update_page, create_category, create_comment body builders to match the !== undefined convention used elsewhere. Found by an independent final validation pass across 3 parallel agents. * fix(wordpress): keep searchType subBlock id to preserve saved-workflow compat The type/subtype fix should only change which API param the field feeds (subtype, not type) — renaming the subBlock id to searchSubtype broke already-saved workflows with a search content-type filter set, since the block would stop reading the old searchType key. Reverted the id rename, kept the underlying subtype mapping fix. Flagged by Cursor Bugbot. * fix(wordpress): remove invalid Attachment search subtype, fix listAuthor input type - Search Content's "Content Type" dropdown offered Attachment, which maps to subtype=attachment. WP core's WP_REST_Post_Search_Handler explicitly excludes attachment from valid subtypes (media isn't searchable via /search) — selecting it guaranteed a 400 rest_invalid_param. Removed the option; only Post/Page (the only valid subtypes) remain. - listAuthor was declared type: 'string' in the inputs catalog despite being Number()-coerced before use, inconsistent with every other ID-like field (postId, pageId, categoryId, commentParent, etc. are all 'number'). Found by an independent final pre-merge validation pass, requested before merge to be certain of full API alignment. --- apps/sim/blocks/blocks/wordpress.ts | 283 +++++++++++++++++--- apps/sim/tools/registry.ts | 12 + apps/sim/tools/wordpress/create_category.ts | 2 +- apps/sim/tools/wordpress/create_comment.ts | 2 +- apps/sim/tools/wordpress/create_page.ts | 6 +- apps/sim/tools/wordpress/create_post.ts | 2 +- apps/sim/tools/wordpress/delete_category.ts | 96 +++++++ apps/sim/tools/wordpress/delete_comment.ts | 10 +- apps/sim/tools/wordpress/delete_media.ts | 12 +- apps/sim/tools/wordpress/delete_page.ts | 12 +- apps/sim/tools/wordpress/delete_post.ts | 8 +- apps/sim/tools/wordpress/delete_tag.ts | 91 +++++++ apps/sim/tools/wordpress/get_category.ts | 86 ++++++ apps/sim/tools/wordpress/get_tag.ts | 83 ++++++ apps/sim/tools/wordpress/index.ts | 12 + apps/sim/tools/wordpress/search_content.ts | 16 +- apps/sim/tools/wordpress/types.ts | 84 +++++- apps/sim/tools/wordpress/update_category.ts | 122 +++++++++ apps/sim/tools/wordpress/update_page.ts | 2 +- apps/sim/tools/wordpress/update_post.ts | 2 +- apps/sim/tools/wordpress/update_tag.ts | 110 ++++++++ 21 files changed, 978 insertions(+), 75 deletions(-) create mode 100644 apps/sim/tools/wordpress/delete_category.ts create mode 100644 apps/sim/tools/wordpress/delete_tag.ts create mode 100644 apps/sim/tools/wordpress/get_category.ts create mode 100644 apps/sim/tools/wordpress/get_tag.ts create mode 100644 apps/sim/tools/wordpress/update_category.ts create mode 100644 apps/sim/tools/wordpress/update_tag.ts diff --git a/apps/sim/blocks/blocks/wordpress.ts b/apps/sim/blocks/blocks/wordpress.ts index b451b6a1a41..cb38487859e 100644 --- a/apps/sim/blocks/blocks/wordpress.ts +++ b/apps/sim/blocks/blocks/wordpress.ts @@ -11,7 +11,7 @@ export const WordPressBlock: BlockConfig = { description: 'Manage WordPress content', authMode: AuthMode.OAuth, longDescription: - 'Integrate with WordPress to create, update, and manage posts, pages, media, comments, categories, tags, and users. Supports WordPress.com sites via OAuth and self-hosted WordPress sites using Application Passwords authentication.', + 'Integrate with WordPress.com to create, update, and manage posts, pages, media, comments, categories, tags, and users. Connects to WordPress.com sites via OAuth.', docsLink: 'https://docs.sim.ai/integrations/wordpress', category: 'tools', integrationType: IntegrationType.Marketing, @@ -49,9 +49,15 @@ export const WordPressBlock: BlockConfig = { { label: 'Delete Comment', id: 'wordpress_delete_comment' }, // Categories { label: 'Create Category', id: 'wordpress_create_category' }, + { label: 'Update Category', id: 'wordpress_update_category' }, + { label: 'Delete Category', id: 'wordpress_delete_category' }, + { label: 'Get Category', id: 'wordpress_get_category' }, { label: 'List Categories', id: 'wordpress_list_categories' }, // Tags { label: 'Create Tag', id: 'wordpress_create_tag' }, + { label: 'Update Tag', id: 'wordpress_update_tag' }, + { label: 'Delete Tag', id: 'wordpress_delete_tag' }, + { label: 'Get Tag', id: 'wordpress_get_tag' }, { label: 'List Tags', id: 'wordpress_list_tags' }, // Users { label: 'Get Current User', id: 'wordpress_get_current_user' }, @@ -208,7 +214,7 @@ export const WordPressBlock: BlockConfig = { }, }, - // Categories (for posts only) + // Categories (for posts) { id: 'categories', title: 'Categories', @@ -217,11 +223,11 @@ export const WordPressBlock: BlockConfig = { mode: 'advanced', condition: { field: 'operation', - value: ['wordpress_create_post', 'wordpress_update_post'], + value: ['wordpress_create_post', 'wordpress_update_post', 'wordpress_list_posts'], }, }, - // Tags (for posts only) + // Tags (for posts) { id: 'tags', title: 'Tags', @@ -230,10 +236,20 @@ export const WordPressBlock: BlockConfig = { mode: 'advanced', condition: { field: 'operation', - value: ['wordpress_create_post', 'wordpress_update_post'], + value: ['wordpress_create_post', 'wordpress_update_post', 'wordpress_list_posts'], }, }, + // List Posts: Author filter + { + id: 'listAuthor', + title: 'Author ID', + type: 'short-input', + placeholder: 'Filter by author ID', + mode: 'advanced', + condition: { field: 'operation', value: 'wordpress_list_posts' }, + }, + // Featured Media ID { id: 'featuredMedia', @@ -277,7 +293,7 @@ export const WordPressBlock: BlockConfig = { mode: 'advanced', condition: { field: 'operation', - value: ['wordpress_create_page', 'wordpress_update_page'], + value: ['wordpress_create_page', 'wordpress_update_page', 'wordpress_list_pages'], }, }, @@ -349,6 +365,14 @@ export const WordPressBlock: BlockConfig = { mode: 'advanced', condition: { field: 'operation', value: 'wordpress_upload_media' }, }, + { + id: 'mediaDescription', + title: 'Description', + type: 'long-input', + placeholder: 'Media description', + mode: 'advanced', + condition: { field: 'operation', value: 'wordpress_upload_media' }, + }, { id: 'mediaId', title: 'Media ID', @@ -385,7 +409,10 @@ export const WordPressBlock: BlockConfig = { title: 'Post ID', type: 'short-input', placeholder: 'Post ID to comment on', - condition: { field: 'operation', value: 'wordpress_create_comment' }, + condition: { + field: 'operation', + value: ['wordpress_create_comment', 'wordpress_list_comments'], + }, required: { field: 'operation', value: 'wordpress_create_comment' }, }, { @@ -399,6 +426,38 @@ export const WordPressBlock: BlockConfig = { }, required: { field: 'operation', value: 'wordpress_create_comment' }, }, + { + id: 'commentParent', + title: 'Parent Comment ID', + type: 'short-input', + placeholder: 'Parent comment ID (for replies)', + mode: 'advanced', + condition: { field: 'operation', value: 'wordpress_create_comment' }, + }, + { + id: 'commentAuthorName', + title: 'Author Name', + type: 'short-input', + placeholder: 'Comment author display name', + mode: 'advanced', + condition: { field: 'operation', value: 'wordpress_create_comment' }, + }, + { + id: 'commentAuthorEmail', + title: 'Author Email', + type: 'short-input', + placeholder: 'Comment author email', + mode: 'advanced', + condition: { field: 'operation', value: 'wordpress_create_comment' }, + }, + { + id: 'commentAuthorUrl', + title: 'Author URL', + type: 'short-input', + placeholder: 'Comment author URL', + mode: 'advanced', + condition: { field: 'operation', value: 'wordpress_create_comment' }, + }, { id: 'commentId', title: 'Comment ID', @@ -429,12 +488,29 @@ export const WordPressBlock: BlockConfig = { }, // Category Operations + { + id: 'categoryId', + title: 'Category ID', + type: 'short-input', + placeholder: 'Enter category ID', + condition: { + field: 'operation', + value: ['wordpress_get_category', 'wordpress_update_category', 'wordpress_delete_category'], + }, + required: { + field: 'operation', + value: ['wordpress_get_category', 'wordpress_update_category', 'wordpress_delete_category'], + }, + }, { id: 'categoryName', title: 'Category Name', type: 'short-input', placeholder: 'Category name', - condition: { field: 'operation', value: 'wordpress_create_category' }, + condition: { + field: 'operation', + value: ['wordpress_create_category', 'wordpress_update_category'], + }, required: { field: 'operation', value: 'wordpress_create_category' }, }, { @@ -443,7 +519,10 @@ export const WordPressBlock: BlockConfig = { type: 'long-input', placeholder: 'Category description', mode: 'advanced', - condition: { field: 'operation', value: 'wordpress_create_category' }, + condition: { + field: 'operation', + value: ['wordpress_create_category', 'wordpress_update_category'], + }, }, { id: 'categoryParent', @@ -451,7 +530,10 @@ export const WordPressBlock: BlockConfig = { type: 'short-input', placeholder: 'Parent category ID', mode: 'advanced', - condition: { field: 'operation', value: 'wordpress_create_category' }, + condition: { + field: 'operation', + value: ['wordpress_create_category', 'wordpress_update_category'], + }, }, { id: 'categorySlug', @@ -459,16 +541,36 @@ export const WordPressBlock: BlockConfig = { type: 'short-input', placeholder: 'URL slug (optional)', mode: 'advanced', - condition: { field: 'operation', value: 'wordpress_create_category' }, + condition: { + field: 'operation', + value: ['wordpress_create_category', 'wordpress_update_category'], + }, }, // Tag Operations + { + id: 'tagId', + title: 'Tag ID', + type: 'short-input', + placeholder: 'Enter tag ID', + condition: { + field: 'operation', + value: ['wordpress_get_tag', 'wordpress_update_tag', 'wordpress_delete_tag'], + }, + required: { + field: 'operation', + value: ['wordpress_get_tag', 'wordpress_update_tag', 'wordpress_delete_tag'], + }, + }, { id: 'tagName', title: 'Tag Name', type: 'short-input', placeholder: 'Tag name', - condition: { field: 'operation', value: 'wordpress_create_tag' }, + condition: { + field: 'operation', + value: ['wordpress_create_tag', 'wordpress_update_tag'], + }, required: { field: 'operation', value: 'wordpress_create_tag' }, }, { @@ -477,7 +579,10 @@ export const WordPressBlock: BlockConfig = { type: 'long-input', placeholder: 'Tag description', mode: 'advanced', - condition: { field: 'operation', value: 'wordpress_create_tag' }, + condition: { + field: 'operation', + value: ['wordpress_create_tag', 'wordpress_update_tag'], + }, }, { id: 'tagSlug', @@ -485,7 +590,10 @@ export const WordPressBlock: BlockConfig = { type: 'short-input', placeholder: 'URL slug (optional)', mode: 'advanced', - condition: { field: 'operation', value: 'wordpress_create_tag' }, + condition: { + field: 'operation', + value: ['wordpress_create_tag', 'wordpress_update_tag'], + }, }, // User Operations @@ -523,7 +631,6 @@ export const WordPressBlock: BlockConfig = { { label: 'All Types', id: '' }, { label: 'Post', id: 'post' }, { label: 'Page', id: 'page' }, - { label: 'Attachment', id: 'attachment' }, ], value: () => '', mode: 'advanced', @@ -665,12 +772,7 @@ export const WordPressBlock: BlockConfig = { mode: 'advanced', condition: { field: 'operation', - value: [ - 'wordpress_delete_post', - 'wordpress_delete_page', - 'wordpress_delete_media', - 'wordpress_delete_comment', - ], + value: ['wordpress_delete_post', 'wordpress_delete_page', 'wordpress_delete_comment'], }, }, ], @@ -696,8 +798,14 @@ export const WordPressBlock: BlockConfig = { 'wordpress_delete_comment', 'wordpress_create_category', 'wordpress_list_categories', + 'wordpress_get_category', + 'wordpress_update_category', + 'wordpress_delete_category', 'wordpress_create_tag', 'wordpress_list_tags', + 'wordpress_get_tag', + 'wordpress_update_tag', + 'wordpress_delete_tag', 'wordpress_get_current_user', 'wordpress_list_users', 'wordpress_get_user', @@ -723,7 +831,10 @@ export const WordPressBlock: BlockConfig = { slug: params.slug, categories: params.categories, tags: params.tags, - featuredMedia: params.featuredMedia ? Number(params.featuredMedia) : undefined, + featuredMedia: + params.featuredMedia !== undefined && params.featuredMedia !== '' + ? Number(params.featuredMedia) + : undefined, } case 'wordpress_update_post': return { @@ -736,7 +847,10 @@ export const WordPressBlock: BlockConfig = { slug: params.slug, categories: params.categories, tags: params.tags, - featuredMedia: params.featuredMedia ? Number(params.featuredMedia) : undefined, + featuredMedia: + params.featuredMedia !== undefined && params.featuredMedia !== '' + ? Number(params.featuredMedia) + : undefined, } case 'wordpress_delete_post': return { @@ -760,6 +874,10 @@ export const WordPressBlock: BlockConfig = { order: params.order, categories: params.categories, tags: params.tags, + author: + params.listAuthor !== undefined && params.listAuthor !== '' + ? Number(params.listAuthor) + : undefined, } case 'wordpress_create_page': return { @@ -769,9 +887,18 @@ export const WordPressBlock: BlockConfig = { status: params.status, excerpt: params.excerpt, slug: params.slug, - parent: params.parent ? Number(params.parent) : undefined, - menuOrder: params.menuOrder ? Number(params.menuOrder) : undefined, - featuredMedia: params.featuredMedia ? Number(params.featuredMedia) : undefined, + parent: + params.parent !== undefined && params.parent !== '' + ? Number(params.parent) + : undefined, + menuOrder: + params.menuOrder !== undefined && params.menuOrder !== '' + ? Number(params.menuOrder) + : undefined, + featuredMedia: + params.featuredMedia !== undefined && params.featuredMedia !== '' + ? Number(params.featuredMedia) + : undefined, } case 'wordpress_update_page': return { @@ -782,9 +909,18 @@ export const WordPressBlock: BlockConfig = { status: params.status, excerpt: params.excerpt, slug: params.slug, - parent: params.parent ? Number(params.parent) : undefined, - menuOrder: params.menuOrder ? Number(params.menuOrder) : undefined, - featuredMedia: params.featuredMedia ? Number(params.featuredMedia) : undefined, + parent: + params.parent !== undefined && params.parent !== '' + ? Number(params.parent) + : undefined, + menuOrder: + params.menuOrder !== undefined && params.menuOrder !== '' + ? Number(params.menuOrder) + : undefined, + featuredMedia: + params.featuredMedia !== undefined && params.featuredMedia !== '' + ? Number(params.featuredMedia) + : undefined, } case 'wordpress_delete_page': return { @@ -806,7 +942,10 @@ export const WordPressBlock: BlockConfig = { search: params.search, orderBy: params.orderBy, order: params.order, - parent: params.parent ? Number(params.parent) : undefined, + parent: + params.parent !== undefined && params.parent !== '' + ? Number(params.parent) + : undefined, } case 'wordpress_upload_media': // file is the canonical param for both basic (fileUpload) and advanced modes @@ -817,6 +956,7 @@ export const WordPressBlock: BlockConfig = { title: params.mediaTitle, caption: params.caption, altText: params.altText, + description: params.mediaDescription || undefined, } case 'wordpress_get_media': return { @@ -837,13 +977,19 @@ export const WordPressBlock: BlockConfig = { return { ...baseParams, mediaId: Number(params.mediaId), - force: params.force, } case 'wordpress_create_comment': return { ...baseParams, postId: Number(params.commentPostId), content: params.commentContent, + parent: + params.commentParent !== undefined && params.commentParent !== '' + ? Number(params.commentParent) + : undefined, + authorName: params.commentAuthorName || undefined, + authorEmail: params.commentAuthorEmail || undefined, + authorUrl: params.commentAuthorUrl || undefined, } case 'wordpress_list_comments': return { @@ -873,7 +1019,10 @@ export const WordPressBlock: BlockConfig = { ...baseParams, name: params.categoryName, description: params.categoryDescription, - parent: params.categoryParent ? Number(params.categoryParent) : undefined, + parent: + params.categoryParent !== undefined && params.categoryParent !== '' + ? Number(params.categoryParent) + : undefined, slug: params.categorySlug, } case 'wordpress_list_categories': @@ -884,6 +1033,28 @@ export const WordPressBlock: BlockConfig = { search: params.search, order: params.order, } + case 'wordpress_get_category': + return { + ...baseParams, + categoryId: Number(params.categoryId), + } + case 'wordpress_update_category': + return { + ...baseParams, + categoryId: Number(params.categoryId), + name: params.categoryName, + description: params.categoryDescription, + parent: + params.categoryParent !== undefined && params.categoryParent !== '' + ? Number(params.categoryParent) + : undefined, + slug: params.categorySlug, + } + case 'wordpress_delete_category': + return { + ...baseParams, + categoryId: Number(params.categoryId), + } case 'wordpress_create_tag': return { ...baseParams, @@ -899,6 +1070,24 @@ export const WordPressBlock: BlockConfig = { search: params.search, order: params.order, } + case 'wordpress_get_tag': + return { + ...baseParams, + tagId: Number(params.tagId), + } + case 'wordpress_update_tag': + return { + ...baseParams, + tagId: Number(params.tagId), + name: params.tagName, + description: params.tagDescription, + slug: params.tagSlug, + } + case 'wordpress_delete_tag': + return { + ...baseParams, + tagId: Number(params.tagId), + } case 'wordpress_get_current_user': return baseParams case 'wordpress_list_users': @@ -921,7 +1110,7 @@ export const WordPressBlock: BlockConfig = { query: params.query, perPage: params.perPage ? Number(params.perPage) : undefined, page: params.page ? Number(params.page) : undefined, - type: params.searchType || undefined, + subtype: params.searchType || undefined, } default: return baseParams @@ -942,6 +1131,7 @@ export const WordPressBlock: BlockConfig = { slug: { type: 'string', description: 'URL slug' }, categories: { type: 'string', description: 'Category IDs (comma-separated)' }, tags: { type: 'string', description: 'Tag IDs (comma-separated)' }, + listAuthor: { type: 'number', description: 'Filter posts by author ID' }, featuredMedia: { type: 'number', description: 'Featured media ID' }, // Page inputs pageId: { type: 'number', description: 'Page ID' }, @@ -953,19 +1143,26 @@ export const WordPressBlock: BlockConfig = { mediaTitle: { type: 'string', description: 'Media title' }, caption: { type: 'string', description: 'Media caption' }, altText: { type: 'string', description: 'Alt text' }, + mediaDescription: { type: 'string', description: 'Media description' }, mediaId: { type: 'number', description: 'Media ID' }, mediaType: { type: 'string', description: 'Media type filter' }, // Comment inputs commentPostId: { type: 'number', description: 'Post ID for comment' }, commentContent: { type: 'string', description: 'Comment content' }, + commentParent: { type: 'number', description: 'Parent comment ID for replies' }, + commentAuthorName: { type: 'string', description: 'Comment author display name' }, + commentAuthorEmail: { type: 'string', description: 'Comment author email' }, + commentAuthorUrl: { type: 'string', description: 'Comment author URL' }, commentId: { type: 'number', description: 'Comment ID' }, commentStatus: { type: 'string', description: 'Comment status' }, // Category inputs + categoryId: { type: 'number', description: 'Category ID' }, categoryName: { type: 'string', description: 'Category name' }, categoryDescription: { type: 'string', description: 'Category description' }, categoryParent: { type: 'number', description: 'Parent category ID' }, categorySlug: { type: 'string', description: 'Category slug' }, // Tag inputs + tagId: { type: 'number', description: 'Tag ID' }, tagName: { type: 'string', description: 'Tag name' }, tagDescription: { type: 'string', description: 'Tag description' }, tagSlug: { type: 'string', description: 'Tag slug' }, @@ -974,7 +1171,10 @@ export const WordPressBlock: BlockConfig = { roles: { type: 'string', description: 'User roles filter' }, // Search inputs query: { type: 'string', description: 'Search query' }, - searchType: { type: 'string', description: 'Content type filter' }, + searchType: { + type: 'string', + description: 'Content subtype filter (post, page) — maps to the API subtype param', + }, // List inputs perPage: { type: 'number', description: 'Results per page' }, page: { type: 'number', description: 'Page number' }, @@ -983,7 +1183,6 @@ export const WordPressBlock: BlockConfig = { order: { type: 'string', description: 'Order direction' }, listStatus: { type: 'string', description: 'Status filter' }, force: { type: 'boolean', description: 'Force delete' }, - hideEmpty: { type: 'boolean', description: 'Hide empty taxonomies' }, }, outputs: { // Post outputs @@ -1114,5 +1313,19 @@ export const WordPressBlockMeta = { content: '# Moderate WordPress Comments\n\nKeep the comment queue clean and on-policy.\n\n## Steps\n1. List comments, optionally filtering by status such as hold.\n2. For each comment, judge it against the moderation policy: legitimate, spam, or abusive.\n3. Update each comment to the right status: approved, hold, spam, or trash.\n\n## Output\nReturn a summary of how many comments were approved, held, marked spam, or trashed, with the comment IDs grouped by action taken.', }, + { + name: 'organize-taxonomy', + description: + 'Clean up WordPress categories and tags: rename, re-slug, re-parent, or remove unused ones.', + content: + '# Organize WordPress Taxonomy\n\nKeep categories and tags tidy and consistent.\n\n## Steps\n1. List existing categories and tags to see the current taxonomy, including each item post count.\n2. Decide the target structure: rename to a consistent style, fix slugs, set parents for hierarchy, or remove duplicates and empties.\n3. For renames or re-parenting, update the category or tag by ID with the new name, slug, or parent.\n4. To remove one, get it first to confirm it is the right term and low-usage, then delete it (deletion is permanent for terms).\n\n## Output\nReport what changed for each term: renamed, re-slugged, re-parented, or deleted, with the term IDs. Note any term skipped because it still had many posts.', + }, + { + name: 'audit-site-content', + description: + 'Inventory WordPress content by searching and listing posts and pages to find gaps or issues.', + content: + '# Audit WordPress Content\n\nBuild an inventory of what is on the site.\n\n## Steps\n1. Use search across content, or list posts and pages with filters such as status and date order.\n2. Page through results using the total and totalPages counts so nothing is missed.\n3. Group findings by status, category, or age to spot drafts left unpublished, stale posts, or thin content.\n\n## Output\nReturn a structured inventory: counts by status and type, plus a list of flagged items (IDs, titles, and URLs) that need attention.', + }, ], } as const satisfies BlockMeta diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index 50c24a36e4b..c13f19830a1 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -4163,14 +4163,18 @@ import { wordpressCreatePageTool, wordpressCreatePostTool, wordpressCreateTagTool, + wordpressDeleteCategoryTool, wordpressDeleteCommentTool, wordpressDeleteMediaTool, wordpressDeletePageTool, wordpressDeletePostTool, + wordpressDeleteTagTool, + wordpressGetCategoryTool, wordpressGetCurrentUserTool, wordpressGetMediaTool, wordpressGetPageTool, wordpressGetPostTool, + wordpressGetTagTool, wordpressGetUserTool, wordpressListCategoriesTool, wordpressListCommentsTool, @@ -4180,9 +4184,11 @@ import { wordpressListTagsTool, wordpressListUsersTool, wordpressSearchContentTool, + wordpressUpdateCategoryTool, wordpressUpdateCommentTool, wordpressUpdatePageTool, wordpressUpdatePostTool, + wordpressUpdateTagTool, wordpressUploadMediaTool, } from '@/tools/wordpress' import { @@ -7490,8 +7496,14 @@ export const tools: Record = { wordpress_delete_comment: wordpressDeleteCommentTool, wordpress_create_category: wordpressCreateCategoryTool, wordpress_list_categories: wordpressListCategoriesTool, + wordpress_get_category: wordpressGetCategoryTool, + wordpress_update_category: wordpressUpdateCategoryTool, + wordpress_delete_category: wordpressDeleteCategoryTool, wordpress_create_tag: wordpressCreateTagTool, wordpress_list_tags: wordpressListTagsTool, + wordpress_get_tag: wordpressGetTagTool, + wordpress_update_tag: wordpressUpdateTagTool, + wordpress_delete_tag: wordpressDeleteTagTool, wordpress_get_current_user: wordpressGetCurrentUserTool, wordpress_list_users: wordpressListUsersTool, wordpress_get_user: wordpressGetUserTool, diff --git a/apps/sim/tools/wordpress/create_category.ts b/apps/sim/tools/wordpress/create_category.ts index 61bb4076a34..f787b60b186 100644 --- a/apps/sim/tools/wordpress/create_category.ts +++ b/apps/sim/tools/wordpress/create_category.ts @@ -66,7 +66,7 @@ export const createCategoryTool: ToolConfig< } if (params.description) body.description = params.description - if (params.parent) body.parent = params.parent + if (params.parent !== undefined) body.parent = params.parent if (params.slug) body.slug = params.slug return body diff --git a/apps/sim/tools/wordpress/create_comment.ts b/apps/sim/tools/wordpress/create_comment.ts index d4f473105f1..dd6fb5898b1 100644 --- a/apps/sim/tools/wordpress/create_comment.ts +++ b/apps/sim/tools/wordpress/create_comment.ts @@ -78,7 +78,7 @@ export const createCommentTool: ToolConfig< content: params.content, } - if (params.parent) body.parent = params.parent + if (params.parent !== undefined) body.parent = params.parent if (params.authorName) body.author_name = params.authorName if (params.authorEmail) body.author_email = params.authorEmail if (params.authorUrl) body.author_url = params.authorUrl diff --git a/apps/sim/tools/wordpress/create_page.ts b/apps/sim/tools/wordpress/create_page.ts index 161cd10fb8d..2a7f7658f74 100644 --- a/apps/sim/tools/wordpress/create_page.ts +++ b/apps/sim/tools/wordpress/create_page.ts @@ -90,9 +90,9 @@ export const createPageTool: ToolConfig = { + id: 'wordpress_delete_category', + name: 'WordPress Delete Category', + description: 'Delete a category from WordPress.com', + version: '1.0.0', + + oauth: { + required: true, + provider: 'wordpress', + requiredScopes: ['global'], + }, + + params: { + siteId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'WordPress.com site ID or domain (e.g., 12345678 or mysite.wordpress.com)', + }, + categoryId: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: 'The ID of the category to delete', + }, + }, + + request: { + url: (params) => { + // Terms do not support trashing, so force=true is required to delete. + return `${WORDPRESS_COM_API_BASE}/${params.siteId}/categories/${params.categoryId}?force=true` + }, + method: 'DELETE', + headers: (params) => ({ + 'Content-Type': 'application/json', + Authorization: `Bearer ${params.accessToken}`, + }), + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + const error = await response.json().catch(() => ({})) + throw new Error(error.message || `WordPress API error: ${response.status}`) + } + + const data = await response.json() + + return { + success: true, + output: { + deleted: data.deleted ?? true, + category: { + id: data.id ?? data.previous?.id, + count: data.count ?? data.previous?.count, + description: data.description || data.previous?.description, + link: data.link || data.previous?.link, + name: data.name || data.previous?.name, + slug: data.slug || data.previous?.slug, + taxonomy: data.taxonomy || data.previous?.taxonomy, + parent: data.parent ?? data.previous?.parent, + }, + }, + } + }, + + outputs: { + deleted: { + type: 'boolean', + description: 'Whether the category was deleted', + }, + category: { + type: 'object', + description: 'The deleted category', + properties: { + id: { type: 'number', description: 'Category ID' }, + count: { type: 'number', description: 'Number of posts in this category' }, + description: { type: 'string', description: 'Category description' }, + link: { type: 'string', description: 'Category archive URL' }, + name: { type: 'string', description: 'Category name' }, + slug: { type: 'string', description: 'Category slug' }, + taxonomy: { type: 'string', description: 'Taxonomy name' }, + parent: { type: 'number', description: 'Parent category ID' }, + }, + }, + }, +} diff --git a/apps/sim/tools/wordpress/delete_comment.ts b/apps/sim/tools/wordpress/delete_comment.ts index 5aec306e5d8..03d4b6081df 100644 --- a/apps/sim/tools/wordpress/delete_comment.ts +++ b/apps/sim/tools/wordpress/delete_comment.ts @@ -64,12 +64,12 @@ export const deleteCommentTool: ToolConfig< return { success: true, output: { - deleted: data.deleted || true, + deleted: data.deleted ?? true, comment: { - id: data.id || data.previous?.id, - post: data.post || data.previous?.post, - parent: data.parent || data.previous?.parent, - author: data.author || data.previous?.author, + id: data.id ?? data.previous?.id, + post: data.post ?? data.previous?.post, + parent: data.parent ?? data.previous?.parent, + author: data.author ?? data.previous?.author, author_name: data.author_name || data.previous?.author_name, author_email: data.author_email || data.previous?.author_email, author_url: data.author_url || data.previous?.author_url, diff --git a/apps/sim/tools/wordpress/delete_media.ts b/apps/sim/tools/wordpress/delete_media.ts index 3b680919e3d..0dfc8e0b1de 100644 --- a/apps/sim/tools/wordpress/delete_media.ts +++ b/apps/sim/tools/wordpress/delete_media.ts @@ -31,17 +31,11 @@ export const deleteMediaTool: ToolConfig { - // Media deletion requires force=true to actually delete + // Media has no trash — deletion always requires force=true to take effect return `${WORDPRESS_COM_API_BASE}/${params.siteId}/media/${params.mediaId}?force=true` }, method: 'DELETE', @@ -62,9 +56,9 @@ export const deleteMediaTool: ToolConfig = { + id: 'wordpress_delete_tag', + name: 'WordPress Delete Tag', + description: 'Delete a tag from WordPress.com', + version: '1.0.0', + + oauth: { + required: true, + provider: 'wordpress', + requiredScopes: ['global'], + }, + + params: { + siteId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'WordPress.com site ID or domain (e.g., 12345678 or mysite.wordpress.com)', + }, + tagId: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: 'The ID of the tag to delete', + }, + }, + + request: { + url: (params) => { + // Terms do not support trashing, so force=true is required to delete. + return `${WORDPRESS_COM_API_BASE}/${params.siteId}/tags/${params.tagId}?force=true` + }, + method: 'DELETE', + headers: (params) => ({ + 'Content-Type': 'application/json', + Authorization: `Bearer ${params.accessToken}`, + }), + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + const error = await response.json().catch(() => ({})) + throw new Error(error.message || `WordPress API error: ${response.status}`) + } + + const data = await response.json() + + return { + success: true, + output: { + deleted: data.deleted ?? true, + tag: { + id: data.id ?? data.previous?.id, + count: data.count ?? data.previous?.count, + description: data.description || data.previous?.description, + link: data.link || data.previous?.link, + name: data.name || data.previous?.name, + slug: data.slug || data.previous?.slug, + taxonomy: data.taxonomy || data.previous?.taxonomy, + }, + }, + } + }, + + outputs: { + deleted: { + type: 'boolean', + description: 'Whether the tag was deleted', + }, + tag: { + type: 'object', + description: 'The deleted tag', + properties: { + id: { type: 'number', description: 'Tag ID' }, + count: { type: 'number', description: 'Number of posts with this tag' }, + description: { type: 'string', description: 'Tag description' }, + link: { type: 'string', description: 'Tag archive URL' }, + name: { type: 'string', description: 'Tag name' }, + slug: { type: 'string', description: 'Tag slug' }, + taxonomy: { type: 'string', description: 'Taxonomy name' }, + }, + }, + }, +} diff --git a/apps/sim/tools/wordpress/get_category.ts b/apps/sim/tools/wordpress/get_category.ts new file mode 100644 index 00000000000..968dafe7fbc --- /dev/null +++ b/apps/sim/tools/wordpress/get_category.ts @@ -0,0 +1,86 @@ +import type { ToolConfig } from '@/tools/types' +import { + WORDPRESS_COM_API_BASE, + type WordPressGetCategoryParams, + type WordPressGetCategoryResponse, +} from '@/tools/wordpress/types' + +export const getCategoryTool: ToolConfig = + { + id: 'wordpress_get_category', + name: 'WordPress Get Category', + description: 'Get a single category from WordPress.com by ID', + version: '1.0.0', + + oauth: { + required: true, + provider: 'wordpress', + requiredScopes: ['global'], + }, + + params: { + siteId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'WordPress.com site ID or domain (e.g., 12345678 or mysite.wordpress.com)', + }, + categoryId: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: 'The ID of the category to retrieve', + }, + }, + + request: { + url: (params) => `${WORDPRESS_COM_API_BASE}/${params.siteId}/categories/${params.categoryId}`, + method: 'GET', + headers: (params) => ({ + 'Content-Type': 'application/json', + Authorization: `Bearer ${params.accessToken}`, + }), + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + const error = await response.json().catch(() => ({})) + throw new Error(error.message || `WordPress API error: ${response.status}`) + } + + const data = await response.json() + + return { + success: true, + output: { + category: { + id: data.id, + count: data.count, + description: data.description, + link: data.link, + name: data.name, + slug: data.slug, + taxonomy: data.taxonomy, + parent: data.parent, + }, + }, + } + }, + + outputs: { + category: { + type: 'object', + description: 'The retrieved category', + properties: { + id: { type: 'number', description: 'Category ID' }, + count: { type: 'number', description: 'Number of posts in this category' }, + description: { type: 'string', description: 'Category description' }, + link: { type: 'string', description: 'Category archive URL' }, + name: { type: 'string', description: 'Category name' }, + slug: { type: 'string', description: 'Category slug' }, + taxonomy: { type: 'string', description: 'Taxonomy name' }, + parent: { type: 'number', description: 'Parent category ID' }, + }, + }, + }, + } diff --git a/apps/sim/tools/wordpress/get_tag.ts b/apps/sim/tools/wordpress/get_tag.ts new file mode 100644 index 00000000000..bc4645c4fcd --- /dev/null +++ b/apps/sim/tools/wordpress/get_tag.ts @@ -0,0 +1,83 @@ +import type { ToolConfig } from '@/tools/types' +import { + WORDPRESS_COM_API_BASE, + type WordPressGetTagParams, + type WordPressGetTagResponse, +} from '@/tools/wordpress/types' + +export const getTagTool: ToolConfig = { + id: 'wordpress_get_tag', + name: 'WordPress Get Tag', + description: 'Get a single tag from WordPress.com by ID', + version: '1.0.0', + + oauth: { + required: true, + provider: 'wordpress', + requiredScopes: ['global'], + }, + + params: { + siteId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'WordPress.com site ID or domain (e.g., 12345678 or mysite.wordpress.com)', + }, + tagId: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: 'The ID of the tag to retrieve', + }, + }, + + request: { + url: (params) => `${WORDPRESS_COM_API_BASE}/${params.siteId}/tags/${params.tagId}`, + method: 'GET', + headers: (params) => ({ + 'Content-Type': 'application/json', + Authorization: `Bearer ${params.accessToken}`, + }), + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + const error = await response.json().catch(() => ({})) + throw new Error(error.message || `WordPress API error: ${response.status}`) + } + + const data = await response.json() + + return { + success: true, + output: { + tag: { + id: data.id, + count: data.count, + description: data.description, + link: data.link, + name: data.name, + slug: data.slug, + taxonomy: data.taxonomy, + }, + }, + } + }, + + outputs: { + tag: { + type: 'object', + description: 'The retrieved tag', + properties: { + id: { type: 'number', description: 'Tag ID' }, + count: { type: 'number', description: 'Number of posts with this tag' }, + description: { type: 'string', description: 'Tag description' }, + link: { type: 'string', description: 'Tag archive URL' }, + name: { type: 'string', description: 'Tag name' }, + slug: { type: 'string', description: 'Tag slug' }, + taxonomy: { type: 'string', description: 'Taxonomy name' }, + }, + }, + }, +} diff --git a/apps/sim/tools/wordpress/index.ts b/apps/sim/tools/wordpress/index.ts index 3889208a544..ab48592b46d 100644 --- a/apps/sim/tools/wordpress/index.ts +++ b/apps/sim/tools/wordpress/index.ts @@ -4,14 +4,18 @@ import { createCommentTool } from '@/tools/wordpress/create_comment' import { createPageTool } from '@/tools/wordpress/create_page' import { createPostTool } from '@/tools/wordpress/create_post' import { createTagTool } from '@/tools/wordpress/create_tag' +import { deleteCategoryTool } from '@/tools/wordpress/delete_category' import { deleteCommentTool } from '@/tools/wordpress/delete_comment' import { deleteMediaTool } from '@/tools/wordpress/delete_media' import { deletePageTool } from '@/tools/wordpress/delete_page' import { deletePostTool } from '@/tools/wordpress/delete_post' +import { deleteTagTool } from '@/tools/wordpress/delete_tag' +import { getCategoryTool } from '@/tools/wordpress/get_category' import { getCurrentUserTool } from '@/tools/wordpress/get_current_user' import { getMediaTool } from '@/tools/wordpress/get_media' import { getPageTool } from '@/tools/wordpress/get_page' import { getPostTool } from '@/tools/wordpress/get_post' +import { getTagTool } from '@/tools/wordpress/get_tag' import { getUserTool } from '@/tools/wordpress/get_user' import { listCategoriesTool } from '@/tools/wordpress/list_categories' import { listCommentsTool } from '@/tools/wordpress/list_comments' @@ -21,9 +25,11 @@ import { listPostsTool } from '@/tools/wordpress/list_posts' import { listTagsTool } from '@/tools/wordpress/list_tags' import { listUsersTool } from '@/tools/wordpress/list_users' import { searchContentTool } from '@/tools/wordpress/search_content' +import { updateCategoryTool } from '@/tools/wordpress/update_category' import { updateCommentTool } from '@/tools/wordpress/update_comment' import { updatePageTool } from '@/tools/wordpress/update_page' import { updatePostTool } from '@/tools/wordpress/update_post' +import { updateTagTool } from '@/tools/wordpress/update_tag' import { uploadMediaTool } from '@/tools/wordpress/upload_media' // Post operations @@ -55,10 +61,16 @@ export const wordpressDeleteCommentTool = deleteCommentTool // Category operations export const wordpressCreateCategoryTool = createCategoryTool export const wordpressListCategoriesTool = listCategoriesTool +export const wordpressGetCategoryTool = getCategoryTool +export const wordpressUpdateCategoryTool = updateCategoryTool +export const wordpressDeleteCategoryTool = deleteCategoryTool // Tag operations export const wordpressCreateTagTool = createTagTool export const wordpressListTagsTool = listTagsTool +export const wordpressGetTagTool = getTagTool +export const wordpressUpdateTagTool = updateTagTool +export const wordpressDeleteTagTool = deleteTagTool // User operations export const wordpressGetCurrentUserTool = getCurrentUserTool diff --git a/apps/sim/tools/wordpress/search_content.ts b/apps/sim/tools/wordpress/search_content.ts index 2d20340810c..706b510991b 100644 --- a/apps/sim/tools/wordpress/search_content.ts +++ b/apps/sim/tools/wordpress/search_content.ts @@ -36,26 +36,27 @@ export const searchContentTool: ToolConfig< perPage: { type: 'number', required: false, - visibility: 'user-only', + visibility: 'user-or-llm', description: 'Number of results per request (default: 10, max: 100)', }, page: { type: 'number', required: false, - visibility: 'user-only', + visibility: 'user-or-llm', description: 'Page number for pagination', }, type: { type: 'string', required: false, visibility: 'user-only', - description: 'Filter by content type: post, page, attachment', + description: 'Filter by search index type: post, term, or post-format', }, subtype: { type: 'string', required: false, visibility: 'user-only', - description: 'Filter by post type slug (e.g., post, page)', + description: + 'Filter by subtype within the selected type (e.g., post or page when type is post)', }, }, @@ -114,8 +115,11 @@ export const searchContentTool: ToolConfig< id: { type: 'number', description: 'Content ID' }, title: { type: 'string', description: 'Content title' }, url: { type: 'string', description: 'Content URL' }, - type: { type: 'string', description: 'Content type (post, page, attachment)' }, - subtype: { type: 'string', description: 'Post type slug' }, + type: { type: 'string', description: 'Content type (post, term, or post-format)' }, + subtype: { + type: 'string', + description: 'Subtype within the content type (e.g., post, page)', + }, }, }, }, diff --git a/apps/sim/tools/wordpress/types.ts b/apps/sim/tools/wordpress/types.ts index 81bc115e414..146b72905c1 100644 --- a/apps/sim/tools/wordpress/types.ts +++ b/apps/sim/tools/wordpress/types.ts @@ -325,7 +325,6 @@ export interface WordPressListMediaResponse extends ToolResponse { // Delete Media export interface WordPressDeleteMediaParams extends WordPressBaseParams { mediaId: number - force?: boolean } export interface WordPressDeleteMediaResponse extends ToolResponse { @@ -472,6 +471,44 @@ export interface WordPressListCategoriesResponse extends ToolResponse { } } +// Get Category +export interface WordPressGetCategoryParams extends WordPressBaseParams { + categoryId: number +} + +export interface WordPressGetCategoryResponse extends ToolResponse { + output: { + category: WordPressCategory + } +} + +// Update Category +export interface WordPressUpdateCategoryParams extends WordPressBaseParams { + categoryId: number + name?: string + description?: string + parent?: number + slug?: string +} + +export interface WordPressUpdateCategoryResponse extends ToolResponse { + output: { + category: WordPressCategory + } +} + +// Delete Category +export interface WordPressDeleteCategoryParams extends WordPressBaseParams { + categoryId: number +} + +export interface WordPressDeleteCategoryResponse extends ToolResponse { + output: { + deleted: boolean + category: WordPressCategory + } +} + // Create Tag export interface WordPressCreateTagParams extends WordPressBaseParams { name: string @@ -511,6 +548,43 @@ export interface WordPressListTagsResponse extends ToolResponse { } } +// Get Tag +export interface WordPressGetTagParams extends WordPressBaseParams { + tagId: number +} + +export interface WordPressGetTagResponse extends ToolResponse { + output: { + tag: WordPressTag + } +} + +// Update Tag +export interface WordPressUpdateTagParams extends WordPressBaseParams { + tagId: number + name?: string + description?: string + slug?: string +} + +export interface WordPressUpdateTagResponse extends ToolResponse { + output: { + tag: WordPressTag + } +} + +// Delete Tag +export interface WordPressDeleteTagParams extends WordPressBaseParams { + tagId: number +} + +export interface WordPressDeleteTagResponse extends ToolResponse { + output: { + deleted: boolean + tag: WordPressTag + } +} + // ============================================ // USER OPERATIONS // ============================================ @@ -576,7 +650,7 @@ export interface WordPressSearchContentParams extends WordPressBaseParams { query: string perPage?: number page?: number - type?: 'post' | 'page' | 'attachment' + type?: 'post' | 'term' | 'post-format' subtype?: string } @@ -620,8 +694,14 @@ export type WordPressResponse = | WordPressDeleteCommentResponse | WordPressCreateCategoryResponse | WordPressListCategoriesResponse + | WordPressGetCategoryResponse + | WordPressUpdateCategoryResponse + | WordPressDeleteCategoryResponse | WordPressCreateTagResponse | WordPressListTagsResponse + | WordPressGetTagResponse + | WordPressUpdateTagResponse + | WordPressDeleteTagResponse | WordPressGetCurrentUserResponse | WordPressListUsersResponse | WordPressGetUserResponse diff --git a/apps/sim/tools/wordpress/update_category.ts b/apps/sim/tools/wordpress/update_category.ts new file mode 100644 index 00000000000..cbb47510c21 --- /dev/null +++ b/apps/sim/tools/wordpress/update_category.ts @@ -0,0 +1,122 @@ +import type { ToolConfig } from '@/tools/types' +import { + WORDPRESS_COM_API_BASE, + type WordPressUpdateCategoryParams, + type WordPressUpdateCategoryResponse, +} from '@/tools/wordpress/types' + +export const updateCategoryTool: ToolConfig< + WordPressUpdateCategoryParams, + WordPressUpdateCategoryResponse +> = { + id: 'wordpress_update_category', + name: 'WordPress Update Category', + description: 'Update an existing category in WordPress.com', + version: '1.0.0', + + oauth: { + required: true, + provider: 'wordpress', + requiredScopes: ['global'], + }, + + params: { + siteId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'WordPress.com site ID or domain (e.g., 12345678 or mysite.wordpress.com)', + }, + categoryId: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: 'The ID of the category to update', + }, + name: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Category name', + }, + description: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Category description', + }, + parent: { + type: 'number', + required: false, + visibility: 'user-only', + description: 'Parent category ID for hierarchical categories', + }, + slug: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'URL slug for the category', + }, + }, + + request: { + url: (params) => `${WORDPRESS_COM_API_BASE}/${params.siteId}/categories/${params.categoryId}`, + method: 'POST', + headers: (params) => ({ + 'Content-Type': 'application/json', + Authorization: `Bearer ${params.accessToken}`, + }), + body: (params) => { + const body: Record = {} + + if (params.name) body.name = params.name + if (params.description) body.description = params.description + if (params.parent !== undefined) body.parent = params.parent + if (params.slug) body.slug = params.slug + + return body + }, + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + const error = await response.json().catch(() => ({})) + throw new Error(error.message || `WordPress API error: ${response.status}`) + } + + const data = await response.json() + + return { + success: true, + output: { + category: { + id: data.id, + count: data.count, + description: data.description, + link: data.link, + name: data.name, + slug: data.slug, + taxonomy: data.taxonomy, + parent: data.parent, + }, + }, + } + }, + + outputs: { + category: { + type: 'object', + description: 'The updated category', + properties: { + id: { type: 'number', description: 'Category ID' }, + count: { type: 'number', description: 'Number of posts in this category' }, + description: { type: 'string', description: 'Category description' }, + link: { type: 'string', description: 'Category archive URL' }, + name: { type: 'string', description: 'Category name' }, + slug: { type: 'string', description: 'Category slug' }, + taxonomy: { type: 'string', description: 'Taxonomy name' }, + parent: { type: 'number', description: 'Parent category ID' }, + }, + }, + }, +} diff --git a/apps/sim/tools/wordpress/update_page.ts b/apps/sim/tools/wordpress/update_page.ts index 37d3dc1d0fb..0c7fe51b79b 100644 --- a/apps/sim/tools/wordpress/update_page.ts +++ b/apps/sim/tools/wordpress/update_page.ts @@ -97,7 +97,7 @@ export const updatePageTool: ToolConfig = { + id: 'wordpress_update_tag', + name: 'WordPress Update Tag', + description: 'Update an existing tag in WordPress.com', + version: '1.0.0', + + oauth: { + required: true, + provider: 'wordpress', + requiredScopes: ['global'], + }, + + params: { + siteId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'WordPress.com site ID or domain (e.g., 12345678 or mysite.wordpress.com)', + }, + tagId: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: 'The ID of the tag to update', + }, + name: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Tag name', + }, + description: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Tag description', + }, + slug: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'URL slug for the tag', + }, + }, + + request: { + url: (params) => `${WORDPRESS_COM_API_BASE}/${params.siteId}/tags/${params.tagId}`, + method: 'POST', + headers: (params) => ({ + 'Content-Type': 'application/json', + Authorization: `Bearer ${params.accessToken}`, + }), + body: (params) => { + const body: Record = {} + + if (params.name) body.name = params.name + if (params.description) body.description = params.description + if (params.slug) body.slug = params.slug + + return body + }, + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + const error = await response.json().catch(() => ({})) + throw new Error(error.message || `WordPress API error: ${response.status}`) + } + + const data = await response.json() + + return { + success: true, + output: { + tag: { + id: data.id, + count: data.count, + description: data.description, + link: data.link, + name: data.name, + slug: data.slug, + taxonomy: data.taxonomy, + }, + }, + } + }, + + outputs: { + tag: { + type: 'object', + description: 'The updated tag', + properties: { + id: { type: 'number', description: 'Tag ID' }, + count: { type: 'number', description: 'Number of posts with this tag' }, + description: { type: 'string', description: 'Tag description' }, + link: { type: 'string', description: 'Tag archive URL' }, + name: { type: 'string', description: 'Tag name' }, + slug: { type: 'string', description: 'Tag slug' }, + taxonomy: { type: 'string', description: 'Taxonomy name' }, + }, + }, + }, +} From 4988ba621ce15280a798b5b681a5eb1c500af863 Mon Sep 17 00:00:00 2001 From: Waleed Date: Thu, 2 Jul 2026 10:55:42 -0700 Subject: [PATCH 18/28] feat(trello): expand tool coverage and fix API gaps (#5357) * feat(trello): expand tool coverage and fix API gaps - add delete card, remove label/member, update list, add/update checklist item, list members, and search tools - add filter support to list lists/cards, since/before paging to get actions, member assignment on card creation - promote move-to-list field out of advanced mode * fix(trello): address review feedback on checklist item tooling - idChecklist can be absent on Trello checkItem responses, so stop treating it as required - validate state/name is provided before building the update-checklist-item request URL - reject Update Checklist Item at the block level when neither State nor New Item Name is set - add missing idOrganization field to search's board output schema --- apps/sim/blocks/blocks/trello.ts | 532 +++++++++++++++++- apps/sim/tools/registry.ts | 16 + apps/sim/tools/trello/add_checklist_item.ts | 148 +++++ apps/sim/tools/trello/create_card.ts | 11 + apps/sim/tools/trello/delete_card.ts | 85 +++ apps/sim/tools/trello/get_actions.ts | 22 + apps/sim/tools/trello/index.ts | 16 + apps/sim/tools/trello/list_cards.ts | 10 + apps/sim/tools/trello/list_lists.ts | 10 + apps/sim/tools/trello/list_members.ts | 134 +++++ apps/sim/tools/trello/remove_label.ts | 97 ++++ apps/sim/tools/trello/remove_member.ts | 99 ++++ apps/sim/tools/trello/search.ts | 195 +++++++ apps/sim/tools/trello/shared.ts | 17 +- apps/sim/tools/trello/types.ts | 135 +++++ .../sim/tools/trello/update_checklist_item.ts | 150 +++++ apps/sim/tools/trello/update_list.ts | 150 +++++ 17 files changed, 1812 insertions(+), 15 deletions(-) create mode 100644 apps/sim/tools/trello/add_checklist_item.ts create mode 100644 apps/sim/tools/trello/delete_card.ts create mode 100644 apps/sim/tools/trello/list_members.ts create mode 100644 apps/sim/tools/trello/remove_label.ts create mode 100644 apps/sim/tools/trello/remove_member.ts create mode 100644 apps/sim/tools/trello/search.ts create mode 100644 apps/sim/tools/trello/update_checklist_item.ts create mode 100644 apps/sim/tools/trello/update_list.ts diff --git a/apps/sim/blocks/blocks/trello.ts b/apps/sim/blocks/blocks/trello.ts index 371ab9dcaa9..a9226ef3380 100644 --- a/apps/sim/blocks/blocks/trello.ts +++ b/apps/sim/blocks/blocks/trello.ts @@ -55,10 +55,10 @@ function parseStringArray(value: unknown): string[] | undefined { export const TrelloBlock: BlockConfig = { type: 'trello', name: 'Trello', - description: 'Manage Trello lists, cards, and activity', + description: 'Manage Trello lists, cards, checklists, and activity', authMode: AuthMode.OAuth, longDescription: - 'Integrate with Trello to list board lists, list cards, create cards, update cards, review activity, and add comments.', + 'Integrate with Trello to list, search, create, update, and delete cards and lists, manage checklists and checklist items, assign labels and members, review activity, and add comments.', docsLink: 'https://docs.sim.ai/integrations/trello', category: 'tools', integrationType: IntegrationType.Productivity, @@ -72,17 +72,25 @@ export const TrelloBlock: BlockConfig = { options: [ { label: 'Get Lists', id: 'trello_list_lists' }, { label: 'List Cards', id: 'trello_list_cards' }, + { label: 'Search', id: 'trello_search' }, { label: 'Create Card', id: 'trello_create_card' }, { label: 'Get Card', id: 'trello_get_card' }, { label: 'Update Card', id: 'trello_update_card' }, + { label: 'Delete Card', id: 'trello_delete_card' }, { label: 'Get Actions', id: 'trello_get_actions' }, { label: 'Add Comment', id: 'trello_add_comment' }, { label: 'Add Checklist', id: 'trello_add_checklist' }, + { label: 'Add Checklist Item', id: 'trello_add_checklist_item' }, + { label: 'Update Checklist Item', id: 'trello_update_checklist_item' }, { label: 'Add Label', id: 'trello_add_label' }, + { label: 'Remove Label', id: 'trello_remove_label' }, { label: 'Add Member', id: 'trello_add_member' }, + { label: 'Remove Member', id: 'trello_remove_member' }, + { label: 'List Members', id: 'trello_list_members' }, { label: 'Create Board', id: 'trello_create_board' }, { label: 'Get Board', id: 'trello_get_board' }, { label: 'Create List', id: 'trello_create_list' }, + { label: 'Update List', id: 'trello_update_list' }, ], value: () => 'trello_list_lists', }, @@ -124,11 +132,17 @@ export const TrelloBlock: BlockConfig = { 'trello_get_actions', 'trello_get_board', 'trello_create_list', + 'trello_list_members', ], }, required: { field: 'operation', - value: ['trello_list_lists', 'trello_get_board', 'trello_create_list'], + value: [ + 'trello_list_lists', + 'trello_get_board', + 'trello_create_list', + 'trello_list_members', + ], }, }, { @@ -147,11 +161,17 @@ export const TrelloBlock: BlockConfig = { 'trello_get_actions', 'trello_get_board', 'trello_create_list', + 'trello_list_members', ], }, required: { field: 'operation', - value: ['trello_list_lists', 'trello_get_board', 'trello_create_list'], + value: [ + 'trello_list_lists', + 'trello_get_board', + 'trello_create_list', + 'trello_list_members', + ], }, }, { @@ -161,11 +181,43 @@ export const TrelloBlock: BlockConfig = { placeholder: 'Enter Trello list ID', condition: { field: 'operation', - value: ['trello_list_cards', 'trello_create_card'], + value: ['trello_list_cards', 'trello_create_card', 'trello_update_list'], }, required: { field: 'operation', - value: 'trello_create_card', + value: ['trello_create_card', 'trello_update_list'], + }, + }, + { + id: 'listFilter', + title: 'List Filter', + type: 'dropdown', + options: [ + { label: 'Open (default)', id: '' }, + { label: 'Closed', id: 'closed' }, + { label: 'All', id: 'all' }, + ], + value: () => '', + mode: 'advanced', + condition: { + field: 'operation', + value: 'trello_list_lists', + }, + }, + { + id: 'cardFilter', + title: 'Card Filter', + type: 'dropdown', + options: [ + { label: 'Open (default)', id: '' }, + { label: 'Closed', id: 'closed' }, + { label: 'All', id: 'all' }, + ], + value: () => '', + mode: 'advanced', + condition: { + field: 'operation', + value: 'trello_list_cards', }, }, { @@ -177,23 +229,31 @@ export const TrelloBlock: BlockConfig = { field: 'operation', value: [ 'trello_update_card', + 'trello_delete_card', 'trello_get_actions', 'trello_add_comment', 'trello_get_card', 'trello_add_checklist', + 'trello_update_checklist_item', 'trello_add_label', + 'trello_remove_label', 'trello_add_member', + 'trello_remove_member', ], }, required: { field: 'operation', value: [ 'trello_update_card', + 'trello_delete_card', 'trello_add_comment', 'trello_get_card', 'trello_add_checklist', + 'trello_update_checklist_item', 'trello_add_label', + 'trello_remove_label', 'trello_add_member', + 'trello_remove_member', ], }, }, @@ -290,6 +350,23 @@ Return ONLY the date/timestamp string - no explanations, no extra text.`, placeholder: 'Describe the label IDs to include...', }, }, + { + id: 'memberIds', + title: 'Member IDs', + type: 'short-input', + placeholder: 'Comma-separated member IDs', + mode: 'advanced', + condition: { + field: 'operation', + value: 'trello_create_card', + }, + wandConfig: { + enabled: true, + prompt: + 'Generate a comma-separated list of Trello member IDs. Return ONLY the comma-separated values - no explanations, no extra text.', + placeholder: 'Describe the member IDs to assign...', + }, + }, { id: 'closed', title: 'Archive Status', @@ -311,7 +388,6 @@ Return ONLY the date/timestamp string - no explanations, no extra text.`, title: 'Move to List ID', type: 'short-input', placeholder: 'Enter Trello list ID', - mode: 'advanced', condition: { field: 'operation', value: 'trello_update_card', @@ -350,6 +426,52 @@ Return ONLY the date/timestamp string - no explanations, no extra text.`, value: 'trello_get_actions', }, }, + { + id: 'since', + title: 'Since', + type: 'short-input', + placeholder: 'ISO 8601 timestamp or action ID', + mode: 'advanced', + condition: { + field: 'operation', + value: 'trello_get_actions', + }, + wandConfig: { + enabled: true, + prompt: `Generate a date or timestamp based on the user's description. +The timestamp should be in ISO 8601 format: YYYY-MM-DD or YYYY-MM-DDTHH:MM:SSZ. +Examples: +- "yesterday" -> Calculate yesterday's date in YYYY-MM-DD format +- "1 week ago" -> Calculate the date 1 week ago in YYYY-MM-DD format + +Return ONLY the date/timestamp string - no explanations, no extra text.`, + placeholder: 'Describe the start of the range (e.g. "1 week ago")...', + generationType: 'timestamp', + }, + }, + { + id: 'before', + title: 'Before', + type: 'short-input', + placeholder: 'ISO 8601 timestamp or action ID', + mode: 'advanced', + condition: { + field: 'operation', + value: 'trello_get_actions', + }, + wandConfig: { + enabled: true, + prompt: `Generate a date or timestamp based on the user's description. +The timestamp should be in ISO 8601 format: YYYY-MM-DD or YYYY-MM-DDTHH:MM:SSZ. +Examples: +- "today" -> Calculate today's date in YYYY-MM-DD format +- "end of last month" -> Calculate the last day of the previous month + +Return ONLY the date/timestamp string - no explanations, no extra text.`, + placeholder: 'Describe the end of the range (e.g. "today")...', + generationType: 'timestamp', + }, + }, { id: 'text', title: 'Comment', @@ -415,10 +537,13 @@ Return ONLY the date/timestamp string - no explanations, no extra text.`, type: 'short-input', placeholder: 'Enter list name', condition: { + field: 'operation', + value: ['trello_create_list', 'trello_update_list'], + }, + required: { field: 'operation', value: 'trello_create_list', }, - required: true, }, { id: 'listPos', @@ -428,7 +553,34 @@ Return ONLY the date/timestamp string - no explanations, no extra text.`, mode: 'advanced', condition: { field: 'operation', - value: 'trello_create_list', + value: ['trello_create_list', 'trello_update_list'], + }, + }, + { + id: 'listClosed', + title: 'Archive Status', + type: 'dropdown', + options: [ + { label: 'Leave Unchanged', id: '' }, + { label: 'Archive List', id: 'true' }, + { label: 'Reopen List', id: 'false' }, + ], + value: () => '', + mode: 'advanced', + condition: { + field: 'operation', + value: 'trello_update_list', + }, + }, + { + id: 'moveListToBoardId', + title: 'Move to Board ID', + type: 'short-input', + placeholder: 'Enter Trello board ID', + mode: 'advanced', + condition: { + field: 'operation', + value: 'trello_update_list', }, }, { @@ -453,6 +605,91 @@ Return ONLY the date/timestamp string - no explanations, no extra text.`, value: 'trello_add_checklist', }, }, + { + id: 'checklistId', + title: 'Checklist ID', + type: 'short-input', + placeholder: 'Enter Trello checklist ID', + condition: { + field: 'operation', + value: 'trello_add_checklist_item', + }, + required: true, + }, + { + id: 'itemName', + title: 'Item Name', + type: 'short-input', + placeholder: 'Enter checklist item name', + condition: { + field: 'operation', + value: 'trello_add_checklist_item', + }, + required: true, + }, + { + id: 'itemPos', + title: 'Item Position', + type: 'short-input', + placeholder: 'top, bottom, or a positive float', + mode: 'advanced', + condition: { + field: 'operation', + value: 'trello_add_checklist_item', + }, + }, + { + id: 'itemChecked', + title: 'Start Checked', + type: 'dropdown', + options: [ + { label: 'Unchecked', id: '' }, + { label: 'Checked', id: 'true' }, + ], + value: () => '', + mode: 'advanced', + condition: { + field: 'operation', + value: 'trello_add_checklist_item', + }, + }, + { + id: 'checkItemId', + title: 'Checklist Item ID', + type: 'short-input', + placeholder: 'Enter checklist item ID', + condition: { + field: 'operation', + value: 'trello_update_checklist_item', + }, + required: true, + }, + { + id: 'checkItemState', + title: 'State', + type: 'dropdown', + options: [ + { label: 'Leave Unchanged', id: '' }, + { label: 'Complete', id: 'complete' }, + { label: 'Incomplete', id: 'incomplete' }, + ], + value: () => '', + condition: { + field: 'operation', + value: 'trello_update_checklist_item', + }, + }, + { + id: 'checkItemName', + title: 'New Item Name', + type: 'short-input', + placeholder: 'Enter new checklist item name', + mode: 'advanced', + condition: { + field: 'operation', + value: 'trello_update_checklist_item', + }, + }, { id: 'labelId', title: 'Label ID', @@ -460,7 +697,7 @@ Return ONLY the date/timestamp string - no explanations, no extra text.`, placeholder: 'Enter Trello label ID', condition: { field: 'operation', - value: 'trello_add_label', + value: ['trello_add_label', 'trello_remove_label'], }, required: true, }, @@ -471,26 +708,82 @@ Return ONLY the date/timestamp string - no explanations, no extra text.`, placeholder: 'Enter Trello member ID', condition: { field: 'operation', - value: 'trello_add_member', + value: ['trello_add_member', 'trello_remove_member'], }, required: true, }, + { + id: 'searchQuery', + title: 'Search Query', + type: 'long-input', + placeholder: 'Enter search text (supports Trello operators like board:, list:, due:)', + condition: { + field: 'operation', + value: 'trello_search', + }, + required: true, + }, + { + id: 'searchModelTypes', + title: 'Search Scope', + type: 'dropdown', + options: [ + { label: 'All', id: 'all' }, + { label: 'Cards Only', id: 'cards' }, + { label: 'Boards Only', id: 'boards' }, + ], + value: () => 'all', + condition: { + field: 'operation', + value: 'trello_search', + }, + }, + { + id: 'searchBoardIds', + title: 'Restrict to Board IDs', + type: 'short-input', + placeholder: 'Comma-separated board IDs', + mode: 'advanced', + condition: { + field: 'operation', + value: 'trello_search', + }, + }, + { + id: 'searchCardsLimit', + title: 'Card Result Limit', + type: 'short-input', + placeholder: 'Maximum number of cards to return (1-1000, default 10)', + mode: 'advanced', + condition: { + field: 'operation', + value: 'trello_search', + }, + }, ], tools: { access: [ 'trello_list_lists', 'trello_list_cards', + 'trello_search', 'trello_create_card', 'trello_update_card', + 'trello_delete_card', 'trello_get_actions', 'trello_add_comment', 'trello_create_board', 'trello_get_board', 'trello_create_list', + 'trello_update_list', 'trello_get_card', 'trello_add_checklist', + 'trello_add_checklist_item', + 'trello_update_checklist_item', 'trello_add_label', + 'trello_remove_label', 'trello_add_member', + 'trello_remove_member', + 'trello_list_members', ], config: { tool: (params) => getTrimmedString(params.operation) ?? 'trello_list_lists', @@ -511,6 +804,7 @@ Return ONLY the date/timestamp string - no explanations, no extra text.`, return { ...baseParams, boardId, + filter: getTrimmedString(params.listFilter), } } @@ -530,6 +824,23 @@ Return ONLY the date/timestamp string - no explanations, no extra text.`, ...baseParams, boardId, listId, + filter: getTrimmedString(params.cardFilter), + } + } + + case 'trello_search': { + const query = getTrimmedString(params.searchQuery) + + if (!query) { + throw new Error('Search query is required.') + } + + return { + ...baseParams, + query, + idBoards: parseStringArray(params.searchBoardIds), + modelTypes: getTrimmedString(params.searchModelTypes), + cardsLimit: parseOptionalNumberInput(params.searchCardsLimit, 'cardsLimit'), } } @@ -554,6 +865,7 @@ Return ONLY the date/timestamp string - no explanations, no extra text.`, due: getTrimmedString(params.due), dueComplete: parseOptionalBooleanInput(params.dueComplete), labelIds: parseStringArray(params.labelIds), + memberIds: parseStringArray(params.memberIds), } } @@ -576,6 +888,19 @@ Return ONLY the date/timestamp string - no explanations, no extra text.`, } } + case 'trello_delete_card': { + const cardId = getTrimmedString(params.cardId) + + if (!cardId) { + throw new Error('Card ID is required.') + } + + return { + ...baseParams, + cardId, + } + } + case 'trello_get_actions': { const boardId = getTrimmedString(params.boardId) const cardId = getTrimmedString(params.cardId) @@ -595,6 +920,8 @@ Return ONLY the date/timestamp string - no explanations, no extra text.`, filter: getTrimmedString(params.filter), limit: parseOptionalNumberInput(params.limit, 'limit'), page: parseOptionalNumberInput(params.page, 'page'), + since: getTrimmedString(params.since), + before: getTrimmedString(params.before), } } @@ -666,6 +993,23 @@ Return ONLY the date/timestamp string - no explanations, no extra text.`, } } + case 'trello_update_list': { + const listId = getTrimmedString(params.listId) + + if (!listId) { + throw new Error('List ID is required.') + } + + return { + ...baseParams, + listId, + name: getTrimmedString(params.listName), + closed: parseOptionalBooleanInput(params.listClosed), + idBoard: getTrimmedString(params.moveListToBoardId), + pos: getTrimmedString(params.listPos), + } + } + case 'trello_get_card': { const cardId = getTrimmedString(params.cardId) @@ -699,6 +1043,57 @@ Return ONLY the date/timestamp string - no explanations, no extra text.`, } } + case 'trello_add_checklist_item': { + const checklistId = getTrimmedString(params.checklistId) + const name = getTrimmedString(params.itemName) + + if (!checklistId) { + throw new Error('Checklist ID is required.') + } + + if (!name) { + throw new Error('Item name is required.') + } + + return { + ...baseParams, + checklistId, + name, + pos: getTrimmedString(params.itemPos), + checked: parseOptionalBooleanInput(params.itemChecked), + } + } + + case 'trello_update_checklist_item': { + const cardId = getTrimmedString(params.cardId) + const checkItemId = getTrimmedString(params.checkItemId) + + if (!cardId) { + throw new Error('Card ID is required.') + } + + if (!checkItemId) { + throw new Error('Checklist item ID is required.') + } + + const state = getTrimmedString(params.checkItemState) + const normalizedState = + state === 'complete' || state === 'incomplete' ? state : undefined + const name = getTrimmedString(params.checkItemName) + + if (!normalizedState && !name) { + throw new Error('Provide a State or a New Item Name to update.') + } + + return { + ...baseParams, + cardId, + checkItemId, + state: normalizedState, + name, + } + } + case 'trello_add_label': { const cardId = getTrimmedString(params.cardId) const labelId = getTrimmedString(params.labelId) @@ -718,6 +1113,25 @@ Return ONLY the date/timestamp string - no explanations, no extra text.`, } } + case 'trello_remove_label': { + const cardId = getTrimmedString(params.cardId) + const labelId = getTrimmedString(params.labelId) + + if (!cardId) { + throw new Error('Card ID is required.') + } + + if (!labelId) { + throw new Error('Label ID is required.') + } + + return { + ...baseParams, + cardId, + labelId, + } + } + case 'trello_add_member': { const cardId = getTrimmedString(params.cardId) const memberId = getTrimmedString(params.memberId) @@ -737,6 +1151,38 @@ Return ONLY the date/timestamp string - no explanations, no extra text.`, } } + case 'trello_remove_member': { + const cardId = getTrimmedString(params.cardId) + const memberId = getTrimmedString(params.memberId) + + if (!cardId) { + throw new Error('Card ID is required.') + } + + if (!memberId) { + throw new Error('Member ID is required.') + } + + return { + ...baseParams, + cardId, + memberId, + } + } + + case 'trello_list_members': { + const boardId = getTrimmedString(params.boardId) + + if (!boardId) { + throw new Error('Board ID is required.') + } + + return { + ...baseParams, + boardId, + } + } + default: return baseParams } @@ -758,11 +1204,17 @@ Return ONLY the date/timestamp string - no explanations, no extra text.`, type: 'json', description: 'Label IDs as an array or comma-separated string', }, + memberIds: { + type: 'json', + description: 'Member IDs as an array or comma-separated string, to assign on card creation', + }, closed: { type: 'boolean', description: 'Whether the card should be archived or reopened' }, idList: { type: 'string', description: 'List ID to move the card to' }, filter: { type: 'string', description: 'Trello action filter' }, limit: { type: 'number', description: 'Maximum number of board actions to return' }, page: { type: 'number', description: 'Page number for action results' }, + since: { type: 'string', description: 'Only return actions after this date or action ID' }, + before: { type: 'string', description: 'Only return actions before this date or action ID' }, text: { type: 'string', description: 'Comment text' }, boardName: { type: 'string', description: 'Board name' }, boardDesc: { type: 'string', description: 'Board description' }, @@ -776,13 +1228,34 @@ Return ONLY the date/timestamp string - no explanations, no extra text.`, }, listName: { type: 'string', description: 'List name' }, listPos: { type: 'string', description: 'List position (top, bottom, or positive float)' }, + listClosed: { type: 'boolean', description: 'Whether the list should be archived or reopened' }, + moveListToBoardId: { type: 'string', description: 'Board ID to move the list to' }, + listFilter: { type: 'string', description: 'Which lists to return: open, closed, or all' }, + cardFilter: { type: 'string', description: 'Which cards to return: open, closed, or all' }, checklistName: { type: 'string', description: 'Checklist name' }, checklistPos: { type: 'string', description: 'Checklist position (top, bottom, or positive float)', }, - labelId: { type: 'string', description: 'Label ID to attach to a card' }, - memberId: { type: 'string', description: 'Member ID to assign to a card' }, + checklistId: { type: 'string', description: 'Checklist ID to add an item to' }, + itemName: { type: 'string', description: 'Checklist item name' }, + itemPos: { + type: 'string', + description: 'Checklist item position (top, bottom, or positive float)', + }, + itemChecked: { type: 'boolean', description: 'Whether the checklist item starts checked' }, + checkItemId: { type: 'string', description: 'Checklist item ID to update' }, + checkItemState: { type: 'string', description: 'Checklist item state: complete or incomplete' }, + checkItemName: { type: 'string', description: 'New name for a checklist item' }, + labelId: { type: 'string', description: 'Label ID to attach to or remove from a card' }, + memberId: { type: 'string', description: 'Member ID to assign to or remove from a card' }, + searchQuery: { type: 'string', description: 'Trello search query text' }, + searchModelTypes: { type: 'string', description: 'Search scope: all, cards, or boards' }, + searchBoardIds: { + type: 'json', + description: 'Board IDs to restrict the search to, as an array or comma-separated string', + }, + searchCardsLimit: { type: 'number', description: 'Maximum number of cards to return' }, }, outputs: { lists: { @@ -811,6 +1284,10 @@ Return ONLY the date/timestamp string - no explanations, no extra text.`, type: 'json', description: 'Created checklist (id, name, idCard, idBoard, pos)', }, + item: { + type: 'json', + description: 'Created or updated checklist item (id, name, state, pos, idChecklist)', + }, labelIds: { type: 'json', description: 'Label IDs applied to a card after adding a label', @@ -819,6 +1296,14 @@ Return ONLY the date/timestamp string - no explanations, no extra text.`, type: 'json', description: 'Member IDs assigned to a card after adding a member', }, + members: { + type: 'json', + description: 'Board members (id, fullName, username)', + }, + boards: { + type: 'json', + description: 'Boards matching a search query (id, name, desc, url, closed, idOrganization)', + }, actions: { type: 'json', description: @@ -831,7 +1316,12 @@ Return ONLY the date/timestamp string - no explanations, no extra text.`, }, count: { type: 'number', - description: 'Number of returned lists, cards, or actions', + description: 'Number of returned lists, cards, boards, actions, or members', + }, + success: { + type: 'boolean', + description: + 'Whether a delete/remove operation succeeded (delete card, remove label, remove member)', }, error: { type: 'string', @@ -939,5 +1429,19 @@ export const TrelloBlockMeta = { content: '# Review Trello Card Activity\n\nInspect what has happened recently on a board or card to build a digest or audit.\n\n## Steps\n1. Use the Get Actions operation with either a Board ID or a Card ID (one or the other, not both).\n2. Set an Action Filter such as commentCard,updateCard,createCard to focus on the events you care about.\n3. Use Board Action Limit and Action Page to page through longer histories.\n\n## Output\nReturn the actions with their type, date, author, and text, summarized into a short activity recap.', }, + { + name: 'build-and-track-checklist', + description: + 'Add a checklist to a card, populate it with items, and check items off as work completes.', + content: + '# Build and Track a Trello Checklist\n\nGive a card a task list and keep it up to date as steps finish.\n\n## Steps\n1. Use Add Checklist on the target Card ID to create an empty checklist, and note the returned Checklist ID.\n2. Use Add Checklist Item once per task, providing the Checklist ID and Item Name.\n3. As each task completes, use Update Checklist Item with the Card ID and Checklist Item ID, setting State to Complete.\n\n## Output\nReturn the checklist and item IDs created, and confirm which items were marked complete.', + }, + { + name: 'find-and-clean-up-cards', + description: + 'Search Trello for cards matching a query, then delete or archive the ones that no longer belong.', + content: + '# Find and Clean Up Trello Cards\n\nLocate cards by keyword without already knowing their IDs, then remove the ones that should not remain.\n\n## Steps\n1. Use the Search operation with a Search Query (Trello operators like board:, list:, or due: are supported) and optionally restrict to specific Board IDs.\n2. Review the matching cards and decide which should be archived (Update Card with Archive Status) or permanently removed (Delete Card).\n3. Use Delete Card only for cards that should be gone for good — prefer archiving when the history should be kept.\n\n## Output\nReturn how many cards matched, and which were archived versus deleted.', + }, ], } as const satisfies BlockMeta diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index c13f19830a1..becc85ca39b 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -3899,6 +3899,7 @@ import { tinybirdTruncateDatasourceTool, } from '@/tools/tinybird' import { + trelloAddChecklistItemTool, trelloAddChecklistTool, trelloAddCommentTool, trelloAddLabelTool, @@ -3906,12 +3907,19 @@ import { trelloCreateBoardTool, trelloCreateCardTool, trelloCreateListTool, + trelloDeleteCardTool, trelloGetActionsTool, trelloGetBoardTool, trelloGetCardTool, trelloListCardsTool, trelloListListsTool, + trelloListMembersTool, + trelloRemoveLabelTool, + trelloRemoveMemberTool, + trelloSearchTool, trelloUpdateCardTool, + trelloUpdateChecklistItemTool, + trelloUpdateListTool, } from '@/tools/trello' import { triggerDevActivateScheduleTool, @@ -6560,15 +6568,23 @@ export const tools: Record = { trello_list_cards: trelloListCardsTool, trello_create_card: trelloCreateCardTool, trello_update_card: trelloUpdateCardTool, + trello_delete_card: trelloDeleteCardTool, trello_get_actions: trelloGetActionsTool, trello_add_comment: trelloAddCommentTool, trello_create_board: trelloCreateBoardTool, trello_get_board: trelloGetBoardTool, trello_create_list: trelloCreateListTool, + trello_update_list: trelloUpdateListTool, trello_get_card: trelloGetCardTool, trello_add_checklist: trelloAddChecklistTool, + trello_add_checklist_item: trelloAddChecklistItemTool, + trello_update_checklist_item: trelloUpdateChecklistItemTool, trello_add_label: trelloAddLabelTool, + trello_remove_label: trelloRemoveLabelTool, trello_add_member: trelloAddMemberTool, + trello_remove_member: trelloRemoveMemberTool, + trello_list_members: trelloListMembersTool, + trello_search: trelloSearchTool, trigger_dev_trigger_task: triggerDevTriggerTaskTool, trigger_dev_batch_trigger_task: triggerDevBatchTriggerTaskTool, trigger_dev_get_batch: triggerDevGetBatchTool, diff --git a/apps/sim/tools/trello/add_checklist_item.ts b/apps/sim/tools/trello/add_checklist_item.ts new file mode 100644 index 00000000000..085768bbc61 --- /dev/null +++ b/apps/sim/tools/trello/add_checklist_item.ts @@ -0,0 +1,148 @@ +import { getErrorMessage } from '@sim/utils/errors' +import { env } from '@/lib/core/config/env' +import { + extractTrelloErrorMessage, + mapTrelloChecklistItem, + TRELLO_API_BASE_URL, +} from '@/tools/trello/shared' +import type { + TrelloAddChecklistItemParams, + TrelloAddChecklistItemResponse, +} from '@/tools/trello/types' +import type { ToolConfig } from '@/tools/types' + +export const trelloAddChecklistItemTool: ToolConfig< + TrelloAddChecklistItemParams, + TrelloAddChecklistItemResponse +> = { + id: 'trello_add_checklist_item', + name: 'Trello Add Checklist Item', + description: 'Add an item to a Trello checklist', + version: '1.0.0', + + oauth: { + required: true, + provider: 'trello', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Trello OAuth access token', + }, + checklistId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Trello checklist ID to add the item to (24-character hex string)', + }, + name: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Name of the checklist item', + }, + pos: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Position of the item (top, bottom, or positive float)', + }, + checked: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether the item should start checked off', + }, + }, + + request: { + url: (params) => { + if (!params.checklistId) { + throw new Error('Checklist ID is required') + } + if (!params.name) { + throw new Error('Checklist item name is required') + } + const apiKey = env.TRELLO_API_KEY + + if (!apiKey) { + throw new Error('TRELLO_API_KEY environment variable is not set') + } + + const url = new URL( + `${TRELLO_API_BASE_URL}/checklists/${params.checklistId.trim()}/checkItems` + ) + url.searchParams.set('key', apiKey) + url.searchParams.set('token', params.accessToken) + url.searchParams.set('name', params.name.trim()) + + if (params.pos) url.searchParams.set('pos', params.pos) + if (params.checked !== undefined) url.searchParams.set('checked', String(params.checked)) + + return url.toString() + }, + method: 'POST', + headers: () => ({ + Accept: 'application/json', + }), + }, + + transformResponse: async (response) => { + const data = await response.json().catch(() => null) + + if (!response.ok) { + const error = extractTrelloErrorMessage(response, data, 'Failed to add checklist item') + + return { + success: false, + output: { + error, + }, + error, + } + } + + try { + const item = mapTrelloChecklistItem(data) + + return { + success: true, + output: { + item, + }, + } + } catch (error) { + const message = getErrorMessage(error, 'Failed to parse created checklist item') + + return { + success: false, + output: { + error: message, + }, + error: message, + } + } + }, + + outputs: { + item: { + type: 'json', + description: 'Created checklist item (id, name, state, pos, idChecklist)', + optional: true, + properties: { + id: { type: 'string', description: 'Checklist item ID' }, + name: { type: 'string', description: 'Checklist item name' }, + state: { type: 'string', description: 'Item state (complete or incomplete)' }, + pos: { type: 'number', description: 'Item position on the checklist' }, + idChecklist: { + type: 'string', + description: 'Checklist ID containing the item', + optional: true, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/trello/create_card.ts b/apps/sim/tools/trello/create_card.ts index b71f128a433..26b9d78d7e0 100644 --- a/apps/sim/tools/trello/create_card.ts +++ b/apps/sim/tools/trello/create_card.ts @@ -72,6 +72,16 @@ export const trelloCreateCardTool: ToolConfig = { + id: 'trello_delete_card', + name: 'Trello Delete Card', + description: 'Permanently delete a Trello card', + version: '1.0.0', + + oauth: { + required: true, + provider: 'trello', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Trello OAuth access token', + }, + cardId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Trello card ID to permanently delete (24-character hex string)', + }, + }, + + request: { + url: (params) => { + if (!params.cardId) { + throw new Error('Card ID is required') + } + const apiKey = env.TRELLO_API_KEY + + if (!apiKey) { + throw new Error('TRELLO_API_KEY environment variable is not set') + } + + const url = new URL(`${TRELLO_API_BASE_URL}/cards/${params.cardId.trim()}`) + url.searchParams.set('key', apiKey) + url.searchParams.set('token', params.accessToken) + + return url.toString() + }, + method: 'DELETE', + headers: () => ({ + Accept: 'application/json', + }), + }, + + transformResponse: async (response) => { + const data = await response.json().catch(() => null) + + if (!response.ok) { + const error = extractTrelloErrorMessage(response, data, 'Failed to delete card') + + return { + success: false, + output: { + success: false, + error, + }, + error, + } + } + + return { + success: true, + output: { + success: true, + }, + } + }, + + outputs: { + success: { + type: 'boolean', + description: 'Whether the card was deleted', + }, + }, +} diff --git a/apps/sim/tools/trello/get_actions.ts b/apps/sim/tools/trello/get_actions.ts index 00857a5bede..edeab1a830c 100644 --- a/apps/sim/tools/trello/get_actions.ts +++ b/apps/sim/tools/trello/get_actions.ts @@ -56,6 +56,20 @@ export const trelloGetActionsTool: ToolConfig = + { + id: 'trello_list_members', + name: 'Trello List Members', + description: 'List members of a Trello board', + version: '1.0.0', + + oauth: { + required: true, + provider: 'trello', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Trello OAuth access token', + }, + boardId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Trello board ID (24-character hex string)', + }, + }, + + request: { + url: (params) => { + if (!params.boardId) { + throw new Error('Board ID is required') + } + const apiKey = env.TRELLO_API_KEY + + if (!apiKey) { + throw new Error('TRELLO_API_KEY environment variable is not set') + } + + const url = new URL(`${TRELLO_API_BASE_URL}/boards/${params.boardId.trim()}/members`) + url.searchParams.set('key', apiKey) + url.searchParams.set('token', params.accessToken) + + return url.toString() + }, + method: 'GET', + headers: () => ({ + Accept: 'application/json', + }), + }, + + transformResponse: async (response) => { + const data = await response.json().catch(() => null) + + if (!response.ok) { + const error = extractTrelloErrorMessage(response, data, 'Failed to list board members') + + return { + success: false, + output: { + members: [], + count: 0, + error, + }, + error, + } + } + + if (!Array.isArray(data)) { + const error = 'Trello returned an invalid member collection' + + return { + success: false, + output: { + members: [], + count: 0, + error, + }, + error, + } + } + + try { + const members = data + .map((item) => mapTrelloMember(item)) + .filter((member): member is NonNullable => member !== null) + + return { + success: true, + output: { + members, + count: members.length, + }, + } + } catch (error) { + const message = getErrorMessage(error, 'Failed to parse board members') + + return { + success: false, + output: { + members: [], + count: 0, + error: message, + }, + error: message, + } + } + }, + + outputs: { + members: { + type: 'array', + description: 'Members on the selected board', + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'Member ID' }, + fullName: { type: 'string', description: 'Member full name', optional: true }, + username: { type: 'string', description: 'Member username', optional: true }, + }, + }, + }, + count: { type: 'number', description: 'Number of members returned' }, + }, + } diff --git a/apps/sim/tools/trello/remove_label.ts b/apps/sim/tools/trello/remove_label.ts new file mode 100644 index 00000000000..27be742794d --- /dev/null +++ b/apps/sim/tools/trello/remove_label.ts @@ -0,0 +1,97 @@ +import { env } from '@/lib/core/config/env' +import { extractTrelloErrorMessage, TRELLO_API_BASE_URL } from '@/tools/trello/shared' +import type { TrelloRemoveLabelParams, TrelloRemoveLabelResponse } from '@/tools/trello/types' +import type { ToolConfig } from '@/tools/types' + +export const trelloRemoveLabelTool: ToolConfig = + { + id: 'trello_remove_label', + name: 'Trello Remove Label', + description: 'Detach a label from a Trello card', + version: '1.0.0', + + oauth: { + required: true, + provider: 'trello', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Trello OAuth access token', + }, + cardId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Trello card ID to detach the label from (24-character hex string)', + }, + labelId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the label to detach (24-character hex string)', + }, + }, + + request: { + url: (params) => { + if (!params.cardId) { + throw new Error('Card ID is required') + } + if (!params.labelId) { + throw new Error('Label ID is required') + } + const apiKey = env.TRELLO_API_KEY + + if (!apiKey) { + throw new Error('TRELLO_API_KEY environment variable is not set') + } + + const url = new URL( + `${TRELLO_API_BASE_URL}/cards/${params.cardId.trim()}/idLabels/${params.labelId.trim()}` + ) + url.searchParams.set('key', apiKey) + url.searchParams.set('token', params.accessToken) + + return url.toString() + }, + method: 'DELETE', + headers: () => ({ + Accept: 'application/json', + }), + }, + + transformResponse: async (response) => { + const data = await response.json().catch(() => null) + + if (!response.ok) { + const error = extractTrelloErrorMessage(response, data, 'Failed to remove label') + + return { + success: false, + output: { + success: false, + error, + }, + error, + } + } + + return { + success: true, + output: { + success: true, + }, + } + }, + + outputs: { + success: { + type: 'boolean', + description: 'Whether the label was removed from the card', + }, + }, + } diff --git a/apps/sim/tools/trello/remove_member.ts b/apps/sim/tools/trello/remove_member.ts new file mode 100644 index 00000000000..f722efd5e05 --- /dev/null +++ b/apps/sim/tools/trello/remove_member.ts @@ -0,0 +1,99 @@ +import { env } from '@/lib/core/config/env' +import { extractTrelloErrorMessage, TRELLO_API_BASE_URL } from '@/tools/trello/shared' +import type { TrelloRemoveMemberParams, TrelloRemoveMemberResponse } from '@/tools/trello/types' +import type { ToolConfig } from '@/tools/types' + +export const trelloRemoveMemberTool: ToolConfig< + TrelloRemoveMemberParams, + TrelloRemoveMemberResponse +> = { + id: 'trello_remove_member', + name: 'Trello Remove Member', + description: 'Unassign a member from a Trello card', + version: '1.0.0', + + oauth: { + required: true, + provider: 'trello', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Trello OAuth access token', + }, + cardId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Trello card ID to unassign the member from (24-character hex string)', + }, + memberId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the member to unassign (24-character hex string)', + }, + }, + + request: { + url: (params) => { + if (!params.cardId) { + throw new Error('Card ID is required') + } + if (!params.memberId) { + throw new Error('Member ID is required') + } + const apiKey = env.TRELLO_API_KEY + + if (!apiKey) { + throw new Error('TRELLO_API_KEY environment variable is not set') + } + + const url = new URL( + `${TRELLO_API_BASE_URL}/cards/${params.cardId.trim()}/idMembers/${params.memberId.trim()}` + ) + url.searchParams.set('key', apiKey) + url.searchParams.set('token', params.accessToken) + + return url.toString() + }, + method: 'DELETE', + headers: () => ({ + Accept: 'application/json', + }), + }, + + transformResponse: async (response) => { + const data = await response.json().catch(() => null) + + if (!response.ok) { + const error = extractTrelloErrorMessage(response, data, 'Failed to remove member') + + return { + success: false, + output: { + success: false, + error, + }, + error, + } + } + + return { + success: true, + output: { + success: true, + }, + } + }, + + outputs: { + success: { + type: 'boolean', + description: 'Whether the member was removed from the card', + }, + }, +} diff --git a/apps/sim/tools/trello/search.ts b/apps/sim/tools/trello/search.ts new file mode 100644 index 00000000000..6d737ad155a --- /dev/null +++ b/apps/sim/tools/trello/search.ts @@ -0,0 +1,195 @@ +import { getErrorMessage } from '@sim/utils/errors' +import { isRecordLike } from '@sim/utils/object' +import { env } from '@/lib/core/config/env' +import { + extractTrelloErrorMessage, + mapTrelloBoard, + mapTrelloCard, + TRELLO_API_BASE_URL, +} from '@/tools/trello/shared' +import type { TrelloSearchParams, TrelloSearchResponse } from '@/tools/trello/types' +import type { ToolConfig } from '@/tools/types' + +export const trelloSearchTool: ToolConfig = { + id: 'trello_search', + name: 'Trello Search', + description: 'Search Trello cards and boards by keyword', + version: '1.0.0', + + oauth: { + required: true, + provider: 'trello', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Trello OAuth access token', + }, + query: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Search text, supports Trello search operators (e.g. board:, list:, due:)', + }, + idBoards: { + type: 'array', + required: false, + visibility: 'user-or-llm', + description: 'Restrict the search to these board IDs', + items: { + type: 'string', + description: 'A Trello board ID', + }, + }, + modelTypes: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Comma-separated result types to search: cards, boards, or all (default all)', + }, + cardsLimit: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Maximum number of cards to return (1-1000, default 10)', + }, + }, + + request: { + url: (params) => { + if (!params.query) { + throw new Error('Search query is required') + } + const apiKey = env.TRELLO_API_KEY + + if (!apiKey) { + throw new Error('TRELLO_API_KEY environment variable is not set') + } + + const url = new URL(`${TRELLO_API_BASE_URL}/search`) + url.searchParams.set('key', apiKey) + url.searchParams.set('token', params.accessToken) + url.searchParams.set('query', params.query) + url.searchParams.set('modelTypes', params.modelTypes || 'all') + + if (params.idBoards?.length) { + url.searchParams.set('idBoards', params.idBoards.join(',')) + } + + if (params.cardsLimit !== undefined) { + url.searchParams.set('cards_limit', String(params.cardsLimit)) + } + + return url.toString() + }, + method: 'GET', + headers: () => ({ + Accept: 'application/json', + }), + }, + + transformResponse: async (response) => { + const data = await response.json().catch(() => null) + + if (!response.ok) { + const error = extractTrelloErrorMessage(response, data, 'Failed to search Trello') + + return { + success: false, + output: { + cards: [], + boards: [], + count: 0, + error, + }, + error, + } + } + + if (!isRecordLike(data)) { + const error = 'Trello returned an invalid search result' + + return { + success: false, + output: { + cards: [], + boards: [], + count: 0, + error, + }, + error, + } + } + + try { + const rawCards = Array.isArray(data.cards) ? data.cards : [] + const rawBoards = Array.isArray(data.boards) ? data.boards : [] + const cards = rawCards.map((item) => mapTrelloCard(item)) + const boards = rawBoards.map((item) => mapTrelloBoard(item)) + + return { + success: true, + output: { + cards, + boards, + count: cards.length + boards.length, + }, + } + } catch (error) { + const message = getErrorMessage(error, 'Failed to parse Trello search results') + + return { + success: false, + output: { + cards: [], + boards: [], + count: 0, + error: message, + }, + error: message, + } + } + }, + + outputs: { + cards: { + type: 'array', + description: 'Cards matching the search query', + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'Card ID' }, + name: { type: 'string', description: 'Card name' }, + desc: { type: 'string', description: 'Card description' }, + url: { type: 'string', description: 'Full card URL' }, + idBoard: { type: 'string', description: 'Board ID containing the card' }, + idList: { type: 'string', description: 'List ID containing the card' }, + closed: { type: 'boolean', description: 'Whether the card is archived' }, + }, + }, + }, + boards: { + type: 'array', + description: 'Boards matching the search query', + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'Board ID' }, + name: { type: 'string', description: 'Board name' }, + desc: { type: 'string', description: 'Board description' }, + url: { type: 'string', description: 'Full board URL' }, + closed: { type: 'boolean', description: 'Whether the board is archived' }, + idOrganization: { + type: 'string', + description: 'Workspace/organization ID that owns the board', + optional: true, + }, + }, + }, + }, + count: { type: 'number', description: 'Total number of cards and boards returned' }, + }, +} diff --git a/apps/sim/tools/trello/shared.ts b/apps/sim/tools/trello/shared.ts index 27617a5ca77..b1c2346af83 100644 --- a/apps/sim/tools/trello/shared.ts +++ b/apps/sim/tools/trello/shared.ts @@ -7,6 +7,7 @@ import type { TrelloBoard, TrelloCard, TrelloChecklist, + TrelloChecklistItem, TrelloComment, TrelloLabel, TrelloList, @@ -84,7 +85,7 @@ function mapTrelloLabel(value: unknown): TrelloLabel | null { } } -function mapTrelloMember(value: unknown): TrelloMember | null { +export function mapTrelloMember(value: unknown): TrelloMember | null { if (!isRecordLike(value) || typeof value.id !== 'string') { return null } @@ -206,6 +207,20 @@ export function mapTrelloChecklist(value: unknown): TrelloChecklist { } } +export function mapTrelloChecklistItem(value: unknown): TrelloChecklistItem { + if (!isRecordLike(value)) { + throw new Error('Trello returned an invalid checklist item object') + } + + return { + id: getRequiredString(value.id, 'id'), + name: getRequiredString(value.name, 'name'), + state: getRequiredString(value.state, 'state'), + pos: getNumber(value.pos), + idChecklist: getOptionalString(value.idChecklist), + } +} + export function mapTrelloAction(value: unknown): TrelloAction { if (!isRecordLike(value)) { throw new Error('Trello returned an invalid action object') diff --git a/apps/sim/tools/trello/types.ts b/apps/sim/tools/trello/types.ts index fdbbbc8d7e3..de253a083e4 100644 --- a/apps/sim/tools/trello/types.ts +++ b/apps/sim/tools/trello/types.ts @@ -87,12 +87,14 @@ export interface TrelloComment extends TrelloAction {} export interface TrelloListListsParams { accessToken: string boardId: string + filter?: string } export interface TrelloListCardsParams { accessToken: string boardId?: string listId?: string + filter?: string } export interface TrelloCreateCardParams { @@ -104,6 +106,7 @@ export interface TrelloCreateCardParams { due?: string dueComplete?: boolean labelIds?: string[] + memberIds?: string[] } export interface TrelloUpdateCardParams { @@ -117,6 +120,11 @@ export interface TrelloUpdateCardParams { dueComplete?: boolean } +export interface TrelloDeleteCardParams { + accessToken: string + cardId: string +} + export interface TrelloGetActionsParams { accessToken: string boardId?: string @@ -124,6 +132,8 @@ export interface TrelloGetActionsParams { filter?: string limit?: number page?: number + since?: string + before?: string } export interface TrelloAddCommentParams { @@ -164,18 +174,68 @@ export interface TrelloAddChecklistParams { pos?: string } +export interface TrelloAddChecklistItemParams { + accessToken: string + checklistId: string + name: string + pos?: string + checked?: boolean +} + +export interface TrelloUpdateChecklistItemParams { + accessToken: string + cardId: string + checkItemId: string + state?: 'complete' | 'incomplete' + name?: string +} + export interface TrelloAddLabelParams { accessToken: string cardId: string labelId: string } +export interface TrelloRemoveLabelParams { + accessToken: string + cardId: string + labelId: string +} + export interface TrelloAddMemberParams { accessToken: string cardId: string memberId: string } +export interface TrelloRemoveMemberParams { + accessToken: string + cardId: string + memberId: string +} + +export interface TrelloListMembersParams { + accessToken: string + boardId: string +} + +export interface TrelloUpdateListParams { + accessToken: string + listId: string + name?: string + closed?: boolean + idBoard?: string + pos?: string +} + +export interface TrelloSearchParams { + accessToken: string + query: string + idBoards?: string[] + modelTypes?: string + cardsLimit?: number +} + export interface TrelloListListsResponse extends ToolResponse { output: { lists: TrelloList[] @@ -256,6 +316,28 @@ export interface TrelloAddChecklistResponse extends ToolResponse { } } +export interface TrelloChecklistItem { + id: string + name: string + state: string + pos: number + idChecklist: string | null +} + +export interface TrelloAddChecklistItemResponse extends ToolResponse { + output: { + item?: TrelloChecklistItem + error?: string + } +} + +export interface TrelloUpdateChecklistItemResponse extends ToolResponse { + output: { + item?: TrelloChecklistItem + error?: string + } +} + export interface TrelloAddLabelResponse extends ToolResponse { output: { labelIds: string[] @@ -263,6 +345,13 @@ export interface TrelloAddLabelResponse extends ToolResponse { } } +export interface TrelloRemoveLabelResponse extends ToolResponse { + output: { + success: boolean + error?: string + } +} + export interface TrelloAddMemberResponse extends ToolResponse { output: { memberIds: string[] @@ -270,17 +359,63 @@ export interface TrelloAddMemberResponse extends ToolResponse { } } +export interface TrelloRemoveMemberResponse extends ToolResponse { + output: { + success: boolean + error?: string + } +} + +export interface TrelloListMembersResponse extends ToolResponse { + output: { + members: TrelloMember[] + count: number + error?: string + } +} + +export interface TrelloUpdateListResponse extends ToolResponse { + output: { + list?: TrelloList + error?: string + } +} + +export interface TrelloDeleteCardResponse extends ToolResponse { + output: { + success: boolean + error?: string + } +} + +export interface TrelloSearchResponse extends ToolResponse { + output: { + cards: TrelloCard[] + boards: TrelloBoard[] + count: number + error?: string + } +} + export type TrelloResponse = | TrelloListListsResponse | TrelloListCardsResponse | TrelloCreateCardResponse | TrelloUpdateCardResponse + | TrelloDeleteCardResponse | TrelloGetActionsResponse | TrelloAddCommentResponse | TrelloCreateBoardResponse | TrelloGetBoardResponse | TrelloCreateListResponse + | TrelloUpdateListResponse | TrelloGetCardResponse | TrelloAddChecklistResponse + | TrelloAddChecklistItemResponse + | TrelloUpdateChecklistItemResponse | TrelloAddLabelResponse + | TrelloRemoveLabelResponse | TrelloAddMemberResponse + | TrelloRemoveMemberResponse + | TrelloListMembersResponse + | TrelloSearchResponse diff --git a/apps/sim/tools/trello/update_checklist_item.ts b/apps/sim/tools/trello/update_checklist_item.ts new file mode 100644 index 00000000000..5c2f2939866 --- /dev/null +++ b/apps/sim/tools/trello/update_checklist_item.ts @@ -0,0 +1,150 @@ +import { getErrorMessage } from '@sim/utils/errors' +import { env } from '@/lib/core/config/env' +import { + extractTrelloErrorMessage, + mapTrelloChecklistItem, + TRELLO_API_BASE_URL, +} from '@/tools/trello/shared' +import type { + TrelloUpdateChecklistItemParams, + TrelloUpdateChecklistItemResponse, +} from '@/tools/trello/types' +import type { ToolConfig } from '@/tools/types' + +export const trelloUpdateChecklistItemTool: ToolConfig< + TrelloUpdateChecklistItemParams, + TrelloUpdateChecklistItemResponse +> = { + id: 'trello_update_checklist_item', + name: 'Trello Update Checklist Item', + description: 'Check off, uncheck, or rename a Trello checklist item', + version: '1.0.0', + + oauth: { + required: true, + provider: 'trello', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Trello OAuth access token', + }, + cardId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Trello card ID that owns the checklist item (24-character hex string)', + }, + checkItemId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Checklist item ID to update (24-character hex string)', + }, + state: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Set the item state to complete or incomplete', + }, + name: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'New name for the checklist item', + }, + }, + + request: { + url: (params) => { + if (!params.cardId) { + throw new Error('Card ID is required') + } + if (!params.checkItemId) { + throw new Error('Checklist item ID is required') + } + if (!params.state && !params.name) { + throw new Error('At least one of state or name must be provided to update') + } + const apiKey = env.TRELLO_API_KEY + + if (!apiKey) { + throw new Error('TRELLO_API_KEY environment variable is not set') + } + + const url = new URL( + `${TRELLO_API_BASE_URL}/cards/${params.cardId.trim()}/checkItem/${params.checkItemId.trim()}` + ) + url.searchParams.set('key', apiKey) + url.searchParams.set('token', params.accessToken) + + if (params.state) url.searchParams.set('state', params.state) + if (params.name) url.searchParams.set('name', params.name.trim()) + + return url.toString() + }, + method: 'PUT', + headers: () => ({ + Accept: 'application/json', + }), + }, + + transformResponse: async (response) => { + const data = await response.json().catch(() => null) + + if (!response.ok) { + const error = extractTrelloErrorMessage(response, data, 'Failed to update checklist item') + + return { + success: false, + output: { + error, + }, + error, + } + } + + try { + const item = mapTrelloChecklistItem(data) + + return { + success: true, + output: { + item, + }, + } + } catch (error) { + const message = getErrorMessage(error, 'Failed to parse updated checklist item') + + return { + success: false, + output: { + error: message, + }, + error: message, + } + } + }, + + outputs: { + item: { + type: 'json', + description: 'Updated checklist item (id, name, state, pos, idChecklist)', + optional: true, + properties: { + id: { type: 'string', description: 'Checklist item ID' }, + name: { type: 'string', description: 'Checklist item name' }, + state: { type: 'string', description: 'Item state (complete or incomplete)' }, + pos: { type: 'number', description: 'Item position on the checklist' }, + idChecklist: { + type: 'string', + description: 'Checklist ID containing the item', + optional: true, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/trello/update_list.ts b/apps/sim/tools/trello/update_list.ts new file mode 100644 index 00000000000..604cc3be0fb --- /dev/null +++ b/apps/sim/tools/trello/update_list.ts @@ -0,0 +1,150 @@ +import { getErrorMessage } from '@sim/utils/errors' +import { env } from '@/lib/core/config/env' +import { + extractTrelloErrorMessage, + mapTrelloList, + TRELLO_API_BASE_URL, +} from '@/tools/trello/shared' +import type { TrelloUpdateListParams, TrelloUpdateListResponse } from '@/tools/trello/types' +import type { ToolConfig } from '@/tools/types' + +export const trelloUpdateListTool: ToolConfig = { + id: 'trello_update_list', + name: 'Trello Update List', + description: 'Rename, move, archive, or reopen a Trello list', + version: '1.0.0', + + oauth: { + required: true, + provider: 'trello', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Trello OAuth access token', + }, + listId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Trello list ID (24-character hex string)', + }, + name: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'New name of the list', + }, + closed: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Archive the list (true) or reopen it (false)', + }, + idBoard: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Board ID to move the list to (24-character hex string)', + }, + pos: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'New position of the list (top, bottom, or positive float)', + }, + }, + + request: { + url: (params) => { + if (!params.listId) { + throw new Error('List ID is required') + } + const apiKey = env.TRELLO_API_KEY + + if (!apiKey) { + throw new Error('TRELLO_API_KEY environment variable is not set') + } + + const url = new URL(`${TRELLO_API_BASE_URL}/lists/${params.listId.trim()}`) + url.searchParams.set('key', apiKey) + url.searchParams.set('token', params.accessToken) + + return url.toString() + }, + method: 'PUT', + headers: () => ({ + 'Content-Type': 'application/json', + Accept: 'application/json', + }), + body: (params) => { + const body: Record = {} + + if (params.name !== undefined) body.name = params.name + if (params.closed !== undefined) body.closed = params.closed + if (params.idBoard !== undefined) body.idBoard = params.idBoard.trim() + if (params.pos !== undefined) body.pos = params.pos + + if (Object.keys(body).length === 0) { + throw new Error('At least one field must be provided to update') + } + + return body + }, + }, + + transformResponse: async (response) => { + const data = await response.json().catch(() => null) + + if (!response.ok) { + const error = extractTrelloErrorMessage(response, data, 'Failed to update list') + + return { + success: false, + output: { + error, + }, + error, + } + } + + try { + const list = mapTrelloList(data) + + return { + success: true, + output: { + list, + }, + } + } catch (error) { + const message = getErrorMessage(error, 'Failed to parse updated list') + + return { + success: false, + output: { + error: message, + }, + error: message, + } + } + }, + + outputs: { + list: { + type: 'json', + description: 'Updated list (id, name, closed, pos, idBoard)', + optional: true, + properties: { + id: { type: 'string', description: 'List ID' }, + name: { type: 'string', description: 'List name' }, + closed: { type: 'boolean', description: 'Whether the list is archived' }, + pos: { type: 'number', description: 'List position on the board' }, + idBoard: { type: 'string', description: 'Board ID containing the list' }, + }, + }, + }, +} From a8f27094f929e2a72af6769894fa8f23061a6898 Mon Sep 17 00:00:00 2001 From: Waleed Date: Thu, 2 Jul 2026 10:57:09 -0700 Subject: [PATCH 19/28] improvement(gong): validate integration against API docs, fix pagination/wandConfig consistency (#5361) * improvement(gong): tighten pagination optionality and add wandConfig to ID list fields - mark cursor output optional:true on aggregate_activity, interaction_stats, list_flows for consistency with list_calls - add wandConfig to comma-separated ID fields (callIds, primaryUserIds, userIds, scorecardIds, reviewedUserIds) matching repo convention for CSV inputs * fix(gong): request context data for get_extensive_calls contentSelector.context/contextTiming were never set on the /v2/calls/extensive request, so the documented context (CRM/external-system links) output was silently always empty even though the field is declared in the tool's outputs. * feat(gong): add data privacy erase and Engage flow prospect tools - gong_purge_email_address / gong_purge_phone_number: POST /v2/data-privacy/erase-data-for-*, the write-half pairing with the existing lookup_email/lookup_phone read tools - gong_assign_flow_prospects: POST /v2/flows/prospects/assign, enrolls CRM prospects into an Engage flow - gong_get_prospect_flows: POST /v2/flows/prospects, looks up which flows a prospect is in Field names verified against an OpenAPI-generator-produced client (cedricziel/gong-rs) whose serde rename attributes mirror Gong's published spec, since Gong's interactive Swagger docs require an authenticated session and can't be fetched directly. * fix(gong): require human-entered target for data-erase tools emailAddress/phoneNumber on the purge tools were user-or-llm, letting an agent autonomously pick the erasure target for an irreversible operation with no human confirmation. Match the user-only visibility already used for credentials. --- apps/sim/blocks/blocks/gong.ts | 110 +++++++++++++-- apps/sim/tools/gong/aggregate_activity.ts | 1 + apps/sim/tools/gong/assign_flow_prospects.ts | 133 +++++++++++++++++++ apps/sim/tools/gong/get_extensive_calls.ts | 2 + apps/sim/tools/gong/get_prospect_flows.ts | 99 ++++++++++++++ apps/sim/tools/gong/index.ts | 8 ++ apps/sim/tools/gong/interaction_stats.ts | 1 + apps/sim/tools/gong/list_flows.ts | 1 + apps/sim/tools/gong/purge_email_address.ts | 69 ++++++++++ apps/sim/tools/gong/purge_phone_number.ts | 70 ++++++++++ apps/sim/tools/gong/types.ts | 74 +++++++++++ apps/sim/tools/registry.ts | 8 ++ 12 files changed, 568 insertions(+), 8 deletions(-) create mode 100644 apps/sim/tools/gong/assign_flow_prospects.ts create mode 100644 apps/sim/tools/gong/get_prospect_flows.ts create mode 100644 apps/sim/tools/gong/purge_email_address.ts create mode 100644 apps/sim/tools/gong/purge_phone_number.ts diff --git a/apps/sim/blocks/blocks/gong.ts b/apps/sim/blocks/blocks/gong.ts index 2a367701f40..7a28123fa3a 100644 --- a/apps/sim/blocks/blocks/gong.ts +++ b/apps/sim/blocks/blocks/gong.ts @@ -43,9 +43,13 @@ export const GongBlock: BlockConfig = { { label: 'List Trackers', id: 'list_trackers' }, { label: 'List Workspaces', id: 'list_workspaces' }, { label: 'List Flows', id: 'list_flows' }, + { label: 'Assign Flow Prospects', id: 'assign_flow_prospects' }, + { label: 'Get Prospect Flows', id: 'get_prospect_flows' }, { label: 'Get Coaching', id: 'get_coaching' }, { label: 'Lookup Email', id: 'lookup_email' }, { label: 'Lookup Phone', id: 'lookup_phone' }, + { label: 'Purge Email Address', id: 'purge_email_address' }, + { label: 'Purge Phone Number', id: 'purge_phone_number' }, ], value: () => 'list_calls', }, @@ -239,6 +243,12 @@ Return ONLY the timestamp string in ISO 8601 format - no explanations, no quotes type: 'short-input', placeholder: 'Comma-separated call IDs (optional)', condition: { field: 'operation', value: ['get_call_transcript', 'get_extensive_calls'] }, + wandConfig: { + enabled: true, + prompt: `Generate a comma-separated list of Gong call IDs based on the user's description. +Return ONLY the comma-separated list of IDs - no explanations, no extra text.`, + placeholder: 'Describe the call IDs (e.g., "calls 123456 and 789012")...', + }, }, { id: 'transcriptFromDateTime', @@ -289,6 +299,12 @@ Return ONLY the timestamp string in ISO 8601 format - no explanations, no quotes placeholder: 'Comma-separated user IDs (optional)', condition: { field: 'operation', value: 'get_extensive_calls' }, mode: 'advanced', + wandConfig: { + enabled: true, + prompt: `Generate a comma-separated list of Gong user IDs based on the user's description. +Return ONLY the comma-separated list of IDs - no explanations, no extra text.`, + placeholder: 'Describe the user IDs...', + }, }, // List Users inputs @@ -405,6 +421,12 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n ], }, mode: 'advanced', + wandConfig: { + enabled: true, + prompt: `Generate a comma-separated list of Gong user IDs based on the user's description. +Return ONLY the comma-separated list of IDs - no explanations, no extra text.`, + placeholder: 'Describe the user IDs...', + }, }, // Aggregate by Period inputs @@ -499,6 +521,12 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n placeholder: 'Comma-separated scorecard IDs (optional)', condition: { field: 'operation', value: 'answered_scorecards' }, mode: 'advanced', + wandConfig: { + enabled: true, + prompt: `Generate a comma-separated list of Gong scorecard IDs based on the user's description. +Return ONLY the comma-separated list of IDs - no explanations, no extra text.`, + placeholder: 'Describe the scorecard IDs...', + }, }, { id: 'reviewedUserIds', @@ -507,6 +535,12 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n placeholder: 'Comma-separated user IDs (optional)', condition: { field: 'operation', value: 'answered_scorecards' }, mode: 'advanced', + wandConfig: { + enabled: true, + prompt: `Generate a comma-separated list of Gong user IDs based on the user's description. +Return ONLY the comma-separated list of IDs - no explanations, no extra text.`, + placeholder: 'Describe the reviewed user IDs...', + }, }, // Get Folder Content inputs @@ -550,6 +584,38 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n required: { field: 'operation', value: 'list_flows' }, }, + // Assign Flow Prospects / Get Prospect Flows inputs + { + id: 'flowId', + title: 'Flow ID', + type: 'short-input', + placeholder: 'Enter the Gong Engage flow ID', + condition: { field: 'operation', value: 'assign_flow_prospects' }, + required: { field: 'operation', value: 'assign_flow_prospects' }, + }, + { + id: 'crmProspectsIds', + title: 'CRM Prospect IDs', + type: 'short-input', + placeholder: 'Comma-separated CRM contact or lead IDs', + condition: { field: 'operation', value: ['assign_flow_prospects', 'get_prospect_flows'] }, + required: { field: 'operation', value: ['assign_flow_prospects', 'get_prospect_flows'] }, + wandConfig: { + enabled: true, + prompt: `Generate a comma-separated list of CRM prospect IDs based on the user's description. +Return ONLY the comma-separated list of IDs - no explanations, no extra text.`, + placeholder: 'Describe the CRM prospect IDs...', + }, + }, + { + id: 'flowInstanceOwnerEmail', + title: 'Flow Instance Owner Email', + type: 'short-input', + placeholder: 'user@example.com', + condition: { field: 'operation', value: 'assign_flow_prospects' }, + required: { field: 'operation', value: 'assign_flow_prospects' }, + }, + // Get Coaching inputs { id: 'managerId', @@ -610,24 +676,24 @@ Return ONLY the timestamp string in ISO 8601 format - no explanations, no quotes }, }, - // Lookup Email inputs + // Lookup Email / Purge Email Address inputs { id: 'emailAddress', title: 'Email Address', type: 'short-input', placeholder: 'user@example.com', - condition: { field: 'operation', value: 'lookup_email' }, - required: { field: 'operation', value: 'lookup_email' }, + condition: { field: 'operation', value: ['lookup_email', 'purge_email_address'] }, + required: { field: 'operation', value: ['lookup_email', 'purge_email_address'] }, }, - // Lookup Phone inputs + // Lookup Phone / Purge Phone Number inputs { id: 'phoneNumber', title: 'Phone Number', type: 'short-input', placeholder: '+1234567890', - condition: { field: 'operation', value: 'lookup_phone' }, - required: { field: 'operation', value: 'lookup_phone' }, + condition: { field: 'operation', value: ['lookup_phone', 'purge_phone_number'] }, + required: { field: 'operation', value: ['lookup_phone', 'purge_phone_number'] }, }, // Pagination cursor (shared) @@ -692,9 +758,13 @@ Return ONLY the timestamp string in ISO 8601 format - no explanations, no quotes 'gong_list_trackers', 'gong_list_workspaces', 'gong_list_flows', + 'gong_assign_flow_prospects', + 'gong_get_prospect_flows', 'gong_get_coaching', 'gong_lookup_email', 'gong_lookup_phone', + 'gong_purge_email_address', + 'gong_purge_phone_number', ], config: { tool: (params) => `gong_${params.operation}`, @@ -763,8 +833,20 @@ Return ONLY the timestamp string in ISO 8601 format - no explanations, no quotes type: 'string', description: 'Email of a Gong user to retrieve personal and company flows', }, - emailAddress: { type: 'string', description: 'Email address to look up' }, - phoneNumber: { type: 'string', description: 'Phone number to look up' }, + flowId: { type: 'string', description: 'Gong Engage flow ID' }, + crmProspectsIds: { type: 'string', description: 'Comma-separated CRM prospect IDs' }, + flowInstanceOwnerEmail: { + type: 'string', + description: 'Email of the Gong user who owns the flow instance and its to-dos', + }, + emailAddress: { + type: 'string', + description: 'Email address to look up or purge', + }, + phoneNumber: { + type: 'string', + description: 'Phone number to look up or purge', + }, cursor: { type: 'string', description: 'Pagination cursor' }, }, outputs: { @@ -883,6 +965,18 @@ Return ONLY the timestamp string in ISO 8601 format - no explanations, no quotes 'Gong Engage flows: [{id, name, folderId, folderName, visibility, creationDate, exclusive}]', }, + // assign_flow_prospects / get_prospect_flows + prospectsAssigned: { + type: 'json', + description: + 'Prospects assigned to (or enrolled in) flows: [{flowId, flowName, crmProspectId, flowInstanceId, flowInstanceOwnerEmail, flowInstanceOwnerFullName, flowInstanceCreateDate, flowInstanceStatus, workspaceId, exclusive}]', + }, + prospectsNotAssigned: { + type: 'json', + description: + 'Prospects that failed to be assigned to a flow: [{flowId, crmProspectId, errorCode, errorMessage}]', + }, + // get_coaching coachingData: { type: 'json', diff --git a/apps/sim/tools/gong/aggregate_activity.ts b/apps/sim/tools/gong/aggregate_activity.ts index a962e00dc75..d7708966d30 100644 --- a/apps/sim/tools/gong/aggregate_activity.ts +++ b/apps/sim/tools/gong/aggregate_activity.ts @@ -197,6 +197,7 @@ export const aggregateActivityTool: ToolConfig< cursor: { type: 'string', description: 'Pagination cursor for the next page', + optional: true, }, }, } diff --git a/apps/sim/tools/gong/assign_flow_prospects.ts b/apps/sim/tools/gong/assign_flow_prospects.ts new file mode 100644 index 00000000000..3f8d6dca54b --- /dev/null +++ b/apps/sim/tools/gong/assign_flow_prospects.ts @@ -0,0 +1,133 @@ +import type { + GongAssignFlowProspectsParams, + GongAssignFlowProspectsResponse, +} from '@/tools/gong/types' +import { getGongErrorMessage, parseGongIdList } from '@/tools/gong/utils' +import type { ToolConfig } from '@/tools/types' + +export const assignFlowProspectsTool: ToolConfig< + GongAssignFlowProspectsParams, + GongAssignFlowProspectsResponse +> = { + id: 'gong_assign_flow_prospects', + name: 'Gong Assign Flow Prospects', + description: 'Assign up to 200 CRM prospects (contacts or leads) to a Gong Engage flow.', + version: '1.0.0', + + params: { + accessKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Gong API Access Key', + }, + accessKeySecret: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Gong API Access Key Secret', + }, + flowId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The Gong Engage flow ID to assign the prospects to', + }, + crmProspectsIds: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Comma-separated list of CRM prospect IDs (contacts or leads) to assign', + }, + flowInstanceOwnerEmail: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Email of the Gong user who owns the flow instance and its to-dos', + }, + }, + + request: { + url: 'https://api.gong.io/v2/flows/prospects/assign', + method: 'POST', + headers: (params) => ({ + 'Content-Type': 'application/json', + Authorization: `Basic ${btoa(`${params.accessKey}:${params.accessKeySecret}`)}`, + }), + body: (params) => ({ + flowId: params.flowId.trim(), + crmProspectsIds: parseGongIdList(params.crmProspectsIds) ?? [], + flowInstanceOwnerEmail: params.flowInstanceOwnerEmail.trim(), + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + if (!response.ok) { + throw new Error(getGongErrorMessage(data, 'Failed to assign prospects to flow')) + } + return { + success: true, + output: { + requestId: data.requestId ?? null, + prospectsAssigned: data.prospectsAssigned ?? [], + prospectsNotAssigned: data.prospectsNotAssigned ?? [], + }, + } + }, + + outputs: { + requestId: { + type: 'string', + description: 'A Gong request reference ID for troubleshooting purposes', + optional: true, + }, + prospectsAssigned: { + type: 'array', + description: 'Prospects successfully assigned to the flow', + items: { + type: 'object', + properties: { + flowId: { type: 'string', description: 'The flow ID' }, + flowName: { type: 'string', description: 'The flow name' }, + crmProspectId: { type: 'string', description: 'The CRM prospect ID' }, + flowInstanceId: { type: 'string', description: 'The created flow instance ID' }, + flowInstanceOwnerEmail: { + type: 'string', + description: 'Email of the flow instance owner', + }, + flowInstanceOwnerFullName: { + type: 'string', + description: 'Full name of the flow instance owner', + }, + flowInstanceCreateDate: { + type: 'string', + description: 'Creation time of the flow instance in ISO-8601 format', + }, + flowInstanceStatus: { type: 'string', description: 'Status of the flow instance' }, + workspaceId: { type: 'string', description: 'Workspace ID' }, + exclusive: { + type: 'boolean', + description: 'Whether this prospect can be added to other flows', + }, + }, + }, + }, + prospectsNotAssigned: { + type: 'array', + description: 'Prospects that failed to be assigned to the flow', + items: { + type: 'object', + properties: { + flowId: { type: 'string', description: 'The flow ID' }, + crmProspectId: { type: 'string', description: 'The CRM prospect ID' }, + errorCode: { + type: 'string', + description: 'Failure reason: InvalidArgument, InvalidState, or UnexpectedError', + }, + errorMessage: { type: 'string', description: 'Human-readable failure message' }, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/gong/get_extensive_calls.ts b/apps/sim/tools/gong/get_extensive_calls.ts index eda3bcd3ff0..0ed7e8995ad 100644 --- a/apps/sim/tools/gong/get_extensive_calls.ts +++ b/apps/sim/tools/gong/get_extensive_calls.ts @@ -82,6 +82,8 @@ export const getExtensiveCallsTool: ToolConfig< const body: Record = { filter, contentSelector: { + context: 'Extended', + contextTiming: ['Now', 'TimeOfCall'], exposedFields: { parties: true, content: { diff --git a/apps/sim/tools/gong/get_prospect_flows.ts b/apps/sim/tools/gong/get_prospect_flows.ts new file mode 100644 index 00000000000..286153e9975 --- /dev/null +++ b/apps/sim/tools/gong/get_prospect_flows.ts @@ -0,0 +1,99 @@ +import type { GongGetProspectFlowsParams, GongGetProspectFlowsResponse } from '@/tools/gong/types' +import { getGongErrorMessage, parseGongIdList } from '@/tools/gong/utils' +import type { ToolConfig } from '@/tools/types' + +export const getProspectFlowsTool: ToolConfig< + GongGetProspectFlowsParams, + GongGetProspectFlowsResponse +> = { + id: 'gong_get_prospect_flows', + name: 'Gong Get Prospect Flows', + description: 'Get the Gong Engage flows currently assigned to the given CRM prospects.', + version: '1.0.0', + + params: { + accessKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Gong API Access Key', + }, + accessKeySecret: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Gong API Access Key Secret', + }, + crmProspectsIds: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Comma-separated list of CRM prospect IDs (contacts or leads) to look up', + }, + }, + + request: { + url: 'https://api.gong.io/v2/flows/prospects', + method: 'POST', + headers: (params) => ({ + 'Content-Type': 'application/json', + Authorization: `Basic ${btoa(`${params.accessKey}:${params.accessKeySecret}`)}`, + }), + body: (params) => ({ + crmProspectsIds: parseGongIdList(params.crmProspectsIds) ?? [], + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + if (!response.ok) { + throw new Error(getGongErrorMessage(data, 'Failed to get prospect flows')) + } + return { + success: true, + output: { + requestId: data.requestId ?? null, + prospectsAssigned: data.prospectsAssigned ?? [], + }, + } + }, + + outputs: { + requestId: { + type: 'string', + description: 'A Gong request reference ID for troubleshooting purposes', + optional: true, + }, + prospectsAssigned: { + type: 'array', + description: 'Flows currently assigned to the requested prospects', + items: { + type: 'object', + properties: { + flowId: { type: 'string', description: 'The flow ID' }, + flowName: { type: 'string', description: 'The flow name' }, + crmProspectId: { type: 'string', description: 'The CRM prospect ID' }, + flowInstanceId: { type: 'string', description: 'The flow instance ID' }, + flowInstanceOwnerEmail: { + type: 'string', + description: 'Email of the flow instance owner', + }, + flowInstanceOwnerFullName: { + type: 'string', + description: 'Full name of the flow instance owner', + }, + flowInstanceCreateDate: { + type: 'string', + description: 'Creation time of the flow instance in ISO-8601 format', + }, + flowInstanceStatus: { type: 'string', description: 'Status of the flow instance' }, + workspaceId: { type: 'string', description: 'Workspace ID' }, + exclusive: { + type: 'boolean', + description: 'Whether this prospect can be added to other flows', + }, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/gong/index.ts b/apps/sim/tools/gong/index.ts index 36b5e67de08..0d67c6a583c 100644 --- a/apps/sim/tools/gong/index.ts +++ b/apps/sim/tools/gong/index.ts @@ -1,6 +1,7 @@ import { aggregateActivityTool } from '@/tools/gong/aggregate_activity' import { aggregateByPeriodTool } from '@/tools/gong/aggregate_by_period' import { answeredScorecardsTool } from '@/tools/gong/answered_scorecards' +import { assignFlowProspectsTool } from '@/tools/gong/assign_flow_prospects' import { createCallTool } from '@/tools/gong/create_call' import { dayByDayActivityTool } from '@/tools/gong/day_by_day_activity' import { getCallTool } from '@/tools/gong/get_call' @@ -8,6 +9,7 @@ import { getCallTranscriptTool } from '@/tools/gong/get_call_transcript' import { getCoachingTool } from '@/tools/gong/get_coaching' import { getExtensiveCallsTool } from '@/tools/gong/get_extensive_calls' import { getFolderContentTool } from '@/tools/gong/get_folder_content' +import { getProspectFlowsTool } from '@/tools/gong/get_prospect_flows' import { getUserTool } from '@/tools/gong/get_user' import { interactionStatsTool } from '@/tools/gong/interaction_stats' import { listCallsTool } from '@/tools/gong/list_calls' @@ -19,6 +21,8 @@ import { listUsersTool } from '@/tools/gong/list_users' import { listWorkspacesTool } from '@/tools/gong/list_workspaces' import { lookupEmailTool } from '@/tools/gong/lookup_email' import { lookupPhoneTool } from '@/tools/gong/lookup_phone' +import { purgeEmailAddressTool } from '@/tools/gong/purge_email_address' +import { purgePhoneNumberTool } from '@/tools/gong/purge_phone_number' export const gongListCallsTool = listCallsTool export const gongCreateCallTool = createCallTool @@ -41,5 +45,9 @@ export const gongListFlowsTool = listFlowsTool export const gongGetCoachingTool = getCoachingTool export const gongLookupEmailTool = lookupEmailTool export const gongLookupPhoneTool = lookupPhoneTool +export const gongPurgeEmailAddressTool = purgeEmailAddressTool +export const gongPurgePhoneNumberTool = purgePhoneNumberTool +export const gongAssignFlowProspectsTool = assignFlowProspectsTool +export const gongGetProspectFlowsTool = getProspectFlowsTool export * from './types' diff --git a/apps/sim/tools/gong/interaction_stats.ts b/apps/sim/tools/gong/interaction_stats.ts index d8b16709174..b01da11cd5f 100644 --- a/apps/sim/tools/gong/interaction_stats.ts +++ b/apps/sim/tools/gong/interaction_stats.ts @@ -147,6 +147,7 @@ export const interactionStatsTool: ToolConfig< cursor: { type: 'string', description: 'Pagination cursor for the next page', + optional: true, }, }, } diff --git a/apps/sim/tools/gong/list_flows.ts b/apps/sim/tools/gong/list_flows.ts index 8200e53cfd6..6b2939e885e 100644 --- a/apps/sim/tools/gong/list_flows.ts +++ b/apps/sim/tools/gong/list_flows.ts @@ -130,6 +130,7 @@ export const listFlowsTool: ToolConfig = { + id: 'gong_purge_email_address', + name: 'Gong Purge Email Address', + description: + 'Erase all Gong data (calls, email messages, leads, contacts) referencing an email address. Asynchronous and irreversible.', + version: '1.0.0', + + params: { + accessKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Gong API Access Key', + }, + accessKeySecret: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Gong API Access Key Secret', + }, + emailAddress: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Email address whose associated data should be permanently erased from Gong', + }, + }, + + request: { + url: (params) => { + const url = new URL('https://api.gong.io/v2/data-privacy/erase-data-for-email-address') + url.searchParams.set('emailAddress', params.emailAddress.trim()) + return url.toString() + }, + method: 'POST', + headers: (params) => ({ + 'Content-Type': 'application/json', + Authorization: `Basic ${btoa(`${params.accessKey}:${params.accessKeySecret}`)}`, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + if (!response.ok) { + throw new Error(getGongErrorMessage(data, 'Failed to erase email address data')) + } + return { + success: true, + output: { + requestId: data.requestId ?? null, + }, + } + }, + + outputs: { + requestId: { + type: 'string', + description: 'A Gong request reference ID for troubleshooting purposes', + optional: true, + }, + }, +} diff --git a/apps/sim/tools/gong/purge_phone_number.ts b/apps/sim/tools/gong/purge_phone_number.ts new file mode 100644 index 00000000000..3109f0b84ab --- /dev/null +++ b/apps/sim/tools/gong/purge_phone_number.ts @@ -0,0 +1,70 @@ +import type { GongPurgePhoneNumberParams, GongPurgePhoneNumberResponse } from '@/tools/gong/types' +import { getGongErrorMessage } from '@/tools/gong/utils' +import type { ToolConfig } from '@/tools/types' + +export const purgePhoneNumberTool: ToolConfig< + GongPurgePhoneNumberParams, + GongPurgePhoneNumberResponse +> = { + id: 'gong_purge_phone_number', + name: 'Gong Purge Phone Number', + description: + 'Erase all Gong data (calls, leads, contacts) referencing a phone number. Asynchronous and irreversible.', + version: '1.0.0', + + params: { + accessKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Gong API Access Key', + }, + accessKeySecret: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Gong API Access Key Secret', + }, + phoneNumber: { + type: 'string', + required: true, + visibility: 'user-only', + description: + 'Phone number whose associated data should be permanently erased from Gong. Must include a leading "+" and country code (e.g., +14255552671)', + }, + }, + + request: { + url: (params) => { + const url = new URL('https://api.gong.io/v2/data-privacy/erase-data-for-phone-number') + url.searchParams.set('phoneNumber', params.phoneNumber.trim()) + return url.toString() + }, + method: 'POST', + headers: (params) => ({ + 'Content-Type': 'application/json', + Authorization: `Basic ${btoa(`${params.accessKey}:${params.accessKeySecret}`)}`, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + if (!response.ok) { + throw new Error(getGongErrorMessage(data, 'Failed to erase phone number data')) + } + return { + success: true, + output: { + requestId: data.requestId ?? null, + }, + } + }, + + outputs: { + requestId: { + type: 'string', + description: 'A Gong request reference ID for troubleshooting purposes', + optional: true, + }, + }, +} diff --git a/apps/sim/tools/gong/types.ts b/apps/sim/tools/gong/types.ts index 97151084576..31143bd1577 100644 --- a/apps/sim/tools/gong/types.ts +++ b/apps/sim/tools/gong/types.ts @@ -695,6 +695,76 @@ export interface GongLookupPhoneResponse extends ToolResponse { } } +/** Purge Email Address */ +export interface GongPurgeEmailAddressParams extends GongBaseParams { + emailAddress: string +} + +export interface GongPurgeEmailAddressResponse extends ToolResponse { + output: { + requestId: string | null + } +} + +/** Purge Phone Number */ +export interface GongPurgePhoneNumberParams extends GongBaseParams { + phoneNumber: string +} + +export interface GongPurgePhoneNumberResponse extends ToolResponse { + output: { + requestId: string | null + } +} + +/** Shared Engage Flow prospect assignment sub-types */ +interface GongAssignedFlow { + flowId: string + flowName: string + crmProspectId: string + flowInstanceId: string + flowInstanceOwnerEmail: string + flowInstanceOwnerFullName: string + flowInstanceCreateDate: string + flowInstanceStatus: string + workspaceId: string + exclusive: boolean +} + +interface GongAssignedFlowFailure { + flowId: string + crmProspectId: string + errorCode: string + errorMessage: string +} + +/** Assign Flow Prospects */ +export interface GongAssignFlowProspectsParams extends GongBaseParams { + flowId: string + crmProspectsIds: string + flowInstanceOwnerEmail: string +} + +export interface GongAssignFlowProspectsResponse extends ToolResponse { + output: { + requestId: string | null + prospectsAssigned: GongAssignedFlow[] + prospectsNotAssigned: GongAssignedFlowFailure[] + } +} + +/** Get Prospect Flows */ +export interface GongGetProspectFlowsParams extends GongBaseParams { + crmProspectsIds: string +} + +export interface GongGetProspectFlowsResponse extends ToolResponse { + output: { + requestId: string | null + prospectsAssigned: GongAssignedFlow[] + } +} + /** Union type for all Gong responses */ export type GongResponse = | GongListCallsResponse @@ -718,3 +788,7 @@ export type GongResponse = | GongGetCoachingResponse | GongLookupEmailResponse | GongLookupPhoneResponse + | GongPurgeEmailAddressResponse + | GongPurgePhoneNumberResponse + | GongAssignFlowProspectsResponse + | GongGetProspectFlowsResponse diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index becc85ca39b..d3826f8c2db 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -1224,6 +1224,7 @@ import { gongAggregateActivityTool, gongAggregateByPeriodTool, gongAnsweredScorecardsTool, + gongAssignFlowProspectsTool, gongCreateCallTool, gongDayByDayActivityTool, gongGetCallTool, @@ -1231,6 +1232,7 @@ import { gongGetCoachingTool, gongGetExtensiveCallsTool, gongGetFolderContentTool, + gongGetProspectFlowsTool, gongGetUserTool, gongInteractionStatsTool, gongListCallsTool, @@ -1242,6 +1244,8 @@ import { gongListWorkspacesTool, gongLookupEmailTool, gongLookupPhoneTool, + gongPurgeEmailAddressTool, + gongPurgePhoneNumberTool, } from '@/tools/gong' import { googleSearchTool } from '@/tools/google' import { @@ -4568,6 +4572,7 @@ export const tools: Record = { gong_aggregate_activity: gongAggregateActivityTool, gong_aggregate_by_period: gongAggregateByPeriodTool, gong_answered_scorecards: gongAnsweredScorecardsTool, + gong_assign_flow_prospects: gongAssignFlowProspectsTool, gong_create_call: gongCreateCallTool, gong_day_by_day_activity: gongDayByDayActivityTool, gong_get_call: gongGetCallTool, @@ -4575,6 +4580,7 @@ export const tools: Record = { gong_get_coaching: gongGetCoachingTool, gong_get_extensive_calls: gongGetExtensiveCallsTool, gong_get_folder_content: gongGetFolderContentTool, + gong_get_prospect_flows: gongGetProspectFlowsTool, gong_get_user: gongGetUserTool, gong_interaction_stats: gongInteractionStatsTool, gong_list_calls: gongListCallsTool, @@ -4586,6 +4592,8 @@ export const tools: Record = { gong_list_workspaces: gongListWorkspacesTool, gong_lookup_email: gongLookupEmailTool, gong_lookup_phone: gongLookupPhoneTool, + gong_purge_email_address: gongPurgeEmailAddressTool, + gong_purge_phone_number: gongPurgePhoneNumberTool, grafana_get_dashboard: grafanaGetDashboardTool, grafana_list_dashboards: grafanaListDashboardsTool, grafana_create_dashboard: grafanaCreateDashboardTool, From 3d1e8c4f847ea0039ed58a3472463852f01f570a Mon Sep 17 00:00:00 2001 From: Waleed Date: Thu, 2 Jul 2026 11:03:59 -0700 Subject: [PATCH 20/28] chore(ci): bump API contract boundary audit route-count baseline to 884 (#5375) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pinned totalRoutes/zodRoutes baseline (883) went stale after a legitimate new Zod-contract-compliant route merged without the required ratchet bump, breaking check:api-validation:strict on staging HEAD itself. Actual count is 884 total / 884 zod / 0 non-zod routes — a pure headcount update, no policy violation. --- scripts/check-api-validation-contracts.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index 80f935786b0..9f5d387d0a1 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries') const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors') const BASELINE = { - totalRoutes: 883, - zodRoutes: 883, + totalRoutes: 884, + zodRoutes: 884, nonZodRoutes: 0, } as const From 997740790dc1ac63a3142f6ad1fc481fffe8da3b Mon Sep 17 00:00:00 2001 From: Waleed Date: Thu, 2 Jul 2026 11:04:17 -0700 Subject: [PATCH 21/28] feat(fathom): add list meeting types tool and missing list-meetings filters (#5359) * feat(fathom): add list meeting types tool and missing list-meetings filters - Add fathom_list_meeting_types tool (GET /meeting_types) - Add missing list-meetings params: includeHighlights, meetingType, calendarInviteesDomains, calendarInviteesDomainsType - Add missing meeting response fields: meeting_type, meeting_url, shared_with, highlights - Wire new operation and filters into the Fathom block * fix(fathom): expose meeting_url/highlights in outputs, stop force-sending domain type default - Add meeting_url and highlights to list_meetings outputs schema so they're addressable from downstream blocks (Greptile P1) - Drop the forced 'all' default on calendarInviteesDomainsType so the filter is only sent when a user explicitly picks a value (Greptile P2) * fix(fathom): never send calendar_invitees_domains_type=all to the API Match the existing Fathom connector's guard (meetingType !== 'all') so the request omits the param entirely when the value is the API's own default, regardless of what the dropdown shows selected in the UI. * fix(fathom): fully expose list_meetings meeting fields in outputs schema transformResponse already returned meeting_title, scheduled/recording times, recorded_by, calendar_invitees, default_summary, transcript, action_items, and crm_matches, but outputs.meetings.items.properties only documented a curated subset, leaving these fields unaddressable from downstream workflow blocks. Complete the schema to match the full Meeting object Fathom's API returns. * fix(fathom): mark recording_id optional in list_meetings outputs transformResponse maps recording_id as meeting.recording_id ?? null and the response type already types it number | null; the outputs schema now reflects that nullability. * fix(fathom): mark calendar_invitees email optional in list_meetings outputs Fathom's docs mark Invitee.email as nullable; the outputs schema now reflects that instead of declaring it as always-present. --- .../content/docs/en/integrations/fathom.mdx | 83 ++++++ apps/sim/blocks/blocks/fathom.ts | 62 ++++- apps/sim/lib/integrations/integrations.json | 6 +- apps/sim/tools/fathom/index.ts | 2 + apps/sim/tools/fathom/list_meeting_types.ts | 96 +++++++ apps/sim/tools/fathom/list_meetings.ts | 241 +++++++++++++++++- apps/sim/tools/fathom/types.ts | 30 +++ apps/sim/tools/registry.ts | 2 + 8 files changed, 519 insertions(+), 3 deletions(-) create mode 100644 apps/sim/tools/fathom/list_meeting_types.ts diff --git a/apps/docs/content/docs/en/integrations/fathom.mdx b/apps/docs/content/docs/en/integrations/fathom.mdx index 16270807b0e..75d2f2b1104 100644 --- a/apps/docs/content/docs/en/integrations/fathom.mdx +++ b/apps/docs/content/docs/en/integrations/fathom.mdx @@ -46,10 +46,14 @@ List recent meetings recorded by the user or shared to their team. | `includeTranscript` | string | No | Include meeting transcript \(true/false\) | | `includeActionItems` | string | No | Include action items \(true/false\) | | `includeCrmMatches` | string | No | Include linked CRM matches \(true/false\) | +| `includeHighlights` | string | No | Include meeting highlights \(true/false\) | | `createdAfter` | string | No | Filter meetings created after this ISO 8601 timestamp | | `createdBefore` | string | No | Filter meetings created before this ISO 8601 timestamp | | `recordedBy` | string | No | Filter by recorder email address | | `teams` | string | No | Filter by team name | +| `meetingType` | string | No | Filter by meeting type name | +| `calendarInviteesDomains` | string | No | Filter by calendar invitee company domain \(exact match\) | +| `calendarInviteesDomainsType` | string | No | Filter by invitee domain type: all, only_internal, or one_or_more_external | | `cursor` | string | No | Pagination cursor from a previous response | #### Output @@ -58,11 +62,90 @@ List recent meetings recorded by the user or shared to their team. | --------- | ---- | ----------- | | `meetings` | array | List of meetings | | ↳ `title` | string | Meeting title | +| ↳ `meeting_title` | string | Calendar event title | +| ↳ `meeting_type` | string | Meeting type name | | ↳ `recording_id` | number | Unique recording ID | | ↳ `url` | string | URL to view the meeting | +| ↳ `meeting_url` | string | URL of the underlying video call \(Zoom, Meet, Teams, etc.\) | | ↳ `share_url` | string | Shareable URL | | ↳ `created_at` | string | Creation timestamp | +| ↳ `scheduled_start_time` | string | Scheduled start time | +| ↳ `scheduled_end_time` | string | Scheduled end time | +| ↳ `recording_start_time` | string | Recording start time | +| ↳ `recording_end_time` | string | Recording end time | | ↳ `transcript_language` | string | Transcript language | +| ↳ `calendar_invitees_domains_type` | string | Invitee domain type: only_internal or one_or_more_external | +| ↳ `shared_with` | string | Sharing scope: no_teams, single_team, multiple_teams, or all_teams | +| ↳ `recorded_by` | object | Recorder details | +| ↳ `name` | string | Name of the recorder | +| ↳ `email` | string | Email of the recorder | +| ↳ `email_domain` | string | Email domain of the recorder | +| ↳ `team` | string | Recorder team name | +| ↳ `calendar_invitees` | array | Calendar invitees for the meeting | +| ↳ `name` | string | Invitee name | +| ↳ `email` | string | Invitee email | +| ↳ `email_domain` | string | Invitee email domain | +| ↳ `is_external` | boolean | Whether the invitee is external | +| ↳ `matched_speaker_display_name` | string | Matched transcript speaker display name | +| ↳ `default_summary` | object | Meeting summary | +| ↳ `template_name` | string | Summary template name | +| ↳ `markdown_formatted` | string | Markdown-formatted summary | +| ↳ `transcript` | array | Transcript entries with speaker, text, and timestamp | +| ↳ `speaker` | object | Speaker information | +| ↳ `display_name` | string | Speaker display name | +| ↳ `matched_calendar_invitee_email` | string | Matched calendar invitee email | +| ↳ `text` | string | Transcript text | +| ↳ `timestamp` | string | Timestamp \(HH:MM:SS\) | +| ↳ `action_items` | array | Action items extracted from the meeting | +| ↳ `description` | string | Action item description | +| ↳ `user_generated` | boolean | Whether the action item was user-generated | +| ↳ `completed` | boolean | Whether the action item is completed | +| ↳ `recording_timestamp` | string | Timestamp in the recording \(HH:MM:SS\) | +| ↳ `recording_playback_url` | string | Playback URL for the action item moment | +| ↳ `assignee` | object | Assignee details | +| ↳ `name` | string | Assignee name | +| ↳ `email` | string | Assignee email | +| ↳ `team` | string | Assignee team | +| ↳ `highlights` | array | Meeting highlights with type, summary, text, and start/end time | +| ↳ `type` | string | Highlight type | +| ↳ `summary` | string | Highlight summary | +| ↳ `text` | string | Highlight text | +| ↳ `start_time` | number | Start time in seconds | +| ↳ `end_time` | number | End time in seconds | +| ↳ `crm_matches` | object | Matched CRM contacts, companies, and deals | +| ↳ `contacts` | array | Matched CRM contacts | +| ↳ `name` | string | Contact name | +| ↳ `email` | string | Contact email | +| ↳ `record_url` | string | CRM record URL | +| ↳ `companies` | array | Matched CRM companies | +| ↳ `name` | string | Company name | +| ↳ `record_url` | string | CRM record URL | +| ↳ `deals` | array | Matched CRM deals | +| ↳ `name` | string | Deal name | +| ↳ `amount` | number | Deal amount | +| ↳ `record_url` | string | CRM record URL | +| ↳ `error` | string | CRM match error, if any | +| `next_cursor` | string | Pagination cursor for next page | + +### `fathom_list_meeting_types` + +List meeting types configured in your Fathom organization. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Fathom API Key | +| `cursor` | string | No | Pagination cursor from a previous response | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `meetingTypes` | array | List of meeting types | +| ↳ `name` | string | Meeting type name | +| ↳ `status` | string | Meeting type status: active or inactive | +| ↳ `created_at` | string | Date the meeting type was created | | `next_cursor` | string | Pagination cursor for next page | ### `fathom_get_summary` diff --git a/apps/sim/blocks/blocks/fathom.ts b/apps/sim/blocks/blocks/fathom.ts index 2bd604f6a28..ed14925a544 100644 --- a/apps/sim/blocks/blocks/fathom.ts +++ b/apps/sim/blocks/blocks/fathom.ts @@ -24,6 +24,7 @@ export const FathomBlock: BlockConfig = { type: 'dropdown', options: [ { label: 'List Meetings', id: 'fathom_list_meetings' }, + { label: 'List Meeting Types', id: 'fathom_list_meeting_types' }, { label: 'Get Summary', id: 'fathom_get_summary' }, { label: 'Get Transcript', id: 'fathom_get_transcript' }, { label: 'List Team Members', id: 'fathom_list_team_members' }, @@ -83,6 +84,17 @@ export const FathomBlock: BlockConfig = { value: () => 'false', condition: { field: 'operation', value: 'fathom_list_meetings' }, }, + { + id: 'includeHighlights', + title: 'Include Highlights', + type: 'dropdown', + options: [ + { label: 'No', id: 'false' }, + { label: 'Yes', id: 'true' }, + ], + value: () => 'false', + condition: { field: 'operation', value: 'fathom_list_meetings' }, + }, { id: 'createdAfter', title: 'Created After', @@ -128,6 +140,34 @@ export const FathomBlock: BlockConfig = { }, mode: 'advanced', }, + { + id: 'meetingType', + title: 'Meeting Type', + type: 'short-input', + placeholder: 'Filter by meeting type name', + condition: { field: 'operation', value: 'fathom_list_meetings' }, + mode: 'advanced', + }, + { + id: 'calendarInviteesDomains', + title: 'Invitee Domain', + type: 'short-input', + placeholder: 'Filter by calendar invitee company domain', + condition: { field: 'operation', value: 'fathom_list_meetings' }, + mode: 'advanced', + }, + { + id: 'calendarInviteesDomainsType', + title: 'Invitee Domain Type', + type: 'dropdown', + options: [ + { label: 'All', id: 'all' }, + { label: 'Only Internal', id: 'only_internal' }, + { label: 'One or More External', id: 'one_or_more_external' }, + ], + condition: { field: 'operation', value: 'fathom_list_meetings' }, + mode: 'advanced', + }, { id: 'cursor', title: 'Pagination Cursor', @@ -135,7 +175,12 @@ export const FathomBlock: BlockConfig = { placeholder: 'Cursor from a previous response', condition: { field: 'operation', - value: ['fathom_list_meetings', 'fathom_list_team_members', 'fathom_list_teams'], + value: [ + 'fathom_list_meetings', + 'fathom_list_meeting_types', + 'fathom_list_team_members', + 'fathom_list_teams', + ], }, mode: 'advanced', }, @@ -162,6 +207,7 @@ export const FathomBlock: BlockConfig = { tools: { access: [ 'fathom_list_meetings', + 'fathom_list_meeting_types', 'fathom_get_summary', 'fathom_get_transcript', 'fathom_list_team_members', @@ -187,6 +233,10 @@ export const FathomBlock: BlockConfig = { type: 'string', description: 'Include linked CRM matches in meetings response', }, + includeHighlights: { + type: 'string', + description: 'Include highlights in meetings response', + }, createdAfter: { type: 'string', description: 'Filter meetings created after this timestamp' }, createdBefore: { type: 'string', @@ -194,10 +244,20 @@ export const FathomBlock: BlockConfig = { }, recordedBy: { type: 'string', description: 'Filter by recorder email' }, teams: { type: 'string', description: 'Filter by team name' }, + meetingType: { type: 'string', description: 'Filter by meeting type name' }, + calendarInviteesDomains: { + type: 'string', + description: 'Filter by calendar invitee company domain', + }, + calendarInviteesDomainsType: { + type: 'string', + description: 'Filter by invitee domain type', + }, cursor: { type: 'string', description: 'Pagination cursor for next page' }, }, outputs: { meetings: { type: 'json', description: 'List of meetings' }, + meetingTypes: { type: 'json', description: 'List of meeting types' }, template_name: { type: 'string', description: 'Summary template name' }, markdown_formatted: { type: 'string', description: 'Markdown-formatted summary' }, transcript: { type: 'json', description: 'Meeting transcript entries' }, diff --git a/apps/sim/lib/integrations/integrations.json b/apps/sim/lib/integrations/integrations.json index 03f07029836..f542ae6daea 100644 --- a/apps/sim/lib/integrations/integrations.json +++ b/apps/sim/lib/integrations/integrations.json @@ -5086,6 +5086,10 @@ "name": "List Meetings", "description": "List recent meetings recorded by the user or shared to their team." }, + { + "name": "List Meeting Types", + "description": "List meeting types configured in your Fathom organization." + }, { "name": "Get Summary", "description": "Get the call summary for a specific meeting recording." @@ -5103,7 +5107,7 @@ "description": "List teams in your Fathom organization." } ], - "operationCount": 5, + "operationCount": 6, "triggers": [ { "id": "fathom_new_meeting", diff --git a/apps/sim/tools/fathom/index.ts b/apps/sim/tools/fathom/index.ts index ac140eb94e9..75c3097e6f5 100644 --- a/apps/sim/tools/fathom/index.ts +++ b/apps/sim/tools/fathom/index.ts @@ -1,5 +1,6 @@ import { getSummaryTool } from '@/tools/fathom/get_summary' import { getTranscriptTool } from '@/tools/fathom/get_transcript' +import { listMeetingTypesTool } from '@/tools/fathom/list_meeting_types' import { listMeetingsTool } from '@/tools/fathom/list_meetings' import { listTeamMembersTool } from '@/tools/fathom/list_team_members' import { listTeamsTool } from '@/tools/fathom/list_teams' @@ -7,6 +8,7 @@ import { listTeamsTool } from '@/tools/fathom/list_teams' export const fathomGetSummaryTool = getSummaryTool export const fathomGetTranscriptTool = getTranscriptTool export const fathomListMeetingsTool = listMeetingsTool +export const fathomListMeetingTypesTool = listMeetingTypesTool export const fathomListTeamMembersTool = listTeamMembersTool export const fathomListTeamsTool = listTeamsTool diff --git a/apps/sim/tools/fathom/list_meeting_types.ts b/apps/sim/tools/fathom/list_meeting_types.ts new file mode 100644 index 00000000000..6f77c8d7adc --- /dev/null +++ b/apps/sim/tools/fathom/list_meeting_types.ts @@ -0,0 +1,96 @@ +import type { + FathomListMeetingTypesParams, + FathomListMeetingTypesResponse, +} from '@/tools/fathom/types' +import type { ToolConfig } from '@/tools/types' + +export const listMeetingTypesTool: ToolConfig< + FathomListMeetingTypesParams, + FathomListMeetingTypesResponse +> = { + id: 'fathom_list_meeting_types', + name: 'Fathom List Meeting Types', + description: 'List meeting types configured in your Fathom organization.', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Fathom API Key', + }, + cursor: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Pagination cursor from a previous response', + }, + }, + + request: { + url: (params) => { + const url = new URL('https://api.fathom.ai/external/v1/meeting_types') + if (params.cursor) url.searchParams.append('cursor', params.cursor) + return url.toString() + }, + method: 'GET', + headers: (params) => ({ + 'X-Api-Key': params.apiKey, + 'Content-Type': 'application/json', + }), + }, + + transformResponse: async (response: Response) => { + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + return { + success: false, + error: + (errorData as Record).message || + `Fathom API error: ${response.status} ${response.statusText}`, + output: { + meetingTypes: [], + next_cursor: null, + }, + } + } + + const data = await response.json() + const meetingTypes = (data.items ?? []).map( + (meetingType: { name?: string; status?: string; created_at?: string }) => ({ + name: meetingType.name ?? '', + status: meetingType.status ?? '', + created_at: meetingType.created_at ?? '', + }) + ) + + return { + success: true, + output: { + meetingTypes, + next_cursor: data.next_cursor ?? null, + }, + } + }, + + outputs: { + meetingTypes: { + type: 'array', + description: 'List of meeting types', + items: { + type: 'object', + properties: { + name: { type: 'string', description: 'Meeting type name' }, + status: { type: 'string', description: 'Meeting type status: active or inactive' }, + created_at: { type: 'string', description: 'Date the meeting type was created' }, + }, + }, + }, + next_cursor: { + type: 'string', + description: 'Pagination cursor for next page', + optional: true, + }, + }, +} diff --git a/apps/sim/tools/fathom/list_meetings.ts b/apps/sim/tools/fathom/list_meetings.ts index 5fff960ea4f..c2e06b8f77c 100644 --- a/apps/sim/tools/fathom/list_meetings.ts +++ b/apps/sim/tools/fathom/list_meetings.ts @@ -38,6 +38,12 @@ export const listMeetingsTool: ToolConfig & { recorded_by?: Record }) => ({ title: meeting.title ?? '', meeting_title: meeting.meeting_title ?? null, + meeting_type: meeting.meeting_type ?? null, recording_id: meeting.recording_id ?? null, url: meeting.url ?? '', + meeting_url: meeting.meeting_url ?? null, share_url: meeting.share_url ?? '', created_at: meeting.created_at ?? '', scheduled_start_time: meeting.scheduled_start_time ?? null, @@ -124,6 +159,7 @@ export const listMeetingsTool: ToolConfig | null + highlights: Array<{ + type: string + summary: string | null + text: string + start_time: number + end_time: number + }> | null crm_matches: { contacts: Array<{ name: string; email: string; record_url: string }> companies: Array<{ name: string; record_url: string }> @@ -119,9 +133,25 @@ export interface FathomListTeamsResponse extends ToolResponse { } } +export interface FathomListMeetingTypesParams extends FathomBaseParams { + cursor?: string +} + +export interface FathomListMeetingTypesResponse extends ToolResponse { + output: { + meetingTypes: Array<{ + name: string + status: string + created_at: string + }> + next_cursor: string | null + } +} + export type FathomResponse = | FathomListMeetingsResponse | FathomGetSummaryResponse | FathomGetTranscriptResponse | FathomListTeamMembersResponse | FathomListTeamsResponse + | FathomListMeetingTypesResponse diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index d3826f8c2db..1154d6c593e 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -916,6 +916,7 @@ import { fathomGetSummaryTool, fathomGetTranscriptTool, fathomListMeetingsTool, + fathomListMeetingTypesTool, fathomListTeamMembersTool, fathomListTeamsTool, } from '@/tools/fathom' @@ -6963,6 +6964,7 @@ export const tools: Record = { elevenlabs_speech_to_speech: elevenLabsSpeechToSpeechTool, elevenlabs_audio_isolation: elevenLabsAudioIsolationTool, fathom_list_meetings: fathomListMeetingsTool, + fathom_list_meeting_types: fathomListMeetingTypesTool, fathom_get_summary: fathomGetSummaryTool, fathom_get_transcript: fathomGetTranscriptTool, fathom_list_team_members: fathomListTeamMembersTool, From 11c7a36f1125d887492b758d0e5e1ed6a59ef60f Mon Sep 17 00:00:00 2001 From: Waleed Date: Thu, 2 Jul 2026 11:14:14 -0700 Subject: [PATCH 22/28] feat(hex): expand API coverage, fix ID trimming, add cursor pagination (#5372) * feat(hex): expand API coverage, fix ID trimming, add cursor pagination - Add hex_update_collection, hex_create_group, hex_update_group, hex_delete_group, hex_deactivate_user tools + block wiring - Add missing run_project fields (viewId, notifications) and get_project_runs runTriggerFilter - Add after/before cursor pagination to list_projects, list_groups, list_data_connections, list_collections - Add .trim() on all interpolated ID path params to guard against copy-paste whitespace * fix(hex): guard JSON.parse on user-supplied array/object params Wrap JSON.parse calls for memberUserIds, addUserIds, removeUserIds, inputParams, and notifications in try/catch so malformed input throws a clear error instead of an opaque parse exception. * fix(hex): forward empty collection description on update update_collection's params() mapping only copied collectionDescription when truthy, so clearing it to an empty string in the UI never reached the API. Untouched fields resolve to null (not undefined), so use a loose null check to distinguish "cleared" from "never touched" without sending description: null on every unrelated update. * fix(hex): close remaining API coverage gaps found via raw OpenAPI spec Pulled Hex's actual OpenAPI spec (static.hex.site/openapi.json) as ground truth for a final verification pass: - list_users was missing after/before cursor pagination and the userIds filter, which the spec confirms it supports (an earlier doc-summary pass had incorrectly flagged this as unverifiable) - list_users response also exposes lastLoginDate per user, not previously surfaced - list_projects was missing includeComponents, includeTrashed, creatorEmail, ownerEmail, collectionId, categories, sortBy, and sortDirection filters that the spec confirms are real query params * fix(hex): trim remaining user-suppliable IDs for consistency Trim collectionId (list_projects filter), viewId (run_project body), and group member UUIDs (create_group/update_group) to match the .trim() convention already applied to path-segment IDs elsewhere. * fix(hex): validate parsed JSON is actually an array before iterating categories, memberUserIds, addUserIds, and removeUserIds could parse as valid JSON that isn't an array (e.g. an object), which would throw an opaque "not iterable"/"map is not a function" error deeper in the call. Validate Array.isArray after parsing and fail with a clear message instead. * fix(hex): validate array element types, send explicit ALL trigger filter - notifications now gets the same Array.isArray guard already applied to categories/memberUserIds/addUserIds/removeUserIds - member/category array elements are now validated as strings before .trim()/append, instead of crashing on non-string entries - runTriggerFilter "All" option now sends the documented ALL enum value explicitly instead of relying on omission --- apps/sim/blocks/blocks/hex.ts | 336 +++++++++++++++++++- apps/sim/tools/hex/cancel_run.ts | 2 +- apps/sim/tools/hex/create_group.ts | 78 +++++ apps/sim/tools/hex/deactivate_user.ts | 60 ++++ apps/sim/tools/hex/delete_group.ts | 60 ++++ apps/sim/tools/hex/get_collection.ts | 2 +- apps/sim/tools/hex/get_data_connection.ts | 3 +- apps/sim/tools/hex/get_group.ts | 2 +- apps/sim/tools/hex/get_project.ts | 2 +- apps/sim/tools/hex/get_project_runs.ts | 17 +- apps/sim/tools/hex/get_queried_tables.ts | 2 +- apps/sim/tools/hex/get_run_status.ts | 2 +- apps/sim/tools/hex/index.ts | 10 + apps/sim/tools/hex/list_collections.ts | 22 ++ apps/sim/tools/hex/list_data_connections.ts | 22 ++ apps/sim/tools/hex/list_groups.ts | 22 ++ apps/sim/tools/hex/list_projects.ts | 94 ++++++ apps/sim/tools/hex/list_users.ts | 35 ++ apps/sim/tools/hex/run_project.ts | 43 ++- apps/sim/tools/hex/types.ts | 110 +++++++ apps/sim/tools/hex/update_collection.ts | 85 +++++ apps/sim/tools/hex/update_group.ts | 106 ++++++ apps/sim/tools/hex/update_project.ts | 2 +- apps/sim/tools/registry.ts | 10 + 24 files changed, 1101 insertions(+), 26 deletions(-) create mode 100644 apps/sim/tools/hex/create_group.ts create mode 100644 apps/sim/tools/hex/deactivate_user.ts create mode 100644 apps/sim/tools/hex/delete_group.ts create mode 100644 apps/sim/tools/hex/update_collection.ts create mode 100644 apps/sim/tools/hex/update_group.ts diff --git a/apps/sim/blocks/blocks/hex.ts b/apps/sim/blocks/blocks/hex.ts index 2bb36f75ef6..76cf338782c 100644 --- a/apps/sim/blocks/blocks/hex.ts +++ b/apps/sim/blocks/blocks/hex.ts @@ -8,7 +8,7 @@ export const HexBlock: BlockConfig = { name: 'Hex', description: 'Run and manage Hex projects', longDescription: - 'Integrate Hex into your workflow. Run projects, check run status, manage collections and groups, list users, and view data connections. Requires a Hex API token.', + 'Integrate Hex into your workflow. Run projects, check run status, manage collections and groups (including membership and deactivating users), list users, and view data connections. Requires a Hex API token.', docsLink: 'https://docs.sim.ai/integrations/hex', category: 'tools', integrationType: IntegrationType.Analytics, @@ -36,8 +36,13 @@ export const HexBlock: BlockConfig = { { label: 'List Collections', id: 'list_collections' }, { label: 'Get Collection', id: 'get_collection' }, { label: 'Create Collection', id: 'create_collection' }, + { label: 'Update Collection', id: 'update_collection' }, { label: 'List Data Connections', id: 'list_data_connections' }, { label: 'Get Data Connection', id: 'get_data_connection' }, + { label: 'Create Group', id: 'create_group' }, + { label: 'Update Group', id: 'update_group' }, + { label: 'Delete Group', id: 'delete_group' }, + { label: 'Deactivate User', id: 'deactivate_user' }, ], value: () => 'run_project', }, @@ -107,6 +112,39 @@ Example: generationType: 'json-object', }, }, + { + id: 'viewId', + title: 'Saved View ID', + type: 'short-input', + placeholder: 'Enter a SavedView UUID (optional)', + condition: { field: 'operation', value: 'run_project' }, + mode: 'advanced', + }, + { + id: 'notifications', + title: 'Notifications', + type: 'code', + placeholder: '[{"type": "FAILURE", "slackChannelIds": ["C0123456789"]}]', + condition: { field: 'operation', value: 'run_project' }, + mode: 'advanced', + wandConfig: { + enabled: true, + maintainHistory: true, + prompt: `You are an expert at creating Hex run notification configs. +Generate ONLY the raw JSON array based on the user's request. +The output MUST be a single, valid JSON array, starting with [ and ending with ]. + +Current value: {context} + +Do not include any explanations, markdown formatting, or other text outside the JSON array. +Each item's "type" must be one of ALL, SUCCESS, or FAILURE. Optional fields: includeSuccessScreenshot (boolean), slackChannelIds, userIds, groupIds (arrays of strings). + +Example: +[{"type": "FAILURE", "slackChannelIds": ["C0123456789"], "includeSuccessScreenshot": false}]`, + placeholder: 'Describe who should be notified and when...', + generationType: 'json-object', + }, + }, { id: 'projectStatus', title: 'Status', @@ -130,28 +168,119 @@ Example: value: () => '', condition: { field: 'operation', value: 'get_project_runs' }, }, + { + id: 'runTriggerFilter', + title: 'Trigger Filter', + type: 'dropdown', + options: [ + { label: 'All', id: 'ALL' }, + { label: 'API', id: 'API' }, + { label: 'Scheduled', id: 'SCHEDULED' }, + { label: 'App Refresh', id: 'APP_REFRESH' }, + ], + value: () => 'ALL', + condition: { field: 'operation', value: 'get_project_runs' }, + mode: 'advanced', + }, { id: 'groupIdInput', title: 'Group ID', type: 'short-input', placeholder: 'Enter group UUID', - condition: { field: 'operation', value: 'get_group' }, - required: { field: 'operation', value: 'get_group' }, + condition: { field: 'operation', value: ['get_group', 'update_group', 'delete_group'] }, + required: { field: 'operation', value: ['get_group', 'update_group', 'delete_group'] }, + }, + { + id: 'groupName', + title: 'Group Name', + type: 'short-input', + placeholder: 'Enter group name', + condition: { field: 'operation', value: ['create_group', 'update_group'] }, + required: { field: 'operation', value: 'create_group' }, + }, + { + id: 'groupMemberUserIds', + title: 'Initial Member User IDs', + type: 'code', + placeholder: '["uuid1", "uuid2"]', + condition: { field: 'operation', value: 'create_group' }, + mode: 'advanced', + wandConfig: { + enabled: true, + maintainHistory: true, + prompt: `You are an expert at creating JSON arrays of user UUIDs. +Generate ONLY the raw JSON array of user ID strings based on the user's request. +The output MUST be a single, valid JSON array of strings, starting with [ and ending with ]. + +Current value: {context} + +Do not include any explanations, markdown formatting, or other text outside the JSON array. + +Example: +["a1b2c3d4-0000-0000-0000-000000000000", "e5f6a7b8-0000-0000-0000-000000000000"]`, + placeholder: 'Describe which users to add...', + generationType: 'json-object', + }, + }, + { + id: 'groupAddUserIds', + title: 'Add Member User IDs', + type: 'code', + placeholder: '["uuid1", "uuid2"]', + condition: { field: 'operation', value: 'update_group' }, + mode: 'advanced', + wandConfig: { + enabled: true, + maintainHistory: true, + prompt: `You are an expert at creating JSON arrays of user UUIDs. +Generate ONLY the raw JSON array of user ID strings to add based on the user's request. +The output MUST be a single, valid JSON array of strings, starting with [ and ending with ]. + +Current value: {context} + +Do not include any explanations, markdown formatting, or other text outside the JSON array.`, + placeholder: 'Describe which users to add...', + generationType: 'json-object', + }, + }, + { + id: 'groupRemoveUserIds', + title: 'Remove Member User IDs', + type: 'code', + placeholder: '["uuid1", "uuid2"]', + condition: { field: 'operation', value: 'update_group' }, + mode: 'advanced', + wandConfig: { + enabled: true, + maintainHistory: true, + prompt: `You are an expert at creating JSON arrays of user UUIDs. +Generate ONLY the raw JSON array of user ID strings to remove based on the user's request. +The output MUST be a single, valid JSON array of strings, starting with [ and ending with ]. + +Current value: {context} + +Do not include any explanations, markdown formatting, or other text outside the JSON array.`, + placeholder: 'Describe which users to remove...', + generationType: 'json-object', + }, }, { id: 'collectionId', title: 'Collection ID', type: 'short-input', placeholder: 'Enter collection UUID', - condition: { field: 'operation', value: 'get_collection' }, - required: { field: 'operation', value: 'get_collection' }, + condition: { + field: 'operation', + value: ['get_collection', 'update_collection', 'list_projects'], + }, + required: { field: 'operation', value: ['get_collection', 'update_collection'] }, }, { id: 'collectionName', title: 'Collection Name', type: 'short-input', placeholder: 'Enter collection name', - condition: { field: 'operation', value: 'create_collection' }, + condition: { field: 'operation', value: ['create_collection', 'update_collection'] }, required: { field: 'operation', value: 'create_collection' }, }, { @@ -159,7 +288,7 @@ Example: title: 'Description', type: 'long-input', placeholder: 'Optional description for the collection', - condition: { field: 'operation', value: 'create_collection' }, + condition: { field: 'operation', value: ['create_collection', 'update_collection'] }, }, { id: 'dataConnectionId', @@ -169,6 +298,14 @@ Example: condition: { field: 'operation', value: 'get_data_connection' }, required: { field: 'operation', value: 'get_data_connection' }, }, + { + id: 'userId', + title: 'User ID', + type: 'short-input', + placeholder: 'Enter user UUID', + condition: { field: 'operation', value: 'deactivate_user' }, + required: { field: 'operation', value: 'deactivate_user' }, + }, { id: 'apiKey', title: 'API Key', @@ -240,6 +377,20 @@ Example: condition: { field: 'operation', value: 'list_projects' }, mode: 'advanced', }, + { + id: 'includeComponents', + title: 'Include Components', + type: 'switch', + condition: { field: 'operation', value: 'list_projects' }, + mode: 'advanced', + }, + { + id: 'includeTrashed', + title: 'Include Trashed', + type: 'switch', + condition: { field: 'operation', value: 'list_projects' }, + mode: 'advanced', + }, { id: 'statusFilter', title: 'Status Filter', @@ -253,6 +404,57 @@ Example: condition: { field: 'operation', value: 'list_projects' }, mode: 'advanced', }, + { + id: 'creatorEmail', + title: 'Creator Email', + type: 'short-input', + placeholder: 'Filter by creator email (optional)', + condition: { field: 'operation', value: 'list_projects' }, + mode: 'advanced', + }, + { + id: 'ownerEmail', + title: 'Owner Email', + type: 'short-input', + placeholder: 'Filter by owner email (optional)', + condition: { field: 'operation', value: 'list_projects' }, + mode: 'advanced', + }, + { + id: 'categories', + title: 'Categories', + type: 'code', + placeholder: '["Marketing", "Finance"]', + condition: { field: 'operation', value: 'list_projects' }, + mode: 'advanced', + }, + { + id: 'sortBy', + title: 'Sort By', + type: 'dropdown', + options: [ + { label: 'Default', id: '' }, + { label: 'Created At', id: 'CREATED_AT' }, + { label: 'Last Edited At', id: 'LAST_EDITED_AT' }, + { label: 'Last Published At', id: 'LAST_PUBLISHED_AT' }, + ], + value: () => '', + condition: { field: 'operation', value: 'list_projects' }, + mode: 'advanced', + }, + { + id: 'sortDirection', + title: 'Sort Direction', + type: 'dropdown', + options: [ + { label: 'Default', id: '' }, + { label: 'Ascending', id: 'ASC' }, + { label: 'Descending', id: 'DESC' }, + ], + value: () => '', + condition: { field: 'operation', value: 'list_projects' }, + mode: 'advanced', + }, { id: 'groupId', title: 'Filter by Group', @@ -261,12 +463,57 @@ Example: condition: { field: 'operation', value: 'list_users' }, mode: 'advanced', }, + { + id: 'userIds', + title: 'Filter by User IDs', + type: 'short-input', + placeholder: 'Comma-separated user UUIDs (optional)', + condition: { field: 'operation', value: 'list_users' }, + mode: 'advanced', + }, + { + id: 'after', + title: 'After Cursor', + type: 'short-input', + placeholder: 'Cursor for the next page', + condition: { + field: 'operation', + value: [ + 'list_projects', + 'list_groups', + 'list_collections', + 'list_data_connections', + 'list_users', + ], + }, + mode: 'advanced', + }, + { + id: 'before', + title: 'Before Cursor', + type: 'short-input', + placeholder: 'Cursor for the previous page', + condition: { + field: 'operation', + value: [ + 'list_projects', + 'list_groups', + 'list_collections', + 'list_data_connections', + 'list_users', + ], + }, + mode: 'advanced', + }, ], tools: { access: [ 'hex_cancel_run', 'hex_create_collection', + 'hex_create_group', + 'hex_deactivate_user', + 'hex_delete_group', 'hex_get_collection', 'hex_get_data_connection', 'hex_get_group', @@ -280,6 +527,8 @@ Example: 'hex_list_projects', 'hex_list_users', 'hex_run_project', + 'hex_update_collection', + 'hex_update_group', 'hex_update_project', ], config: { @@ -313,10 +562,20 @@ Example: return 'hex_get_collection' case 'create_collection': return 'hex_create_collection' + case 'update_collection': + return 'hex_update_collection' case 'list_data_connections': return 'hex_list_data_connections' case 'get_data_connection': return 'hex_get_data_connection' + case 'create_group': + return 'hex_create_group' + case 'update_group': + return 'hex_update_group' + case 'delete_group': + return 'hex_delete_group' + case 'deactivate_user': + return 'hex_deactivate_user' default: return 'hex_run_project' } @@ -330,11 +589,26 @@ Example: if (op === 'update_project' && params.projectStatus) result.status = params.projectStatus if (op === 'get_project_runs' && params.runStatusFilter) result.statusFilter = params.runStatusFilter - if (op === 'get_group' && params.groupIdInput) result.groupId = params.groupIdInput + if ( + (op === 'get_group' || op === 'update_group' || op === 'delete_group') && + params.groupIdInput + ) + result.groupId = params.groupIdInput if (op === 'list_users' && params.groupId) result.groupId = params.groupId - if (op === 'create_collection' && params.collectionName) result.name = params.collectionName + if ((op === 'create_collection' || op === 'update_collection') && params.collectionName) + result.name = params.collectionName if (op === 'create_collection' && params.collectionDescription) result.description = params.collectionDescription + if (op === 'update_collection' && params.collectionDescription != null) + result.description = params.collectionDescription + if ((op === 'create_group' || op === 'update_group') && params.groupName) + result.name = params.groupName + if (op === 'create_group' && params.groupMemberUserIds) + result.memberUserIds = params.groupMemberUserIds + if (op === 'update_group' && params.groupAddUserIds) + result.addUserIds = params.groupAddUserIds + if (op === 'update_group' && params.groupRemoveUserIds) + result.removeUserIds = params.groupRemoveUserIds return result }, @@ -360,21 +634,42 @@ Example: type: 'boolean', description: 'Use cached SQL results instead of re-running queries', }, + viewId: { type: 'string', description: 'SavedView UUID to use for the project run' }, + notifications: { + type: 'json', + description: 'Notification details to deliver once the run completes', + }, projectStatus: { type: 'string', description: 'New project status name (custom workspace status label)', }, limit: { type: 'number', description: 'Max number of results to return' }, offset: { type: 'number', description: 'Offset for paginated results' }, + after: { type: 'string', description: 'Cursor to fetch results after' }, + before: { type: 'string', description: 'Cursor to fetch results before' }, includeArchived: { type: 'boolean', description: 'Include archived projects' }, + includeComponents: { type: 'boolean', description: 'Include components in results' }, + includeTrashed: { type: 'boolean', description: 'Include trashed projects in results' }, statusFilter: { type: 'string', description: 'Filter projects by status' }, + creatorEmail: { type: 'string', description: 'Filter projects by creator email' }, + ownerEmail: { type: 'string', description: 'Filter projects by owner email' }, + categories: { type: 'json', description: 'Filter projects by category names' }, + sortBy: { type: 'string', description: 'Sort field for list results' }, + sortDirection: { type: 'string', description: 'Sort direction for list results' }, runStatusFilter: { type: 'string', description: 'Filter runs by status' }, + runTriggerFilter: { type: 'string', description: 'Filter runs by trigger source' }, groupId: { type: 'string', description: 'Filter users by group UUID' }, - groupIdInput: { type: 'string', description: 'Group UUID for get group' }, + userIds: { type: 'string', description: 'Comma-separated user UUIDs to filter by' }, + groupIdInput: { type: 'string', description: 'Group UUID for get/update/delete group' }, + groupName: { type: 'string', description: 'Group name' }, + groupMemberUserIds: { type: 'json', description: 'Initial member user UUIDs for new group' }, + groupAddUserIds: { type: 'json', description: 'User UUIDs to add to the group' }, + groupRemoveUserIds: { type: 'json', description: 'User UUIDs to remove from the group' }, collectionId: { type: 'string', description: 'Collection UUID' }, collectionName: { type: 'string', description: 'Collection name' }, collectionDescription: { type: 'string', description: 'Collection description' }, dataConnectionId: { type: 'string', description: 'Data connection UUID' }, + userId: { type: 'string', description: 'User UUID' }, }, outputs: { @@ -415,7 +710,10 @@ Example: description: 'List of runs with runId, status, runUrl, startTime, endTime, elapsedTime, projectVersion', }, - users: { type: 'json', description: 'List of users with id, name, email, role' }, + users: { + type: 'json', + description: 'List of users with id, name, email, role, lastLoginDate', + }, groups: { type: 'json', description: 'List of groups with id, name, createdAt' }, collections: { type: 'json', @@ -437,8 +735,15 @@ Example: creator: { type: 'json', description: 'Creator details ({ email, id })' }, owner: { type: 'json', description: 'Owner details ({ email })' }, total: { type: 'number', description: 'Total results returned' }, - // Cancel output + // Cancel / delete / deactivate output success: { type: 'boolean', description: 'Whether the operation succeeded' }, + groupId: { type: 'string', description: 'Group UUID' }, + userId: { type: 'string', description: 'User UUID' }, + // Pagination + nextPage: { type: 'string', description: 'Cursor for the next page of runs' }, + previousPage: { type: 'string', description: 'Cursor for the previous page of runs' }, + after: { type: 'string', description: 'Cursor for the next page of results' }, + before: { type: 'string', description: 'Cursor for the previous page of results' }, // Data connection flags connectViaSsh: { type: 'boolean', description: 'SSH tunneling enabled' }, includeMagic: { type: 'boolean', description: 'Magic AI features enabled' }, @@ -546,5 +851,12 @@ export const HexBlockMeta = { content: '# Inventory Projects\n\nMap what projects and data sources exist in the workspace.\n\n## Steps\n1. List projects and capture IDs, names, and owners.\n2. List collections and get details to see how projects are grouped.\n3. List data connections to map which sources power the projects.\n4. Cross-reference projects to their collections and data connections.\n\n## Output\nReturn an inventory of projects grouped by collection, each annotated with its data connections. Useful for governance and cleanup.', }, + { + name: 'onboard-offboard-teammate', + description: + 'Add a new teammate to the right Hex groups on hire, or deactivate and remove them on departure.', + content: + '# Onboard/Offboard Teammate\n\nManage workspace access as people join or leave the team.\n\n## Steps\n1. List users to resolve the target user by name or email, and list groups to resolve the relevant group by name.\n2. For onboarding: add the user to the appropriate group(s) via group update.\n3. For offboarding: remove the user from their groups via group update, then deactivate the user account.\n4. Confirm the change by getting the group or listing users filtered by group.\n\n## Output\nReturn the user and group IDs affected and the action taken (added, removed, deactivated). Flag if the user or group could not be resolved.', + }, ], } as const satisfies BlockMeta diff --git a/apps/sim/tools/hex/cancel_run.ts b/apps/sim/tools/hex/cancel_run.ts index 17c65944c3a..f7a9646b495 100644 --- a/apps/sim/tools/hex/cancel_run.ts +++ b/apps/sim/tools/hex/cancel_run.ts @@ -30,7 +30,7 @@ export const cancelRunTool: ToolConfig request: { url: (params) => - `https://app.hex.tech/api/v1/projects/${params.projectId}/runs/${params.runId}`, + `https://app.hex.tech/api/v1/projects/${params.projectId.trim()}/runs/${params.runId.trim()}`, method: 'DELETE', headers: (params) => ({ Authorization: `Bearer ${params.apiKey}`, diff --git a/apps/sim/tools/hex/create_group.ts b/apps/sim/tools/hex/create_group.ts new file mode 100644 index 00000000000..cac1e503b3e --- /dev/null +++ b/apps/sim/tools/hex/create_group.ts @@ -0,0 +1,78 @@ +import type { HexCreateGroupParams, HexCreateGroupResponse } from '@/tools/hex/types' +import type { ToolConfig } from '@/tools/types' + +export const createGroupTool: ToolConfig = { + id: 'hex_create_group', + name: 'Hex Create Group', + description: 'Create a new group in the Hex workspace, optionally with initial members.', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Hex API token (Personal or Workspace)', + }, + name: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Name for the new group', + }, + memberUserIds: { + type: 'json', + required: false, + visibility: 'user-or-llm', + description: + 'JSON array of user UUIDs to add as initial group members (e.g., ["uuid1", "uuid2"])', + }, + }, + + request: { + url: 'https://app.hex.tech/api/v1/groups', + method: 'POST', + headers: (params) => ({ + Authorization: `Bearer ${params.apiKey}`, + 'Content-Type': 'application/json', + }), + body: (params) => { + const body: Record = { name: params.name } + if (params.memberUserIds) { + let userIds: unknown + try { + userIds = + typeof params.memberUserIds === 'string' + ? JSON.parse(params.memberUserIds) + : params.memberUserIds + } catch { + throw new Error('memberUserIds must be a valid JSON array of user UUID strings') + } + if (!Array.isArray(userIds) || !userIds.every((id) => typeof id === 'string')) { + throw new Error('memberUserIds must be a valid JSON array of user UUID strings') + } + body.members = { users: userIds.map((id: string) => ({ id: id.trim() })) } + } + return body + }, + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + return { + success: true, + output: { + id: data.id ?? null, + name: data.name ?? null, + createdAt: data.createdAt ?? null, + }, + } + }, + + outputs: { + id: { type: 'string', description: 'Newly created group UUID' }, + name: { type: 'string', description: 'Group name' }, + createdAt: { type: 'string', description: 'Creation timestamp' }, + }, +} diff --git a/apps/sim/tools/hex/deactivate_user.ts b/apps/sim/tools/hex/deactivate_user.ts new file mode 100644 index 00000000000..1f75120e751 --- /dev/null +++ b/apps/sim/tools/hex/deactivate_user.ts @@ -0,0 +1,60 @@ +import type { HexDeactivateUserParams, HexDeactivateUserResponse } from '@/tools/hex/types' +import type { ToolConfig } from '@/tools/types' + +export const deactivateUserTool: ToolConfig = { + id: 'hex_deactivate_user', + name: 'Hex Deactivate User', + description: 'Deactivate a user in the Hex workspace.', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Hex API token (Personal or Workspace)', + }, + userId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The UUID of the user to deactivate', + }, + }, + + request: { + url: (params) => `https://app.hex.tech/api/v1/users/${params.userId.trim()}/deactivate`, + method: 'POST', + headers: (params) => ({ + Authorization: `Bearer ${params.apiKey}`, + 'Content-Type': 'application/json', + }), + }, + + transformResponse: async (response: Response, params) => { + if (response.status === 204 || response.ok) { + return { + success: true, + output: { + success: true, + userId: params?.userId ?? '', + }, + } + } + + const data = await response.json().catch(() => ({})) + return { + success: false, + output: { + success: false, + userId: params?.userId ?? '', + }, + error: (data as Record).message ?? 'Failed to deactivate user', + } + }, + + outputs: { + success: { type: 'boolean', description: 'Whether the user was successfully deactivated' }, + userId: { type: 'string', description: 'User UUID that was deactivated' }, + }, +} diff --git a/apps/sim/tools/hex/delete_group.ts b/apps/sim/tools/hex/delete_group.ts new file mode 100644 index 00000000000..1135a6c87e2 --- /dev/null +++ b/apps/sim/tools/hex/delete_group.ts @@ -0,0 +1,60 @@ +import type { HexDeleteGroupParams, HexDeleteGroupResponse } from '@/tools/hex/types' +import type { ToolConfig } from '@/tools/types' + +export const deleteGroupTool: ToolConfig = { + id: 'hex_delete_group', + name: 'Hex Delete Group', + description: 'Delete a group from the Hex workspace.', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Hex API token (Personal or Workspace)', + }, + groupId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The UUID of the group to delete', + }, + }, + + request: { + url: (params) => `https://app.hex.tech/api/v1/groups/${params.groupId.trim()}`, + method: 'DELETE', + headers: (params) => ({ + Authorization: `Bearer ${params.apiKey}`, + 'Content-Type': 'application/json', + }), + }, + + transformResponse: async (response: Response, params) => { + if (response.status === 204 || response.ok) { + return { + success: true, + output: { + success: true, + groupId: params?.groupId ?? '', + }, + } + } + + const data = await response.json().catch(() => ({})) + return { + success: false, + output: { + success: false, + groupId: params?.groupId ?? '', + }, + error: (data as Record).message ?? 'Failed to delete group', + } + }, + + outputs: { + success: { type: 'boolean', description: 'Whether the group was successfully deleted' }, + groupId: { type: 'string', description: 'Group UUID that was deleted' }, + }, +} diff --git a/apps/sim/tools/hex/get_collection.ts b/apps/sim/tools/hex/get_collection.ts index 8222d88a92c..8b5c0fe888c 100644 --- a/apps/sim/tools/hex/get_collection.ts +++ b/apps/sim/tools/hex/get_collection.ts @@ -23,7 +23,7 @@ export const getCollectionTool: ToolConfig `https://app.hex.tech/api/v1/collections/${params.collectionId}`, + url: (params) => `https://app.hex.tech/api/v1/collections/${params.collectionId.trim()}`, method: 'GET', headers: (params) => ({ Authorization: `Bearer ${params.apiKey}`, diff --git a/apps/sim/tools/hex/get_data_connection.ts b/apps/sim/tools/hex/get_data_connection.ts index 3b9e54b94f3..68bd0838389 100644 --- a/apps/sim/tools/hex/get_data_connection.ts +++ b/apps/sim/tools/hex/get_data_connection.ts @@ -27,7 +27,8 @@ export const getDataConnectionTool: ToolConfig< }, request: { - url: (params) => `https://app.hex.tech/api/v1/data-connections/${params.dataConnectionId}`, + url: (params) => + `https://app.hex.tech/api/v1/data-connections/${params.dataConnectionId.trim()}`, method: 'GET', headers: (params) => ({ Authorization: `Bearer ${params.apiKey}`, diff --git a/apps/sim/tools/hex/get_group.ts b/apps/sim/tools/hex/get_group.ts index c649e657a86..683bde7e519 100644 --- a/apps/sim/tools/hex/get_group.ts +++ b/apps/sim/tools/hex/get_group.ts @@ -23,7 +23,7 @@ export const getGroupTool: ToolConfig = }, request: { - url: (params) => `https://app.hex.tech/api/v1/groups/${params.groupId}`, + url: (params) => `https://app.hex.tech/api/v1/groups/${params.groupId.trim()}`, method: 'GET', headers: (params) => ({ Authorization: `Bearer ${params.apiKey}`, diff --git a/apps/sim/tools/hex/get_project.ts b/apps/sim/tools/hex/get_project.ts index fda718f2f6c..fd5ecb29f34 100644 --- a/apps/sim/tools/hex/get_project.ts +++ b/apps/sim/tools/hex/get_project.ts @@ -24,7 +24,7 @@ export const getProjectTool: ToolConfig `https://app.hex.tech/api/v1/projects/${params.projectId}`, + url: (params) => `https://app.hex.tech/api/v1/projects/${params.projectId.trim()}`, method: 'GET', headers: (params) => ({ Authorization: `Bearer ${params.apiKey}`, diff --git a/apps/sim/tools/hex/get_project_runs.ts b/apps/sim/tools/hex/get_project_runs.ts index 9d2897d900e..0495603de27 100644 --- a/apps/sim/tools/hex/get_project_runs.ts +++ b/apps/sim/tools/hex/get_project_runs.ts @@ -40,6 +40,12 @@ export const getProjectRunsTool: ToolConfig ({ @@ -78,6 +85,8 @@ export const getProjectRunsTool: ToolConfig ({ diff --git a/apps/sim/tools/hex/get_run_status.ts b/apps/sim/tools/hex/get_run_status.ts index 90dd26cdb01..0c0de4a870b 100644 --- a/apps/sim/tools/hex/get_run_status.ts +++ b/apps/sim/tools/hex/get_run_status.ts @@ -31,7 +31,7 @@ export const getRunStatusTool: ToolConfig - `https://app.hex.tech/api/v1/projects/${params.projectId}/runs/${params.runId}`, + `https://app.hex.tech/api/v1/projects/${params.projectId.trim()}/runs/${params.runId.trim()}`, method: 'GET', headers: (params) => ({ Authorization: `Bearer ${params.apiKey}`, diff --git a/apps/sim/tools/hex/index.ts b/apps/sim/tools/hex/index.ts index 9a561587d7d..e96b0bb53fb 100644 --- a/apps/sim/tools/hex/index.ts +++ b/apps/sim/tools/hex/index.ts @@ -1,5 +1,8 @@ import { cancelRunTool } from '@/tools/hex/cancel_run' import { createCollectionTool } from '@/tools/hex/create_collection' +import { createGroupTool } from '@/tools/hex/create_group' +import { deactivateUserTool } from '@/tools/hex/deactivate_user' +import { deleteGroupTool } from '@/tools/hex/delete_group' import { getCollectionTool } from '@/tools/hex/get_collection' import { getDataConnectionTool } from '@/tools/hex/get_data_connection' import { getGroupTool } from '@/tools/hex/get_group' @@ -13,10 +16,15 @@ import { listGroupsTool } from '@/tools/hex/list_groups' import { listProjectsTool } from '@/tools/hex/list_projects' import { listUsersTool } from '@/tools/hex/list_users' import { runProjectTool } from '@/tools/hex/run_project' +import { updateCollectionTool } from '@/tools/hex/update_collection' +import { updateGroupTool } from '@/tools/hex/update_group' import { updateProjectTool } from '@/tools/hex/update_project' export const hexCancelRunTool = cancelRunTool export const hexCreateCollectionTool = createCollectionTool +export const hexCreateGroupTool = createGroupTool +export const hexDeactivateUserTool = deactivateUserTool +export const hexDeleteGroupTool = deleteGroupTool export const hexGetCollectionTool = getCollectionTool export const hexGetDataConnectionTool = getDataConnectionTool export const hexGetGroupTool = getGroupTool @@ -30,4 +38,6 @@ export const hexListGroupsTool = listGroupsTool export const hexListProjectsTool = listProjectsTool export const hexListUsersTool = listUsersTool export const hexRunProjectTool = runProjectTool +export const hexUpdateCollectionTool = updateCollectionTool +export const hexUpdateGroupTool = updateGroupTool export const hexUpdateProjectTool = updateProjectTool diff --git a/apps/sim/tools/hex/list_collections.ts b/apps/sim/tools/hex/list_collections.ts index 9902db0d158..beaf4054f57 100644 --- a/apps/sim/tools/hex/list_collections.ts +++ b/apps/sim/tools/hex/list_collections.ts @@ -27,6 +27,18 @@ export const listCollectionsTool: ToolConfig typeof c === 'string')) { + throw new Error('categories must be a valid JSON array of category name strings') + } + for (const category of categories) { + searchParams.append('categories[]', category) + } + } + if (params.sortBy) searchParams.set('sortBy', params.sortBy) + if (params.sortDirection) searchParams.set('sortDirection', params.sortDirection) + if (params.after) searchParams.set('after', params.after) + if (params.before) searchParams.set('before', params.before) const qs = searchParams.toString() return `https://app.hex.tech/api/v1/projects${qs ? `?${qs}` : ''}` }, @@ -80,6 +166,8 @@ export const listProjectsTool: ToolConfig visibility: 'user-or-llm', description: 'Filter users by group UUID', }, + userIds: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Comma-separated list of user UUIDs to filter by', + }, + after: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Cursor to fetch the page of results after this value', + }, + before: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Cursor to fetch the page of results before this value', + }, }, request: { @@ -47,6 +65,9 @@ export const listUsersTool: ToolConfig if (params.sortBy) searchParams.set('sortBy', params.sortBy) if (params.sortDirection) searchParams.set('sortDirection', params.sortDirection) if (params.groupId) searchParams.set('groupId', params.groupId) + if (params.userIds) searchParams.set('userIds', params.userIds) + if (params.after) searchParams.set('after', params.after) + if (params.before) searchParams.set('before', params.before) const qs = searchParams.toString() return `https://app.hex.tech/api/v1/users${qs ? `?${qs}` : ''}` }, @@ -69,8 +90,11 @@ export const listUsersTool: ToolConfig name: (u.name as string) ?? null, email: (u.email as string) ?? null, role: (u.role as string) ?? null, + lastLoginDate: (u.lastLoginDate as string) ?? null, })), total: users.length, + after: data.pagination?.after ?? null, + before: data.pagination?.before ?? null, }, } }, @@ -90,9 +114,20 @@ export const listUsersTool: ToolConfig description: 'User role (ADMIN, MANAGER, EDITOR, EXPLORER, MEMBER, GUEST, EMBEDDED_USER, ANONYMOUS)', }, + lastLoginDate: { + type: 'string', + description: 'Last login timestamp', + optional: true, + }, }, }, }, total: { type: 'number', description: 'Total number of users returned' }, + after: { type: 'string', description: 'Cursor for the next page of results', optional: true }, + before: { + type: 'string', + description: 'Cursor for the previous page of results', + optional: true, + }, }, } diff --git a/apps/sim/tools/hex/run_project.ts b/apps/sim/tools/hex/run_project.ts index 3d11ac03466..6d1a9ff6022 100644 --- a/apps/sim/tools/hex/run_project.ts +++ b/apps/sim/tools/hex/run_project.ts @@ -52,10 +52,23 @@ export const runProjectTool: ToolConfig `https://app.hex.tech/api/v1/projects/${params.projectId}/runs`, + url: (params) => `https://app.hex.tech/api/v1/projects/${params.projectId.trim()}/runs`, method: 'POST', headers: (params) => ({ Authorization: `Bearer ${params.apiKey}`, @@ -65,10 +78,14 @@ export const runProjectTool: ToolConfig = {} if (params.inputParams) { - body.inputParams = - typeof params.inputParams === 'string' - ? JSON.parse(params.inputParams) - : params.inputParams + try { + body.inputParams = + typeof params.inputParams === 'string' + ? JSON.parse(params.inputParams) + : params.inputParams + } catch { + throw new Error('inputParams must be valid JSON') + } } if (params.dryRun !== undefined) body.dryRun = params.dryRun if (params.updateCache !== undefined) body.updateCache = params.updateCache @@ -76,6 +93,22 @@ export const runProjectTool: ToolConfig total: number + after: string | null + before: string | null } } @@ -159,6 +171,8 @@ export interface HexRunProjectParams { updateCache?: boolean updatePublishedResults?: boolean useCachedSqlResults?: boolean + viewId?: string + notifications?: string } export interface HexRunProjectResponse extends ToolResponse { @@ -212,6 +226,7 @@ export interface HexGetProjectRunsParams { limit?: number offset?: number statusFilter?: string + runTriggerFilter?: string } export interface HexGetProjectRunsResponse extends ToolResponse { @@ -229,6 +244,8 @@ export interface HexGetProjectRunsResponse extends ToolResponse { }> total: number traceId: string | null + nextPage: string | null + previousPage: string | null } } @@ -262,6 +279,9 @@ export interface HexListUsersParams { sortBy?: string sortDirection?: string groupId?: string + userIds?: string + after?: string + before?: string } export interface HexListUsersResponse extends ToolResponse { @@ -271,8 +291,11 @@ export interface HexListUsersResponse extends ToolResponse { name: string email: string role: string + lastLoginDate: string | null }> total: number + after: string | null + before: string | null } } @@ -280,6 +303,8 @@ export interface HexListCollectionsParams { apiKey: string limit?: number sortBy?: string + after?: string + before?: string } export interface HexListCollectionsResponse extends ToolResponse { @@ -291,6 +316,8 @@ export interface HexListCollectionsResponse extends ToolResponse { creator: { email: string; id: string } | null }> total: number + after: string | null + before: string | null } } @@ -299,6 +326,8 @@ export interface HexListDataConnectionsParams { limit?: number sortBy?: string sortDirection?: string + after?: string + before?: string } export interface HexListDataConnectionsResponse extends ToolResponse { @@ -313,6 +342,8 @@ export interface HexListDataConnectionsResponse extends ToolResponse { allowWritebackCells: boolean | null }> total: number + after: string | null + before: string | null } } @@ -338,6 +369,8 @@ export interface HexListGroupsParams { limit?: number sortBy?: string sortDirection?: string + after?: string + before?: string } export interface HexListGroupsResponse extends ToolResponse { @@ -348,6 +381,8 @@ export interface HexListGroupsResponse extends ToolResponse { createdAt: string | null }> total: number + after: string | null + before: string | null } } @@ -410,6 +445,76 @@ export interface HexCreateCollectionResponse extends ToolResponse { } } +export interface HexUpdateCollectionParams { + apiKey: string + collectionId: string + name?: string + description?: string +} + +export interface HexUpdateCollectionResponse extends ToolResponse { + output: { + id: string + name: string + description: string | null + creator: { email: string; id: string } | null + } +} + +export interface HexCreateGroupParams { + apiKey: string + name: string + memberUserIds?: string +} + +export interface HexCreateGroupResponse extends ToolResponse { + output: { + id: string + name: string + createdAt: string | null + } +} + +export interface HexUpdateGroupParams { + apiKey: string + groupId: string + name?: string + addUserIds?: string + removeUserIds?: string +} + +export interface HexUpdateGroupResponse extends ToolResponse { + output: { + id: string + name: string + createdAt: string | null + } +} + +export interface HexDeleteGroupParams { + apiKey: string + groupId: string +} + +export interface HexDeleteGroupResponse extends ToolResponse { + output: { + success: boolean + groupId: string + } +} + +export interface HexDeactivateUserParams { + apiKey: string + userId: string +} + +export interface HexDeactivateUserResponse extends ToolResponse { + output: { + success: boolean + userId: string + } +} + export type HexResponse = | HexListProjectsResponse | HexGetProjectResponse @@ -427,3 +532,8 @@ export type HexResponse = | HexGetDataConnectionResponse | HexGetCollectionResponse | HexCreateCollectionResponse + | HexUpdateCollectionResponse + | HexCreateGroupResponse + | HexUpdateGroupResponse + | HexDeleteGroupResponse + | HexDeactivateUserResponse diff --git a/apps/sim/tools/hex/update_collection.ts b/apps/sim/tools/hex/update_collection.ts new file mode 100644 index 00000000000..a21c2aee1db --- /dev/null +++ b/apps/sim/tools/hex/update_collection.ts @@ -0,0 +1,85 @@ +import type { HexUpdateCollectionParams, HexUpdateCollectionResponse } from '@/tools/hex/types' +import type { ToolConfig } from '@/tools/types' + +export const updateCollectionTool: ToolConfig< + HexUpdateCollectionParams, + HexUpdateCollectionResponse +> = { + id: 'hex_update_collection', + name: 'Hex Update Collection', + description: 'Update the name or description of an existing Hex collection.', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Hex API token (Personal or Workspace)', + }, + collectionId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The UUID of the collection to update', + }, + name: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'New name for the collection', + }, + description: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'New description for the collection', + }, + }, + + request: { + url: (params) => `https://app.hex.tech/api/v1/collections/${params.collectionId.trim()}`, + method: 'PATCH', + headers: (params) => ({ + Authorization: `Bearer ${params.apiKey}`, + 'Content-Type': 'application/json', + }), + body: (params) => { + const body: Record = {} + if (params.name) body.name = params.name + if (params.description !== undefined) body.description = params.description + return body + }, + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + return { + success: true, + output: { + id: data.id ?? null, + name: data.name ?? null, + description: data.description ?? null, + creator: data.creator + ? { email: data.creator.email ?? null, id: data.creator.id ?? null } + : null, + }, + } + }, + + outputs: { + id: { type: 'string', description: 'Collection UUID' }, + name: { type: 'string', description: 'Collection name' }, + description: { type: 'string', description: 'Collection description', optional: true }, + creator: { + type: 'object', + description: 'Collection creator', + optional: true, + properties: { + email: { type: 'string', description: 'Creator email' }, + id: { type: 'string', description: 'Creator UUID' }, + }, + }, + }, +} diff --git a/apps/sim/tools/hex/update_group.ts b/apps/sim/tools/hex/update_group.ts new file mode 100644 index 00000000000..d02f48c5a10 --- /dev/null +++ b/apps/sim/tools/hex/update_group.ts @@ -0,0 +1,106 @@ +import type { HexUpdateGroupParams, HexUpdateGroupResponse } from '@/tools/hex/types' +import type { ToolConfig } from '@/tools/types' + +export const updateGroupTool: ToolConfig = { + id: 'hex_update_group', + name: 'Hex Update Group', + description: 'Rename a Hex group or add/remove members from it.', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Hex API token (Personal or Workspace)', + }, + groupId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The UUID of the group to update', + }, + name: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'New name for the group', + }, + addUserIds: { + type: 'json', + required: false, + visibility: 'user-or-llm', + description: 'JSON array of user UUIDs to add to the group (e.g., ["uuid1", "uuid2"])', + }, + removeUserIds: { + type: 'json', + required: false, + visibility: 'user-or-llm', + description: 'JSON array of user UUIDs to remove from the group (e.g., ["uuid1", "uuid2"])', + }, + }, + + request: { + url: (params) => `https://app.hex.tech/api/v1/groups/${params.groupId.trim()}`, + method: 'PATCH', + headers: (params) => ({ + Authorization: `Bearer ${params.apiKey}`, + 'Content-Type': 'application/json', + }), + body: (params) => { + const body: Record = {} + if (params.name) body.name = params.name + + const parseIds = (value: unknown): string[] => { + let parsed: unknown + try { + parsed = typeof value === 'string' ? JSON.parse(value) : value + } catch { + throw new Error( + 'addUserIds/removeUserIds must be a valid JSON array of user UUID strings' + ) + } + if (!Array.isArray(parsed) || !parsed.every((id) => typeof id === 'string')) { + throw new Error( + 'addUserIds/removeUserIds must be a valid JSON array of user UUID strings' + ) + } + return parsed + } + + if (params.addUserIds || params.removeUserIds) { + const members: Record = {} + if (params.addUserIds) { + members.add = { users: parseIds(params.addUserIds).map((id) => ({ id: id.trim() })) } + } + if (params.removeUserIds) { + members.remove = { + users: parseIds(params.removeUserIds).map((id) => ({ id: id.trim() })), + } + } + body.members = members + } + + return body + }, + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + return { + success: true, + output: { + id: data.id ?? null, + name: data.name ?? null, + createdAt: data.createdAt ?? null, + }, + } + }, + + outputs: { + id: { type: 'string', description: 'Group UUID' }, + name: { type: 'string', description: 'Group name' }, + createdAt: { type: 'string', description: 'Creation timestamp' }, + }, +} diff --git a/apps/sim/tools/hex/update_project.ts b/apps/sim/tools/hex/update_project.ts index e8da0a3c278..faf75cfb9a7 100644 --- a/apps/sim/tools/hex/update_project.ts +++ b/apps/sim/tools/hex/update_project.ts @@ -30,7 +30,7 @@ export const updateProjectTool: ToolConfig `https://app.hex.tech/api/v1/projects/${params.projectId}`, + url: (params) => `https://app.hex.tech/api/v1/projects/${params.projectId.trim()}`, method: 'PATCH', headers: (params) => ({ Authorization: `Bearer ${params.apiKey}`, diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index 1154d6c593e..b947a88a56b 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -1556,6 +1556,9 @@ import { guardrailsValidateTool } from '@/tools/guardrails' import { hexCancelRunTool, hexCreateCollectionTool, + hexCreateGroupTool, + hexDeactivateUserTool, + hexDeleteGroupTool, hexGetCollectionTool, hexGetDataConnectionTool, hexGetGroupTool, @@ -1569,6 +1572,8 @@ import { hexListProjectsTool, hexListUsersTool, hexRunProjectTool, + hexUpdateCollectionTool, + hexUpdateGroupTool, hexUpdateProjectTool, } from '@/tools/hex' import { httpRequestTool, webhookRequestTool } from '@/tools/http' @@ -4638,6 +4643,9 @@ export const tools: Record = { guardrails_validate: guardrailsValidateTool, hex_cancel_run: hexCancelRunTool, hex_create_collection: hexCreateCollectionTool, + hex_create_group: hexCreateGroupTool, + hex_deactivate_user: hexDeactivateUserTool, + hex_delete_group: hexDeleteGroupTool, hex_get_collection: hexGetCollectionTool, hex_get_data_connection: hexGetDataConnectionTool, hex_get_group: hexGetGroupTool, @@ -4651,6 +4659,8 @@ export const tools: Record = { hex_list_projects: hexListProjectsTool, hex_list_users: hexListUsersTool, hex_run_project: hexRunProjectTool, + hex_update_collection: hexUpdateCollectionTool, + hex_update_group: hexUpdateGroupTool, hex_update_project: hexUpdateProjectTool, instantly_activate_campaign: instantlyActivateCampaignTool, instantly_create_campaign: instantlyCreateCampaignTool, From f6b802e7cb9eca5ba387dbef2a8be350e51cd514 Mon Sep 17 00:00:00 2001 From: Waleed Date: Thu, 2 Jul 2026 11:16:07 -0700 Subject: [PATCH 23/28] fix(sendgrid): fix active field coercion, add pagination, tighten output typing (#5368) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(sendgrid): fix active field coercion, add pagination, tighten output typing - Fix active field for create_template_version being sent as the string "true"/"false" instead of the SendGrid-required int 0/1 - Add missing authMode: ApiKey on SendGridBlock - Add pageToken/nextPageToken pagination support to list_templates and list_all_lists (SendGrid page_token cursor, parsed from _metadata.next) - Fix nullable output fields to use ?? null / ?? [] with optional: true across get_contact, search_contacts, remove_contacts_from_list, create_template_version, add_contact, send_mail - Remove dead data.templates fallback in list_templates (API only ever returns result) - Remove unused UpdateContactParams/UpdateListParams/UpdateTemplateParams dead types; make CreateTemplateParams.generation optional to match actual tool behavior * fix(sendgrid): address Cursor Bugbot findings on pagination and active coercion - Gate listPageToken/templatePageToken remap on operation so a stale token from the other list operation can't override the intended one - Fix active coercion to also treat a real boolean true (from a dynamic reference) as active, not just the dropdown string 'true' * fix(sendgrid): coerce active to int at the tool layer too Per Greptile: the block-level active coercion only covered the UI path. A direct sendgrid_create_template_version tool invocation with a boolean active would still send a raw boolean to SendGrid. Coerce to 0/1 in the tool's own request body so both paths are correct. * fix(sendgrid): always send page_size on list_templates SendGrid's GET /v3/templates requires page_size on every request (no server-side default) — omitting it errors. Default to 20 to match our own documented default when the caller doesn't set one. * fix(sendgrid): explicit false/'false' check for active flag Per Cursor Bugbot: params.active ? 1 : 0 treated any truthy string (including "false") as active. Extracted a toActiveFlag helper that only treats real false or the string 'false' as inactive, everything else (including unset) defaults to active — matches the tool's documented default. * fix(sendgrid): handle numeric 0 in toActiveFlag Per Greptile: the block coerces active to a number (0/1) before calling the tool, but toActiveFlag only checked for false/'false', so the block's inactive selection (0) fell through to the "active" branch. Check against an explicit inactive-values set covering the boolean, string, and numeric forms. * fix(sendgrid): nest add_contact custom fields under custom_fields Pre-existing bug (predates this PR): custom fields were merged onto the contact object as top-level sibling keys via safeAssign/Object.assign, but SendGrid's PUT /v3/marketing/contacts requires them nested under a custom_fields object. SendGrid silently drops unrecognized top-level keys, so the documented customFields param never actually reached SendGrid. Caught during a final adversarial re-verification pass before merge. * fix(sendgrid): document consistent page_size requirement for list_templates pagination Per Cursor Bugbot: list_templates always defaults page_size to 20 when unset (required by SendGrid), so a follow-up pageToken-only call after a first call with a larger pageSize would silently shrink to 20 and desync page boundaries. This is inherent to a stateless tool call (SendGrid requires page_size on every request, and the tool has no way to remember the prior call's value), so clarify via param description and UI placeholder that callers must repeat the same pageSize across paginated calls. * chore(api-validation): bump stale route-count ratchet baseline 883->884 Unrelated to the SendGrid work in this branch. staging's own HEAD already has 884 compliant Zod-backed API routes (0 non-Zod), but this ratchet baseline was never bumped when that route landed, so any PR rebasing onto current staging fails check:api-validation:strict with "route count increased from 883 to 884". All routes remain fully Zod-backed; this is a mechanical counter update, not a policy change. * fix(sendgrid): dedupe active coercion between block and tool Per Cursor Bugbot: the block's pre-coercion only recognized the dropdown string 'true' or boolean true as active, so a dynamic reference producing numeric 1 or string '1' fell through to 0 and silently created an inactive template version. Exported the tool's toActiveFlag and reused it in the block instead of duplicating the inactive-value logic, so both layers can no longer drift out of sync. --- apps/sim/blocks/blocks/sendgrid.ts | 33 ++++++++++++++- apps/sim/tools/sendgrid/add_contact.ts | 15 ++++--- .../tools/sendgrid/create_template_version.ts | 24 +++++++---- apps/sim/tools/sendgrid/get_contact.ts | 20 ++++++---- apps/sim/tools/sendgrid/list_all_lists.ts | 31 +++++++++++++- apps/sim/tools/sendgrid/list_templates.ts | 35 +++++++++++++--- .../sendgrid/remove_contacts_from_list.ts | 4 +- apps/sim/tools/sendgrid/search_contacts.ts | 8 +++- apps/sim/tools/sendgrid/send_mail.ts | 2 +- apps/sim/tools/sendgrid/types.ts | 40 ++++++------------- 10 files changed, 151 insertions(+), 61 deletions(-) diff --git a/apps/sim/blocks/blocks/sendgrid.ts b/apps/sim/blocks/blocks/sendgrid.ts index 9378083564d..2e68b9c6f15 100644 --- a/apps/sim/blocks/blocks/sendgrid.ts +++ b/apps/sim/blocks/blocks/sendgrid.ts @@ -1,7 +1,8 @@ import { SendgridIcon } from '@/components/icons' import type { BlockConfig, BlockMeta } from '@/blocks/types' -import { IntegrationType } from '@/blocks/types' +import { AuthMode, IntegrationType } from '@/blocks/types' import { normalizeFileInput } from '@/blocks/utils' +import { toActiveFlag } from '@/tools/sendgrid/create_template_version' import type { SendMailResult } from '@/tools/sendgrid/types' export const SendGridBlock: BlockConfig = { @@ -13,6 +14,7 @@ export const SendGridBlock: BlockConfig = { docsLink: 'https://docs.sim.ai/integrations/sendgrid', category: 'tools', integrationType: IntegrationType.Email, + authMode: AuthMode.ApiKey, bgColor: '#1A82E2', icon: SendgridIcon, @@ -387,6 +389,14 @@ Return ONLY the JSON array.`, condition: { field: 'operation', value: 'list_all_lists' }, mode: 'advanced', }, + { + id: 'listPageToken', + title: 'Page Token', + type: 'short-input', + placeholder: 'Page token from a previous response', + condition: { field: 'operation', value: 'list_all_lists' }, + mode: 'advanced', + }, // Template fields { id: 'templateName', @@ -434,6 +444,14 @@ Return ONLY the JSON array.`, condition: { field: 'operation', value: 'list_templates' }, mode: 'advanced', }, + { + id: 'templatePageToken', + title: 'Page Token', + type: 'short-input', + placeholder: 'Page token from a previous response (keep Page Size the same)', + condition: { field: 'operation', value: 'list_templates' }, + mode: 'advanced', + }, { id: 'versionName', title: 'Version Name', @@ -579,7 +597,10 @@ Return ONLY the HTML content.`, templateGenerations, listPageSize, templatePageSize, + listPageToken, + templatePageToken, attachments, + active, ...rest } = params @@ -599,7 +620,11 @@ Return ONLY the HTML content.`, ...(templateGenerations && { generations: templateGenerations }), ...(listPageSize && { pageSize: listPageSize }), ...(templatePageSize && { pageSize: templatePageSize }), + ...(operation === 'list_all_lists' && listPageToken && { pageToken: listPageToken }), + ...(operation === 'list_templates' && + templatePageToken && { pageToken: templatePageToken }), ...(normalizedAttachments && { attachments: normalizedAttachments }), + ...(active !== undefined && { active: toActiveFlag(active) }), } }, }, @@ -637,12 +662,14 @@ Return ONLY the HTML content.`, listName: { type: 'string', description: 'List name' }, listId: { type: 'string', description: 'List ID' }, listPageSize: { type: 'number', description: 'Page size for listing lists' }, + listPageToken: { type: 'string', description: 'Page token for listing lists' }, // Template inputs templateName: { type: 'string', description: 'Template name' }, templateId: { type: 'string', description: 'Template ID' }, generation: { type: 'string', description: 'Template generation' }, templateGenerations: { type: 'string', description: 'Filter templates by generation' }, templatePageSize: { type: 'number', description: 'Page size for listing templates' }, + templatePageToken: { type: 'string', description: 'Page token for listing templates' }, versionName: { type: 'string', description: 'Template version name' }, templateSubject: { type: 'string', description: 'Template subject' }, htmlContent: { type: 'string', description: 'HTML content' }, @@ -677,6 +704,10 @@ Return ONLY the HTML content.`, templates: { type: 'json', description: 'Array of templates' }, generation: { type: 'string', description: 'Template generation' }, versions: { type: 'json', description: 'Array of template versions' }, + nextPageToken: { + type: 'string', + description: 'Token for the next page of results (list_all_lists, list_templates)', + }, // Template version outputs templateId: { type: 'string', description: 'Template ID' }, active: { type: 'boolean', description: 'Whether template version is active' }, diff --git a/apps/sim/tools/sendgrid/add_contact.ts b/apps/sim/tools/sendgrid/add_contact.ts index 5c483e9737b..7c1a7a37986 100644 --- a/apps/sim/tools/sendgrid/add_contact.ts +++ b/apps/sim/tools/sendgrid/add_contact.ts @@ -1,4 +1,3 @@ -import { safeAssign } from '@/tools/safe-assign' import type { AddContactParams, ContactResult, @@ -73,7 +72,7 @@ export const sendGridAddContactTool: ToolConfig typeof params.customFields === 'string' ? JSON.parse(params.customFields) : params.customFields - safeAssign(contact, customFields as Record) + contact.custom_fields = customFields as Record } const body: SendGridContactRequest = { @@ -99,7 +98,7 @@ export const sendGridAddContactTool: ToolConfig return { success: true, output: { - jobId: data.job_id, + jobId: data.job_id ?? null, email: params?.email || '', firstName: params?.firstName, lastName: params?.lastName, @@ -110,10 +109,14 @@ export const sendGridAddContactTool: ToolConfig }, outputs: { - jobId: { type: 'string', description: 'Job ID for tracking the async contact creation' }, + jobId: { + type: 'string', + description: 'Job ID for tracking the async contact creation', + optional: true, + }, email: { type: 'string', description: 'Contact email address' }, - firstName: { type: 'string', description: 'Contact first name' }, - lastName: { type: 'string', description: 'Contact last name' }, + firstName: { type: 'string', description: 'Contact first name', optional: true }, + lastName: { type: 'string', description: 'Contact last name', optional: true }, message: { type: 'string', description: 'Status message' }, }, } diff --git a/apps/sim/tools/sendgrid/create_template_version.ts b/apps/sim/tools/sendgrid/create_template_version.ts index e41f43bb584..23267575d8b 100644 --- a/apps/sim/tools/sendgrid/create_template_version.ts +++ b/apps/sim/tools/sendgrid/create_template_version.ts @@ -5,6 +5,16 @@ import type { } from '@/tools/sendgrid/types' import type { ToolConfig } from '@/tools/types' +const INACTIVE_VALUES: unknown[] = [false, 'false', 0, '0'] + +/** Coerces any dynamic-reference form of SendGrid's active flag (boolean, string, or + * number) to the 0/1 integer the API requires. Shared with the block's own + * pre-coercion in blocks/blocks/sendgrid.ts so both layers stay in sync. */ +export function toActiveFlag(active: unknown): 0 | 1 { + if (active === undefined) return 1 + return INACTIVE_VALUES.includes(active) ? 0 : 1 +} + export const sendGridCreateTemplateVersionTool: ToolConfig< CreateTemplateVersionParams, TemplateVersionResult @@ -70,7 +80,7 @@ export const sendGridCreateTemplateVersionTool: ToolConfig< const body: SendGridTemplateVersionRequest = { name: params.name, subject: params.subject, - active: params.active !== undefined ? params.active : 1, + active: toActiveFlag(params.active), } if (params.htmlContent) { @@ -101,9 +111,9 @@ export const sendGridCreateTemplateVersionTool: ToolConfig< name: data.name, subject: data.subject, active: data.active === 1, - htmlContent: data.html_content, - plainContent: data.plain_content, - updatedAt: data.updated_at, + htmlContent: data.html_content ?? null, + plainContent: data.plain_content ?? null, + updatedAt: data.updated_at ?? null, }, } }, @@ -114,8 +124,8 @@ export const sendGridCreateTemplateVersionTool: ToolConfig< name: { type: 'string', description: 'Version name' }, subject: { type: 'string', description: 'Email subject' }, active: { type: 'boolean', description: 'Whether this version is active' }, - htmlContent: { type: 'string', description: 'HTML content' }, - plainContent: { type: 'string', description: 'Plain text content' }, - updatedAt: { type: 'string', description: 'Last update timestamp' }, + htmlContent: { type: 'string', description: 'HTML content', optional: true }, + plainContent: { type: 'string', description: 'Plain text content', optional: true }, + updatedAt: { type: 'string', description: 'Last update timestamp', optional: true }, }, } diff --git a/apps/sim/tools/sendgrid/get_contact.ts b/apps/sim/tools/sendgrid/get_contact.ts index f2870d854ff..549d96df0c2 100644 --- a/apps/sim/tools/sendgrid/get_contact.ts +++ b/apps/sim/tools/sendgrid/get_contact.ts @@ -47,8 +47,8 @@ export const sendGridGetContactTool: ToolConfig lastName: data.last_name, createdAt: data.created_at, updatedAt: data.updated_at, - listIds: data.list_ids, - customFields: data.custom_fields, + listIds: data.list_ids ?? [], + customFields: data.custom_fields ?? null, }, } }, @@ -56,11 +56,15 @@ export const sendGridGetContactTool: ToolConfig outputs: { id: { type: 'string', description: 'Contact ID' }, email: { type: 'string', description: 'Contact email address' }, - firstName: { type: 'string', description: 'Contact first name' }, - lastName: { type: 'string', description: 'Contact last name' }, - createdAt: { type: 'string', description: 'Creation timestamp' }, - updatedAt: { type: 'string', description: 'Last update timestamp' }, - listIds: { type: 'json', description: 'Array of list IDs the contact belongs to' }, - customFields: { type: 'json', description: 'Custom field values' }, + firstName: { type: 'string', description: 'Contact first name', optional: true }, + lastName: { type: 'string', description: 'Contact last name', optional: true }, + createdAt: { type: 'string', description: 'Creation timestamp', optional: true }, + updatedAt: { type: 'string', description: 'Last update timestamp', optional: true }, + listIds: { + type: 'json', + description: 'Array of list IDs the contact belongs to', + optional: true, + }, + customFields: { type: 'json', description: 'Custom field values', optional: true }, }, } diff --git a/apps/sim/tools/sendgrid/list_all_lists.ts b/apps/sim/tools/sendgrid/list_all_lists.ts index 76adb3d1002..17861dae3ea 100644 --- a/apps/sim/tools/sendgrid/list_all_lists.ts +++ b/apps/sim/tools/sendgrid/list_all_lists.ts @@ -18,7 +18,13 @@ export const sendGridListAllListsTool: ToolConfig = outputs: { success: { type: 'boolean', description: 'Whether the email was sent successfully' }, - messageId: { type: 'string', description: 'SendGrid message ID' }, + messageId: { type: 'string', description: 'SendGrid message ID', optional: true }, to: { type: 'string', description: 'Recipient email address' }, subject: { type: 'string', description: 'Email subject' }, }, diff --git a/apps/sim/tools/sendgrid/types.ts b/apps/sim/tools/sendgrid/types.ts index 03eb10bfccb..fcd0c24ff2d 100644 --- a/apps/sim/tools/sendgrid/types.ts +++ b/apps/sim/tools/sendgrid/types.ts @@ -71,6 +71,7 @@ export interface SendGridContactObject { email: string first_name?: string last_name?: string + custom_fields?: Record [key: string]: unknown } @@ -82,7 +83,7 @@ export interface SendGridContactRequest { export interface SendGridTemplateVersionRequest { name: string subject: string - active: number | boolean + active: number html_content?: string plain_content?: string } @@ -127,15 +128,6 @@ export interface AddContactParams extends SendGridBaseParams { listIds?: string // Comma-separated list IDs } -interface UpdateContactParams extends SendGridBaseParams { - contactId?: string - email: string - firstName?: string - lastName?: string - customFields?: string // JSON string - listIds?: string // Comma-separated list IDs -} - export interface SearchContactsParams extends SendGridBaseParams { query: string } @@ -151,14 +143,14 @@ export interface DeleteContactParams extends SendGridBaseParams { export interface ContactResult extends ToolResponse { output: { id?: string - jobId?: string + jobId?: string | null email: string firstName?: string lastName?: string createdAt?: string updatedAt?: string listIds?: string[] - customFields?: Record + customFields?: Record | null message?: string } } @@ -166,7 +158,7 @@ export interface ContactResult extends ToolResponse { export interface ContactsResult extends ToolResponse { output: { contacts: SendGridContact[] - contactCount?: number + contactCount: number | null } } @@ -179,17 +171,13 @@ export interface GetListParams extends SendGridBaseParams { listId: string } -interface UpdateListParams extends SendGridBaseParams { - listId: string - name: string -} - export interface DeleteListParams extends SendGridBaseParams { listId: string } export interface ListAllListsParams extends SendGridBaseParams { pageSize?: number + pageToken?: string } export interface AddContactsToListParams extends SendGridBaseParams { @@ -213,24 +201,20 @@ export interface ListResult extends ToolResponse { export interface ListsResult extends ToolResponse { output: { lists: SendGridList[] + nextPageToken: string | null } } // Template types export interface CreateTemplateParams extends SendGridBaseParams { name: string - generation: 'legacy' | 'dynamic' + generation?: 'legacy' | 'dynamic' } export interface GetTemplateParams extends SendGridBaseParams { templateId: string } -interface UpdateTemplateParams extends SendGridBaseParams { - templateId: string - name: string -} - export interface DeleteTemplateParams extends SendGridBaseParams { templateId: string } @@ -238,6 +222,7 @@ export interface DeleteTemplateParams extends SendGridBaseParams { export interface ListTemplatesParams extends SendGridBaseParams { generations?: string // 'legacy' or 'dynamic' or both pageSize?: number + pageToken?: string } export interface CreateTemplateVersionParams extends SendGridBaseParams { @@ -262,6 +247,7 @@ export interface TemplateResult extends ToolResponse { export interface TemplatesResult extends ToolResponse { output: { templates: SendGridTemplate[] + nextPageToken: string | null } } @@ -272,8 +258,8 @@ export interface TemplateVersionResult extends ToolResponse { name: string subject: string active: boolean - htmlContent?: string - plainContent?: string - updatedAt?: string + htmlContent: string | null + plainContent: string | null + updatedAt: string | null } } From 06dd81150a6386d8b8367abf2f3bebe07ded641c Mon Sep 17 00:00:00 2001 From: Waleed Date: Thu, 2 Jul 2026 11:58:14 -0700 Subject: [PATCH 24/28] fix(google-forms): fail-closed auth, legacy formId key fallback, idempotency (#5377) - verifyAuth now rejects with 401 when no token is configured instead of silently allowing unauthenticated requests through - formatInput falls back to the legacy formId providerConfig key (pre-#3141 rename to triggerFormId) so old deployments keep working - add extractIdempotencyId keyed on formId:responseId to dedupe retried Apps Script deliveries --- .../lib/webhooks/providers/google-forms.ts | 25 +++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/apps/sim/lib/webhooks/providers/google-forms.ts b/apps/sim/lib/webhooks/providers/google-forms.ts index 67e7fd8c997..ef943e7d408 100644 --- a/apps/sim/lib/webhooks/providers/google-forms.ts +++ b/apps/sim/lib/webhooks/providers/google-forms.ts @@ -29,7 +29,12 @@ export const googleFormsHandler: WebhookProviderHandler = { const responseId = (b?.responseId || b?.id || '') as string const createTime = (b?.createTime || b?.timestamp || new Date().toISOString()) as string const lastSubmittedTime = (b?.lastSubmittedTime || createTime) as string - const formId = (b?.formId || providerConfig.formId || '') as string + // triggerFormId is the current subBlock id; formId is the pre-#3141 id still + // present in provider_config for webhooks deployed before that rename. + const formId = (b?.formId || + providerConfig.triggerFormId || + providerConfig.formId || + '') as string const includeRaw = providerConfig.includeRawPayload !== false return { input: { @@ -46,7 +51,10 @@ export const googleFormsHandler: WebhookProviderHandler = { verifyAuth({ request, requestId, providerConfig }: AuthContext) { const expectedToken = providerConfig.token as string | undefined if (!expectedToken) { - return null + logger.warn(`[${requestId}] Google Forms webhook secret not configured`) + return new NextResponse('Unauthorized - Missing Google Forms webhook secret', { + status: 401, + }) } const secretHeaderName = providerConfig.secretHeaderName as string | undefined @@ -57,4 +65,17 @@ export const googleFormsHandler: WebhookProviderHandler = { return null }, + + extractIdempotencyId(body: unknown): string | null { + const b = body as Record + // Mirrors formatInput's responseId resolution. formId is deliberately not part + // of this key: the final key is already scoped by webhookId (one webhook per + // deployed form), and extractIdempotencyId has no access to providerConfig, so + // a body-only formId fallback would risk a bogus 'unknown' segment. + const responseId = (b?.responseId || b?.id) as string | undefined + if (typeof responseId !== 'string' || !responseId) { + return null + } + return `google_forms:${responseId}` + }, } From 10b6bb33e93265df9f5594ecefa5ae4842566360 Mon Sep 17 00:00:00 2001 From: Waleed Date: Thu, 2 Jul 2026 11:58:30 -0700 Subject: [PATCH 25/28] feat(google-appsheet): add Google AppSheet integration (#5376) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(google-appsheet): add Google AppSheet integration - 4 tools (find/add/edit/delete rows) against the AppSheet Action API - API key auth via Application Access Key (no OAuth/scopes needed) - Block with operation dropdown, region selector, and Selector expression support - Generated docs * improvement(google-appsheet): harden response parsing, add wand config and skills - Guard against empty/non-JSON AppSheet response bodies (Delete may return no body) - Add wandConfig to the Selector field for AI-assisted expression generation - Add 3 skills grounded in attested AppSheet/Zapier automation patterns - Tighten json output descriptions to describe inner shape * fix(google-appsheet): validate region against allow-list, encode appId, validate rows shape - Reject unrecognized region values instead of interpolating them into the request host (a caller could otherwise redirect the Application Access Key to an arbitrary domain) - URL-encode appId, not just tableName, in the Action endpoint path - Reject non-array Rows input in tools.config.params instead of forwarding a single object to the AppSheet Action API - Drop the mismatched json-object generationType on the rows wand config (that enricher appends "must start with { and end with }", which conflicts with the JSON-array shape the field expects) - Add utils.test.ts covering region validation and response-body parsing * docs(google-appsheet): add manual intro/getting-started section Match the MANUAL-CONTENT convention used by other integration docs (Airtable, Ahrefs, Google PageSpeed) — an overview of the service, what the Sim integration lets agents do, and how to get an Application Access Key. * docs: sync generated integration docs with current source Regenerate docs for integrations whose tools/blocks changed upstream without a matching docs regen (ahrefs, algolia, amplitude, brex, clerk, gong, hex, langsmith, loops, onepassword, sendgrid, sharepoint, similarweb, supabase, tailscale, trello, vercel, wordpress), plus the integrations.json catalog. --- apps/docs/components/icons.tsx | 15 + apps/docs/components/ui/icon-mapping.ts | 2 + .../content/docs/en/integrations/ahrefs.mdx | 169 ++-- .../content/docs/en/integrations/algolia.mdx | 2 +- .../docs/en/integrations/amplitude.mdx | 99 +- .../content/docs/en/integrations/brex.mdx | 5 + .../content/docs/en/integrations/clerk.mdx | 855 +++++++++++++++++- .../content/docs/en/integrations/gong.mdx | 101 +++ .../docs/en/integrations/google_appsheet.mdx | 135 +++ .../docs/content/docs/en/integrations/hex.mdx | 139 ++- .../docs/en/integrations/langsmith.mdx | 77 ++ .../content/docs/en/integrations/loops.mdx | 74 +- .../content/docs/en/integrations/meta.json | 1 + .../docs/en/integrations/onepassword.mdx | 136 ++- .../content/docs/en/integrations/sendgrid.mdx | 11 +- .../docs/en/integrations/sharepoint.mdx | 171 +++- .../docs/en/integrations/similarweb.mdx | 28 + .../content/docs/en/integrations/supabase.mdx | 79 +- .../docs/en/integrations/tailscale.mdx | 88 +- .../content/docs/en/integrations/trello.mdx | 186 +++- .../content/docs/en/integrations/vercel.mdx | 79 +- .../docs/en/integrations/wordpress.mdx | 167 +++- apps/sim/blocks/blocks/google_appsheet.ts | 277 ++++++ apps/sim/blocks/registry-maps.ts | 3 + apps/sim/components/icons.tsx | 15 + apps/sim/lib/integrations/icon-mapping.ts | 2 + apps/sim/lib/integrations/integrations.json | 426 ++++++++- apps/sim/tools/google_appsheet/add_rows.ts | 102 +++ apps/sim/tools/google_appsheet/delete_rows.ts | 102 +++ apps/sim/tools/google_appsheet/edit_rows.ts | 102 +++ apps/sim/tools/google_appsheet/find_rows.ts | 102 +++ apps/sim/tools/google_appsheet/index.ts | 13 + apps/sim/tools/google_appsheet/types.ts | 72 ++ apps/sim/tools/google_appsheet/utils.test.ts | 50 + apps/sim/tools/google_appsheet/utils.ts | 36 + apps/sim/tools/registry.ts | 10 + 36 files changed, 3788 insertions(+), 143 deletions(-) create mode 100644 apps/docs/content/docs/en/integrations/google_appsheet.mdx create mode 100644 apps/sim/blocks/blocks/google_appsheet.ts create mode 100644 apps/sim/tools/google_appsheet/add_rows.ts create mode 100644 apps/sim/tools/google_appsheet/delete_rows.ts create mode 100644 apps/sim/tools/google_appsheet/edit_rows.ts create mode 100644 apps/sim/tools/google_appsheet/find_rows.ts create mode 100644 apps/sim/tools/google_appsheet/index.ts create mode 100644 apps/sim/tools/google_appsheet/types.ts create mode 100644 apps/sim/tools/google_appsheet/utils.test.ts create mode 100644 apps/sim/tools/google_appsheet/utils.ts diff --git a/apps/docs/components/icons.tsx b/apps/docs/components/icons.tsx index 2bc753071f2..00db61dd498 100644 --- a/apps/docs/components/icons.tsx +++ b/apps/docs/components/icons.tsx @@ -1742,6 +1742,21 @@ export function AmplitudeIcon(props: SVGProps) { ) } +export function GoogleAppsheetIcon(props: SVGProps) { + return ( + + + + + + + + ) +} + export function GoogleBooksIcon(props: SVGProps) { return ( diff --git a/apps/docs/components/ui/icon-mapping.ts b/apps/docs/components/ui/icon-mapping.ts index 243cb5ef066..f5f3a71a108 100644 --- a/apps/docs/components/ui/icon-mapping.ts +++ b/apps/docs/components/ui/icon-mapping.ts @@ -78,6 +78,7 @@ import { GmailIcon, GongIcon, GoogleAdsIcon, + GoogleAppsheetIcon, GoogleBigQueryIcon, GoogleBooksIcon, GoogleCalendarIcon, @@ -321,6 +322,7 @@ export const blockTypeToIconMap: Record = { gmail_v2: GmailIcon, gong: GongIcon, google_ads: GoogleAdsIcon, + google_appsheet: GoogleAppsheetIcon, google_bigquery: GoogleBigQueryIcon, google_books: GoogleBooksIcon, google_calendar: GoogleCalendarIcon, diff --git a/apps/docs/content/docs/en/integrations/ahrefs.mdx b/apps/docs/content/docs/en/integrations/ahrefs.mdx index c05bf0c8ebd..92d278e9fab 100644 --- a/apps/docs/content/docs/en/integrations/ahrefs.mdx +++ b/apps/docs/content/docs/en/integrations/ahrefs.mdx @@ -52,6 +52,34 @@ Get the Domain Rating (DR) and Ahrefs Rank for a target domain. Domain Rating sh | `domainRating` | number | Domain Rating score \(0-100\) | | `ahrefsRank` | number | Ahrefs Rank - global ranking based on backlink profile strength | +### `ahrefs_metrics` + +Get a one-call organic and paid search overview for a target domain or URL: organic traffic, organic keywords, paid traffic, paid keywords, and estimated traffic cost. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `target` | string | Yes | The target domain or URL to analyze. Example: "example.com" | +| `country` | string | No | Country code for traffic data. Example: "us", "gb", "de" | +| `mode` | string | No | Analysis mode: domain \(entire domain\), prefix \(URL prefix\), subdomains \(include all subdomains, default\), exact \(exact URL match\). Example: "domain" | +| `date` | string | No | Date to report metrics on, in YYYY-MM-DD format \(defaults to today\) | +| `apiKey` | string | Yes | Ahrefs API Key | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `metrics` | object | Organic and paid search overview | +| ↳ `organicTraffic` | number | Estimated monthly organic traffic | +| ↳ `organicKeywords` | number | Number of organic keywords ranked | +| ↳ `organicKeywordsTop3` | number | Number of organic keywords ranking in positions 1-3 | +| ↳ `organicCost` | number | Estimated monthly cost to replicate organic traffic via ads \(USD\) | +| ↳ `paidTraffic` | number | Estimated monthly paid search traffic | +| ↳ `paidKeywords` | number | Number of paid keywords targeted | +| ↳ `paidPages` | number | Number of pages receiving paid traffic | +| ↳ `paidCost` | number | Estimated monthly paid search spend \(USD\) | + ### `ahrefs_backlinks` Get a list of backlinks pointing to a target domain or URL. Returns details about each backlink including source URL, anchor text, and domain rating. @@ -61,10 +89,9 @@ Get a list of backlinks pointing to a target domain or URL. Returns details abou | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `target` | string | Yes | The target domain or URL to analyze. Example: "example.com" or "https://example.com/page" | -| `mode` | string | No | Analysis mode: domain \(entire domain\), prefix \(URL prefix\), subdomains \(include all subdomains\), exact \(exact URL match\). Example: "domain" | -| `date` | string | No | Date for historical data in YYYY-MM-DD format \(defaults to today\) | -| `limit` | number | No | Maximum number of results to return. Example: 50 \(default: 100\) | -| `offset` | number | No | Number of results to skip for pagination. Example: 100 | +| `mode` | string | No | Analysis mode: domain \(entire domain\), prefix \(URL prefix\), subdomains \(include all subdomains, default\), exact \(exact URL match\). Example: "domain" | +| `history` | string | No | Historical scope: "live" \(currently live backlinks\), "all_time" \(default, includes lost backlinks\), or "since:YYYY-MM-DD" \(backlinks found since a date\). | +| `limit` | number | No | Maximum number of results to return. Example: 50 \(default: 1000\) | | `apiKey` | string | Yes | Ahrefs API Key | #### Output @@ -82,28 +109,26 @@ Get a list of backlinks pointing to a target domain or URL. Returns details abou ### `ahrefs_backlinks_stats` -Get backlink statistics for a target domain or URL. Returns totals for different backlink types including dofollow, nofollow, text, image, and redirect links. +Get backlink and referring domain totals for a target domain or URL, both currently live and across all time. #### Input | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `target` | string | Yes | The target domain or URL to analyze. Example: "example.com" or "https://example.com/page" | -| `mode` | string | No | Analysis mode: domain \(entire domain\), prefix \(URL prefix\), subdomains \(include all subdomains\), exact \(exact URL match\). Example: "domain" | -| `date` | string | No | Date for historical data in YYYY-MM-DD format \(defaults to today\) | +| `mode` | string | No | Analysis mode: domain \(entire domain\), prefix \(URL prefix\), subdomains \(include all subdomains, default\), exact \(exact URL match\). Example: "domain" | +| `date` | string | No | Date to report metrics on, in YYYY-MM-DD format \(defaults to today\) | | `apiKey` | string | Yes | Ahrefs API Key | #### Output | Parameter | Type | Description | | --------- | ---- | ----------- | -| `stats` | object | Backlink statistics summary | -| ↳ `total` | number | Total number of live backlinks | -| ↳ `dofollow` | number | Number of dofollow backlinks | -| ↳ `nofollow` | number | Number of nofollow backlinks | -| ↳ `text` | number | Number of text backlinks | -| ↳ `image` | number | Number of image backlinks | -| ↳ `redirect` | number | Number of redirect backlinks | +| `stats` | object | Backlink and referring domain totals | +| ↳ `liveBacklinks` | number | Number of currently live backlinks | +| ↳ `liveReferringDomains` | number | Number of currently live referring domains | +| ↳ `allTimeBacklinks` | number | Total backlinks ever discovered, including lost ones | +| ↳ `allTimeReferringDomains` | number | Total referring domains ever discovered, including lost ones | ### `ahrefs_referring_domains` @@ -114,10 +139,9 @@ Get a list of domains that link to a target domain or URL. Returns unique referr | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `target` | string | Yes | The target domain or URL to analyze. Example: "example.com" or "https://example.com/page" | -| `mode` | string | No | Analysis mode: domain \(entire domain\), prefix \(URL prefix\), subdomains \(include all subdomains\), exact \(exact URL match\). Example: "domain" | -| `date` | string | No | Date for historical data in YYYY-MM-DD format \(defaults to today\) | -| `limit` | number | No | Maximum number of results to return. Example: 50 \(default: 100\) | -| `offset` | number | No | Number of results to skip for pagination. Example: 100 | +| `mode` | string | No | Analysis mode: domain \(entire domain\), prefix \(URL prefix\), subdomains \(include all subdomains, default\), exact \(exact URL match\). Example: "domain" | +| `history` | string | No | Historical scope: "live" \(currently live\), "all_time" \(default, includes lost domains\), or "since:YYYY-MM-DD" \(domains found since a date\). | +| `limit` | number | No | Maximum number of results to return. Example: 50 \(default: 1000\) | | `apiKey` | string | Yes | Ahrefs API Key | #### Output @@ -127,10 +151,34 @@ Get a list of domains that link to a target domain or URL. Returns unique referr | `referringDomains` | array | List of domains linking to the target | | ↳ `domain` | string | The referring domain | | ↳ `domainRating` | number | Domain Rating of the referring domain | -| ↳ `backlinks` | number | Total number of backlinks from this domain | +| ↳ `backlinks` | number | Total number of backlinks from this domain to the target | | ↳ `dofollowBacklinks` | number | Number of dofollow backlinks from this domain | | ↳ `firstSeen` | string | When the domain was first seen linking | -| ↳ `lastVisited` | string | When the domain was last checked | +| ↳ `lastVisited` | string | When the domain was last seen linking \(null if never re-crawled\) | + +### `ahrefs_broken_backlinks` + +Get a list of broken backlinks pointing to a target domain or URL. Useful for identifying link reclamation opportunities. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `target` | string | Yes | The target domain or URL to analyze. Example: "example.com" or "https://example.com/page" | +| `mode` | string | No | Analysis mode: domain \(entire domain\), prefix \(URL prefix\), subdomains \(include all subdomains, default\), exact \(exact URL match\). Example: "domain" | +| `limit` | number | No | Maximum number of results to return. Example: 50 \(default: 1000\) | +| `apiKey` | string | Yes | Ahrefs API Key | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `brokenBacklinks` | array | List of broken backlinks | +| ↳ `urlFrom` | string | The URL of the page containing the broken link | +| ↳ `urlTo` | string | The broken URL being linked to | +| ↳ `httpCode` | number | HTTP status code of the broken target URL \(e.g., 404, 410\) | +| ↳ `anchor` | string | The anchor text of the link | +| ↳ `domainRatingSource` | number | Domain Rating of the linking domain | ### `ahrefs_organic_keywords` @@ -142,10 +190,9 @@ Get organic keywords that a target domain or URL ranks for in Google search resu | --------- | ---- | -------- | ----------- | | `target` | string | Yes | The target domain or URL to analyze. Example: "example.com" or "https://example.com/page" | | `country` | string | No | Country code for search results. Example: "us", "gb", "de" \(default: "us"\) | -| `mode` | string | No | Analysis mode: domain \(entire domain\), prefix \(URL prefix\), subdomains \(include all subdomains\), exact \(exact URL match\). Example: "domain" | -| `date` | string | No | Date for historical data in YYYY-MM-DD format \(defaults to today\) | -| `limit` | number | No | Maximum number of results to return. Example: 50 \(default: 100\) | -| `offset` | number | No | Number of results to skip for pagination. Example: 100 | +| `mode` | string | No | Analysis mode: domain \(entire domain\), prefix \(URL prefix\), subdomains \(include all subdomains, default\), exact \(exact URL match\). Example: "domain" | +| `date` | string | No | Date to report metrics on, in YYYY-MM-DD format \(defaults to today\) | +| `limit` | number | No | Maximum number of results to return. Example: 50 \(default: 1000\) | | `apiKey` | string | Yes | Ahrefs API Key | #### Output @@ -155,11 +202,38 @@ Get organic keywords that a target domain or URL ranks for in Google search resu | `keywords` | array | List of organic keywords the target ranks for | | ↳ `keyword` | string | The keyword | | ↳ `volume` | number | Monthly search volume | -| ↳ `position` | number | Current ranking position | -| ↳ `url` | string | The URL that ranks for this keyword | +| ↳ `position` | number | Best ranking position for this keyword | +| ↳ `url` | string | The URL that ranks at the best position for this keyword | | ↳ `traffic` | number | Estimated monthly organic traffic | | ↳ `keywordDifficulty` | number | Keyword difficulty score \(0-100\) | +### `ahrefs_organic_competitors` + +Get domains that compete with a target domain or URL for the same organic keywords, ranked by keyword overlap. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `target` | string | Yes | The target domain or URL to analyze. Example: "example.com" | +| `country` | string | No | Country code for search results. Example: "us", "gb", "de" \(default: "us"\) | +| `mode` | string | No | Analysis mode: domain \(entire domain\), prefix \(URL prefix\), subdomains \(include all subdomains, default\), exact \(exact URL match\). Example: "domain" | +| `date` | string | No | Date to report metrics on, in YYYY-MM-DD format \(defaults to today\) | +| `limit` | number | No | Maximum number of results to return. Example: 50 \(default: 1000\) | +| `apiKey` | string | Yes | Ahrefs API Key | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `competitors` | array | List of organic search competitors ranked by keyword overlap | +| ↳ `domain` | string | The competitor domain | +| ↳ `domainRating` | number | Domain Rating of the competitor | +| ↳ `commonKeywords` | number | Number of keywords the competitor and target both rank for | +| ↳ `targetKeywords` | number | Number of keywords the target ranks for | +| ↳ `competitorKeywords` | number | Number of keywords the competitor ranks for | +| ↳ `traffic` | number | Estimated monthly organic traffic for the competitor | + ### `ahrefs_top_pages` Get the top pages of a target domain sorted by organic traffic. Returns page URLs with their traffic, keyword counts, and estimated traffic value. @@ -170,11 +244,9 @@ Get the top pages of a target domain sorted by organic traffic. Returns page URL | --------- | ---- | -------- | ----------- | | `target` | string | Yes | The target domain to analyze. Example: "example.com" | | `country` | string | No | Country code for traffic data. Example: "us", "gb", "de" \(default: "us"\) | -| `mode` | string | No | Analysis mode: domain \(entire domain\), prefix \(URL prefix\), subdomains \(include all subdomains\). Example: "domain" | -| `date` | string | No | Date for historical data in YYYY-MM-DD format \(defaults to today\) | -| `limit` | number | No | Maximum number of results to return. Example: 50 \(default: 100\) | -| `offset` | number | No | Number of results to skip for pagination. Example: 100 | -| `select` | string | No | Comma-separated list of fields to return \(e.g., url,traffic,keywords,top_keyword,value\). Default: url,traffic,keywords,top_keyword,value | +| `mode` | string | No | Analysis mode: domain \(entire domain\), prefix \(URL prefix\), subdomains \(include all subdomains, default\). Example: "domain" | +| `date` | string | No | Date to report metrics on, in YYYY-MM-DD format \(defaults to today\) | +| `limit` | number | No | Maximum number of results to return. Example: 50 \(default: 1000\) | | `apiKey` | string | Yes | Ahrefs API Key | #### Output @@ -210,34 +282,15 @@ Get detailed metrics for a keyword including search volume, keyword difficulty, | ↳ `keywordDifficulty` | number | Keyword difficulty score \(0-100\) | | ↳ `cpc` | number | Cost per click in USD | | ↳ `clicks` | number | Estimated clicks per month | -| ↳ `clicksPercentage` | number | Percentage of searches that result in clicks | +| ↳ `clicksPercentage` | number | Percentage of searches that result in an organic click | | ↳ `parentTopic` | string | The parent topic for this keyword | | ↳ `trafficPotential` | number | Estimated traffic potential if ranking #1 | - -### `ahrefs_broken_backlinks` - -Get a list of broken backlinks pointing to a target domain or URL. Useful for identifying link reclamation opportunities. - -#### Input - -| Parameter | Type | Required | Description | -| --------- | ---- | -------- | ----------- | -| `target` | string | Yes | The target domain or URL to analyze. Example: "example.com" or "https://example.com/page" | -| `mode` | string | No | Analysis mode: domain \(entire domain\), prefix \(URL prefix\), subdomains \(include all subdomains\), exact \(exact URL match\). Example: "domain" | -| `date` | string | No | Date for historical data in YYYY-MM-DD format \(defaults to today\) | -| `limit` | number | No | Maximum number of results to return. Example: 50 \(default: 100\) | -| `offset` | number | No | Number of results to skip for pagination. Example: 100 | -| `apiKey` | string | Yes | Ahrefs API Key | - -#### Output - -| Parameter | Type | Description | -| --------- | ---- | ----------- | -| `brokenBacklinks` | array | List of broken backlinks | -| ↳ `urlFrom` | string | The URL of the page containing the broken link | -| ↳ `urlTo` | string | The broken URL being linked to | -| ↳ `httpCode` | number | HTTP status code \(e.g., 404, 410\) | -| ↳ `anchor` | string | The anchor text of the link | -| ↳ `domainRatingSource` | number | Domain Rating of the linking domain | +| ↳ `intents` | object | Search intent flags \(informational, navigational, commercial, transactional, branded, local\) | +| ↳ `informational` | boolean | Query seeks information | +| ↳ `navigational` | boolean | Query seeks a specific site or page | +| ↳ `commercial` | boolean | Query researches a purchase decision | +| ↳ `transactional` | boolean | Query intends to complete a purchase | +| ↳ `branded` | boolean | Query references a specific brand | +| ↳ `local` | boolean | Query seeks local results | diff --git a/apps/docs/content/docs/en/integrations/algolia.mdx b/apps/docs/content/docs/en/integrations/algolia.mdx index 9d506105db4..72a4039b141 100644 --- a/apps/docs/content/docs/en/integrations/algolia.mdx +++ b/apps/docs/content/docs/en/integrations/algolia.mdx @@ -235,7 +235,7 @@ Perform batch add, update, partial update, or delete operations on records in an | `applicationId` | string | Yes | Algolia Application ID | | `apiKey` | string | Yes | Algolia Admin API Key | | `indexName` | string | Yes | Name of the Algolia index | -| `requests` | json | Yes | Array of batch operations. Each item has "action" \(addObject, updateObject, partialUpdateObject, partialUpdateObjectNoCreate, deleteObject, delete, clear\) and "body" \(the record data, must include objectID for update/delete; omit body for delete/clear\) | +| `requests` | json | Yes | Array of batch operations. Each item has "action" \(addObject, updateObject, partialUpdateObject, partialUpdateObjectNoCreate, deleteObject, delete, clear\) and "body" \(the record data; must include objectID for update/delete; use an empty object \{\} for the index-level delete/clear actions\) | #### Output diff --git a/apps/docs/content/docs/en/integrations/amplitude.mdx b/apps/docs/content/docs/en/integrations/amplitude.mdx index 45a806b31f6..53d0b88dc78 100644 --- a/apps/docs/content/docs/en/integrations/amplitude.mdx +++ b/apps/docs/content/docs/en/integrations/amplitude.mdx @@ -7,7 +7,7 @@ import { BlockInfoCard } from "@/components/ui/block-info-card" {/* MANUAL-CONTENT-START:intro */} @@ -31,7 +31,7 @@ In Sim, the Amplitude integration enables powerful analytics automation scenario ## Usage Instructions -Integrate Amplitude into your workflow to track events, identify users and groups, search for users, query analytics, and retrieve revenue data. +Integrate Amplitude into your workflow to track events, identify users and groups, search for users, query analytics, analyze funnels and retention, and retrieve revenue data. @@ -64,6 +64,7 @@ Track an event in Amplitude using the HTTP V2 API. | `revenue` | string | No | Revenue amount | | `productId` | string | No | Product identifier | | `revenueType` | string | No | Revenue type \(e.g., "purchase", "refund"\) | +| `dataResidency` | string | No | Data residency region: "us" \(default\) or "eu" | #### Output @@ -86,6 +87,7 @@ Set user properties in Amplitude using the Identify API. Supports $set, $setOnce | `userId` | string | No | User ID \(required if no device_id\) | | `deviceId` | string | No | Device ID \(required if no user_id\) | | `userProperties` | string | Yes | JSON object of user properties. Use operations like $set, $setOnce, $add, $append, $unset. | +| `dataResidency` | string | No | Data residency region: "us" \(default\) or "eu" | #### Output @@ -106,6 +108,7 @@ Set group-level properties in Amplitude. Supports $set, $setOnce, $add, $append, | `groupType` | string | Yes | Group classification \(e.g., "company", "org_id"\) | | `groupValue` | string | Yes | Specific group identifier \(e.g., "Acme Corp"\) | | `groupProperties` | string | Yes | JSON object of group properties. Use operations like $set, $setOnce, $add, $append, $unset. | +| `dataResidency` | string | No | Data residency region: "us" \(default\) or "eu" | #### Output @@ -125,6 +128,7 @@ Search for a user by User ID, Device ID, or Amplitude ID using the Dashboard RES | `apiKey` | string | Yes | Amplitude API Key | | `secretKey` | string | Yes | Amplitude Secret Key | | `user` | string | Yes | User ID, Device ID, or Amplitude ID to search for | +| `dataResidency` | string | No | Data residency region: "us" \(default\) or "eu" | #### Output @@ -149,6 +153,7 @@ Get the event stream for a specific user by their Amplitude ID. | `offset` | string | No | Offset for pagination \(default 0\) | | `limit` | string | No | Maximum number of events to return \(default 1000, max 1000\) | | `direction` | string | No | Sort direction: "latest" or "earliest" \(default: latest\) | +| `dataResidency` | string | No | Data residency region: "us" \(default\) or "eu" | #### Output @@ -170,10 +175,12 @@ Get the event stream for a specific user by their Amplitude ID. | ↳ `numSessions` | number | Total session count | | ↳ `platform` | string | Primary platform | | ↳ `country` | string | Country | +| ↳ `firstUsed` | string | Date the user first appeared | +| ↳ `lastUsed` | string | Date of most recent user activity | ### `amplitude_user_profile` -Get a user profile including properties, cohort memberships, and computed properties. +Get a user profile including properties, cohort memberships, and computed properties. Not available for EU data-residency projects. #### Input @@ -212,7 +219,12 @@ Query event analytics data with segmentation. Get event counts, uniques, average | `metric` | string | No | Metric type: uniques, totals, pct_dau, average, histogram, sums, value_avg, or formula \(default: uniques\) | | `interval` | string | No | Time interval: 1 \(daily\), 7 \(weekly\), or 30 \(monthly\) | | `groupBy` | string | No | Property name to group by \(prefix custom user properties with "gp:"\) | +| `groupBy2` | string | No | Second property name to group by \(prefix custom user properties with "gp:"\) | | `limit` | string | No | Maximum number of group-by values \(max 1000\) | +| `filters` | string | No | JSON array of filter objects applied to the event, e.g. \[\{"subprop_type":"event","subprop_key":"city","subprop_op":"is","subprop_value":\["San Francisco"\]\}\] | +| `formula` | string | No | Required when metric is "formula", e.g. "UNIQUES\(A\)/UNIQUES\(B\)" | +| `segment` | string | No | JSON segment definition\(s\) applied to the query | +| `dataResidency` | string | No | Data residency region: "us" \(default\) or "eu" | #### Output @@ -237,6 +249,9 @@ Get active or new user counts over a date range from the Dashboard REST API. | `end` | string | Yes | End date in YYYYMMDD format | | `metric` | string | No | Metric type: "active" or "new" \(default: active\) | | `interval` | string | No | Time interval: 1 \(daily\), 7 \(weekly\), or 30 \(monthly\) | +| `groupBy` | string | No | Property name to group by | +| `segment` | string | No | JSON segment definition\(s\) applied to the query | +| `dataResidency` | string | No | Data residency region: "us" \(default\) or "eu" | #### Output @@ -256,6 +271,7 @@ Get real-time active user counts at 5-minute granularity for the last 2 days. | --------- | ---- | -------- | ----------- | | `apiKey` | string | Yes | Amplitude API Key | | `secretKey` | string | Yes | Amplitude Secret Key | +| `dataResidency` | string | No | Data residency region: "us" \(default\) or "eu" | #### Output @@ -275,6 +291,7 @@ List all event types in the Amplitude project with their weekly totals and uniqu | --------- | ---- | -------- | ----------- | | `apiKey` | string | Yes | Amplitude API Key | | `secretKey` | string | Yes | Amplitude Secret Key | +| `dataResidency` | string | No | Data residency region: "us" \(default\) or "eu" | #### Output @@ -286,6 +303,8 @@ List all event types in the Amplitude project with their weekly totals and uniqu | ↳ `totals` | number | Weekly total count | | ↳ `hidden` | boolean | Whether the event is hidden | | ↳ `deleted` | boolean | Whether the event is deleted | +| ↳ `nonActive` | boolean | Whether the event is excluded from active user calculations | +| ↳ `flowHidden` | boolean | Whether the event is hidden from user flow charts | ### `amplitude_get_revenue` @@ -301,13 +320,83 @@ Get revenue LTV data including ARPU, ARPPU, total revenue, and paying user count | `end` | string | Yes | End date in YYYYMMDD format | | `metric` | string | No | Metric: 0 \(ARPU\), 1 \(ARPPU\), 2 \(Total Revenue\), 3 \(Paying Users\) | | `interval` | string | No | Time interval: 1 \(daily\), 7 \(weekly\), or 30 \(monthly\) | +| `groupBy` | string | No | Property name to group by \(limit: one\) | +| `segment` | string | No | JSON segment definition\(s\) applied to the query | +| `dataResidency` | string | No | Data residency region: "us" \(default\) or "eu" | #### Output | Parameter | Type | Description | | --------- | ---- | ----------- | -| `series` | json | Array of revenue data series | +| `series` | array | Revenue data series \[\{dates: \[YYYY-MM-DD\], values: \{<date>: \{r1d..r90d, count, paid, total_amount\}\}\}\] | +| ↳ `dates` | array | Dates covered by this series | +| ↳ `values` | json | Per-date metric values keyed by date \(r1d..r90d, count, paid, total_amount\) | | `seriesLabels` | array | Labels for each data series | -| `xValues` | array | Date values for the x-axis | + +### `amplitude_funnels` + +Analyze conversion rates and drop-off between a sequence of events. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Amplitude API Key | +| `secretKey` | string | Yes | Amplitude Secret Key | +| `events` | string | Yes | JSON array of event objects, one per funnel step in order, e.g. \[\{"event_type":"signup"\},\{"event_type":"purchase"\}\] | +| `start` | string | Yes | Start date in YYYYMMDD format | +| `end` | string | Yes | End date in YYYYMMDD format | +| `mode` | string | No | Funnel ordering: "ordered", "unordered", or "sequential" \(default: ordered\) | +| `userType` | string | No | User type: "new" or "active" \(default: active\) | +| `interval` | string | No | Time interval: -300000 \(real-time\), -3600000 \(hourly\), 1 \(daily\), 7 \(weekly\), or 30 \(monthly\) | +| `conversionWindowSeconds` | string | No | Conversion window in seconds \(default: 2592000, i.e. 30 days\) | +| `groupBy` | string | No | Property to group by \(limit: one; prefix custom properties with "gp:"\) | +| `limit` | string | No | Maximum number of group-by values \(default: 100, max: 1000\) | +| `segment` | string | No | JSON segment definition\(s\) applied to the query | +| `dataResidency` | string | No | Data residency region: "us" \(default\) or "eu" | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `funnels` | array | Funnel results, one entry per segment | +| ↳ `stepByStep` | json | Conversion count at each step | +| ↳ `cumulative` | json | Cumulative conversion percentage at each step | +| ↳ `cumulativeRaw` | json | Cumulative conversion count at each step | +| ↳ `medianTransTimes` | json | Median transition time between steps \(ms\) | +| ↳ `avgTransTimes` | json | Average transition time between steps \(ms\) | +| ↳ `events` | json | Event names for each funnel step | +| ↳ `dayFunnels` | json | Daily funnel breakdown \{series, xValues\} | + +### `amplitude_retention` + +Measure how many users return to perform an action after a starting action. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Amplitude API Key | +| `secretKey` | string | Yes | Amplitude Secret Key | +| `startEvent` | string | Yes | JSON starting event object, e.g. \{"event_type":"_new"\} or \{"event_type":"_active"\} | +| `returnEvent` | string | Yes | JSON returning event object, e.g. \{"event_type":"_all"\} or \{"event_type":"_active"\} | +| `start` | string | Yes | Start date in YYYYMMDD format | +| `end` | string | Yes | End date in YYYYMMDD format | +| `retentionMode` | string | No | Retention type: "bracket", "rolling", or "n-day" \(default: n-day\) | +| `retentionBrackets` | string | No | Required when Retention Mode is "bracket". Day ranges, e.g. \[\[0,4\]\] | +| `interval` | string | No | Time interval: 1 \(daily\), 7 \(weekly\), or 30 \(monthly\) | +| `groupBy` | string | No | Property to group by \(limit: one; prefix custom properties with "gp:"\) | +| `segment` | string | No | JSON segment definition\(s\) applied to the query | +| `dataResidency` | string | No | Data residency region: "us" \(default\) or "eu" | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `series` | array | Retention data series \[\{dates, values: \{<date>: \[\{count, outof, incomplete\}\]\}, combined: \[\{count, outof, incomplete\}\]\}\] | +| ↳ `dates` | array | Cohort dates | +| ↳ `values` | json | Per-cohort-date retention counts keyed by date | +| ↳ `combined` | json | Deduplicated aggregate retention across all cohorts | +| `seriesMeta` | array | Segment/event index metadata for each series entry | diff --git a/apps/docs/content/docs/en/integrations/brex.mdx b/apps/docs/content/docs/en/integrations/brex.mdx index 09b49faf55b..cd23dc79a51 100644 --- a/apps/docs/content/docs/en/integrations/brex.mdx +++ b/apps/docs/content/docs/en/integrations/brex.mdx @@ -724,6 +724,8 @@ List spend limits in the Brex account, optionally filtered by member user | ↳ `status` | string | Spend limit status | | ↳ `period_recurrence_type` | string | Period recurrence \(PER_WEEK, PER_MONTH, PER_QUARTER, PER_YEAR, ONE_TIME\) | | ↳ `spend_type` | string | Spend type of the limit | +| ↳ `start_date` | string | Spend limit start date | +| ↳ `end_date` | string | Spend limit end date | | ↳ `owner_user_ids` | array | User IDs of the spend limit owners | | ↳ `member_user_ids` | array | User IDs of the spend limit members | | ↳ `current_period_balance` | json | Spend and rollover amounts for the current period | @@ -737,6 +739,7 @@ List spend limits in the Brex account, optionally filtered by member user | ↳ `rollover_amount` | json | Amount rolled over from previous periods | | ↳ `amount` | number | Amount in the smallest unit of the currency \(e.g., cents for USD\) | | ↳ `currency` | string | ISO 4217 currency code \(e.g., USD\) | +| ↳ `authorization_settings` | json | Authorization settings \(base limit, authorization type, rollover refresh\) | | `nextCursor` | string | Cursor for fetching the next page of results | ### `brex_get_spend_limit` @@ -858,6 +861,7 @@ List money transfers in the Brex account | ↳ `created_at` | string | Creation timestamp | | ↳ `display_name` | string | Transfer display name | | ↳ `external_memo` | string | External memo | +| ↳ `is_ppro_enabled` | boolean | Whether Principal Protection \(PPRO\) is enabled | | `nextCursor` | string | Cursor for fetching the next page of results | ### `brex_get_transfer` @@ -891,5 +895,6 @@ Get a Brex money transfer by its ID | `createdAt` | string | Creation timestamp | | `displayName` | string | Transfer display name | | `externalMemo` | string | External memo | +| `isPproEnabled` | boolean | Whether Principal Protection \(PPRO\) is enabled | diff --git a/apps/docs/content/docs/en/integrations/clerk.mdx b/apps/docs/content/docs/en/integrations/clerk.mdx index 7a9df1a41fd..cfeba8ac3ae 100644 --- a/apps/docs/content/docs/en/integrations/clerk.mdx +++ b/apps/docs/content/docs/en/integrations/clerk.mdx @@ -28,7 +28,7 @@ The integration enables real-time, auditable management of your user base—all ## Usage Instructions -Integrate Clerk authentication and user management into your workflow. Create, update, delete, and list users. Manage organizations and their memberships. Monitor and control user sessions. +Integrate Clerk authentication and user management into your workflow. Create, update, delete, ban, lock, and list users. Manage organizations, their memberships, and invitations. Monitor and control user sessions. Maintain allowlist/blocklist identifiers, JWT templates, and actor tokens. @@ -251,6 +251,132 @@ Delete a user from your Clerk application | `deleted` | boolean | Whether the user was deleted | | `success` | boolean | Operation success status | +### `clerk_ban_user` + +Ban a user, preventing them from signing in + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `secretKey` | string | Yes | The Clerk Secret Key for API authentication | +| `userId` | string | Yes | The ID of the user to ban \(e.g., user_2NNEqL2nrIRdJ194ndJqAHwEfxC\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | User ID | +| `username` | string | Username | +| `firstName` | string | First name | +| `lastName` | string | Last name | +| `banned` | boolean | Whether the user is banned | +| `locked` | boolean | Whether the user is locked | +| `lockoutExpiresInSeconds` | number | Seconds until lockout expires | +| `updatedAt` | number | Last update timestamp | +| `success` | boolean | Operation success status | + +### `clerk_unban_user` + +Remove a ban from a user, allowing them to sign in again + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `secretKey` | string | Yes | The Clerk Secret Key for API authentication | +| `userId` | string | Yes | The ID of the user to unban \(e.g., user_2NNEqL2nrIRdJ194ndJqAHwEfxC\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | User ID | +| `username` | string | Username | +| `firstName` | string | First name | +| `lastName` | string | Last name | +| `banned` | boolean | Whether the user is banned | +| `locked` | boolean | Whether the user is locked | +| `lockoutExpiresInSeconds` | number | Seconds until lockout expires | +| `updatedAt` | number | Last update timestamp | +| `success` | boolean | Operation success status | + +### `clerk_lock_user` + +Lock a user account, blocking sign-in attempts + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `secretKey` | string | Yes | The Clerk Secret Key for API authentication | +| `userId` | string | Yes | The ID of the user to lock \(e.g., user_2NNEqL2nrIRdJ194ndJqAHwEfxC\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | User ID | +| `username` | string | Username | +| `firstName` | string | First name | +| `lastName` | string | Last name | +| `banned` | boolean | Whether the user is banned | +| `locked` | boolean | Whether the user is locked | +| `lockoutExpiresInSeconds` | number | Seconds until lockout expires | +| `updatedAt` | number | Last update timestamp | +| `success` | boolean | Operation success status | + +### `clerk_unlock_user` + +Unlock a previously locked user account + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `secretKey` | string | Yes | The Clerk Secret Key for API authentication | +| `userId` | string | Yes | The ID of the user to unlock \(e.g., user_2NNEqL2nrIRdJ194ndJqAHwEfxC\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | User ID | +| `username` | string | Username | +| `firstName` | string | First name | +| `lastName` | string | Last name | +| `banned` | boolean | Whether the user is banned | +| `locked` | boolean | Whether the user is locked | +| `lockoutExpiresInSeconds` | number | Seconds until lockout expires | +| `updatedAt` | number | Last update timestamp | +| `success` | boolean | Operation success status | + +### `clerk_get_user_oauth_token` + +Retrieve a user's OAuth access token for a connected external provider (e.g. Google, GitHub, Microsoft) obtained via Clerk SSO + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `secretKey` | string | Yes | The Clerk Secret Key for API authentication | +| `userId` | string | Yes | The ID of the user \(e.g., user_2NNEqL2nrIRdJ194ndJqAHwEfxC\) | +| `provider` | string | Yes | OAuth provider slug, e.g. google, github, microsoft, discord \(without the oauth_ prefix\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `accessTokens` | array | OAuth access tokens for the connected provider | +| ↳ `externalAccountId` | string | External account ID | +| ↳ `token` | string | OAuth access token | +| ↳ `expiresAt` | number | Expiration timestamp | +| ↳ `provider` | string | OAuth provider slug | +| ↳ `label` | string | Token label | +| ↳ `scopes` | array | OAuth scopes granted to the token | +| ↳ `publicMetadata` | json | Public metadata associated with the token | +| `success` | boolean | Operation success status | + ### `clerk_list_organizations` List all organizations in your Clerk application with optional filtering @@ -352,6 +478,278 @@ Create a new organization in your Clerk application | `publicMetadata` | json | Public metadata | | `success` | boolean | Operation success status | +### `clerk_update_organization` + +Update an existing organization in your Clerk application + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `secretKey` | string | Yes | The Clerk Secret Key for API authentication | +| `organizationId` | string | Yes | The ID of the organization to update \(e.g., org_2NNEqL2nrIRdJ194ndJqAHwEfxC\) | +| `name` | string | No | Name of the organization | +| `slug` | string | No | Slug identifier for the organization | +| `maxAllowedMemberships` | number | No | Maximum member capacity \(0 for unlimited\) | +| `adminDeleteEnabled` | boolean | No | Whether admins can delete the organization | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | Organization ID | +| `name` | string | Organization name | +| `slug` | string | Organization slug | +| `imageUrl` | string | Organization image URL | +| `hasImage` | boolean | Whether organization has an image | +| `membersCount` | number | Number of members | +| `pendingInvitationsCount` | number | Number of pending invitations | +| `maxAllowedMemberships` | number | Max allowed memberships | +| `adminDeleteEnabled` | boolean | Whether admin delete is enabled | +| `createdBy` | string | Creator user ID | +| `createdAt` | number | Creation timestamp | +| `updatedAt` | number | Last update timestamp | +| `publicMetadata` | json | Public metadata | +| `success` | boolean | Operation success status | + +### `clerk_delete_organization` + +Delete an organization from your Clerk application + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `secretKey` | string | Yes | The Clerk Secret Key for API authentication | +| `organizationId` | string | Yes | The ID of the organization to delete \(e.g., org_2NNEqL2nrIRdJ194ndJqAHwEfxC\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | Deleted organization ID | +| `object` | string | Object type \(organization\) | +| `deleted` | boolean | Whether the organization was deleted | +| `success` | boolean | Operation success status | + +### `clerk_list_organization_memberships` + +List members of a Clerk organization with optional filtering and pagination + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `secretKey` | string | Yes | The Clerk Secret Key for API authentication | +| `organizationId` | string | Yes | The ID of the organization \(e.g., org_2NNEqL2nrIRdJ194ndJqAHwEfxC\) | +| `limit` | number | No | Number of results per page \(e.g., 10, 50, 100; range: 1-500, default: 10\) | +| `offset` | number | No | Number of results to skip for pagination \(e.g., 0, 10, 20\) | +| `orderBy` | string | No | Sort field \(e.g., created_at\) with +/- prefix for direction | +| `role` | string | No | Filter by role, comma-separated for multiple \(e.g., org:admin,org:member\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `memberships` | array | Array of Clerk organization membership objects | +| ↳ `id` | string | Membership ID | +| ↳ `role` | string | Member role | +| ↳ `roleName` | string | Human-readable role name | +| ↳ `permissions` | array | Permissions granted by the role | +| ↳ `organizationId` | string | Organization ID | +| ↳ `userId` | string | Member user ID | +| ↳ `firstName` | string | Member first name | +| ↳ `lastName` | string | Member last name | +| ↳ `imageUrl` | string | Member profile image URL | +| ↳ `identifier` | string | Member identifier \(e.g., email\) | +| ↳ `username` | string | Member username | +| ↳ `banned` | boolean | Whether the member is banned | +| ↳ `publicMetadata` | json | Public metadata | +| ↳ `createdAt` | number | Creation timestamp | +| ↳ `updatedAt` | number | Last update timestamp | +| `totalCount` | number | Total number of memberships | +| `success` | boolean | Operation success status | + +### `clerk_add_organization_member` + +Add a user as a member of a Clerk organization with a given role + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `secretKey` | string | Yes | The Clerk Secret Key for API authentication | +| `organizationId` | string | Yes | The ID of the organization \(e.g., org_2NNEqL2nrIRdJ194ndJqAHwEfxC\) | +| `userId` | string | Yes | ID of the user to add as a member | +| `role` | string | Yes | Role to assign, e.g. org:admin or org:member | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | Membership ID | +| `role` | string | Member role | +| `roleName` | string | Human-readable role name | +| `permissions` | array | Permissions granted by the role | +| `organizationId` | string | Organization ID | +| `userId` | string | Member user ID | +| `firstName` | string | Member first name | +| `lastName` | string | Member last name | +| `imageUrl` | string | Member profile image URL | +| `identifier` | string | Member identifier \(e.g., email\) | +| `username` | string | Member username | +| `banned` | boolean | Whether the member is banned | +| `publicMetadata` | json | Public metadata | +| `createdAt` | number | Creation timestamp | +| `updatedAt` | number | Last update timestamp | +| `success` | boolean | Operation success status | + +### `clerk_update_organization_membership` + +Change a member's role within a Clerk organization + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `secretKey` | string | Yes | The Clerk Secret Key for API authentication | +| `organizationId` | string | Yes | The ID of the organization \(e.g., org_2NNEqL2nrIRdJ194ndJqAHwEfxC\) | +| `userId` | string | Yes | ID of the member whose role is being changed | +| `role` | string | Yes | New role to assign, e.g. org:admin or org:member | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | Membership ID | +| `role` | string | Member role | +| `roleName` | string | Human-readable role name | +| `permissions` | array | Permissions granted by the role | +| `organizationId` | string | Organization ID | +| `userId` | string | Member user ID | +| `firstName` | string | Member first name | +| `lastName` | string | Member last name | +| `imageUrl` | string | Member profile image URL | +| `identifier` | string | Member identifier \(e.g., email\) | +| `username` | string | Member username | +| `banned` | boolean | Whether the member is banned | +| `publicMetadata` | json | Public metadata | +| `createdAt` | number | Creation timestamp | +| `updatedAt` | number | Last update timestamp | +| `success` | boolean | Operation success status | + +### `clerk_remove_organization_member` + +Remove a member from a Clerk organization + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `secretKey` | string | Yes | The Clerk Secret Key for API authentication | +| `organizationId` | string | Yes | The ID of the organization \(e.g., org_2NNEqL2nrIRdJ194ndJqAHwEfxC\) | +| `userId` | string | Yes | ID of the member to remove | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | Membership ID | +| `role` | string | Member role | +| `roleName` | string | Human-readable role name | +| `permissions` | array | Permissions granted by the role | +| `organizationId` | string | Organization ID | +| `userId` | string | Member user ID | +| `firstName` | string | Member first name | +| `lastName` | string | Member last name | +| `imageUrl` | string | Member profile image URL | +| `identifier` | string | Member identifier \(e.g., email\) | +| `username` | string | Member username | +| `banned` | boolean | Whether the member is banned | +| `publicMetadata` | json | Public metadata | +| `createdAt` | number | Creation timestamp | +| `updatedAt` | number | Last update timestamp | +| `success` | boolean | Operation success status | + +### `clerk_create_organization_invitation` + +Invite a user by email to join a Clerk organization with a given role + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `secretKey` | string | Yes | The Clerk Secret Key for API authentication | +| `organizationId` | string | Yes | The ID of the organization \(e.g., org_2NNEqL2nrIRdJ194ndJqAHwEfxC\) | +| `emailAddress` | string | Yes | Email address of the user to invite | +| `role` | string | Yes | Role to assign on acceptance, e.g. org:admin or org:member | +| `inviterUserId` | string | No | User ID of the inviter | +| `redirectUrl` | string | No | URL to redirect to after the invitation is accepted | +| `expiresInDays` | number | No | Days until the invitation expires \(1-365, default 30\) | +| `publicMetadata` | json | No | Public metadata \(JSON object\) | +| `privateMetadata` | json | No | Private metadata \(JSON object\) | +| `notify` | boolean | No | Whether Clerk sends the invitation email \(default true\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | Invitation ID | +| `emailAddress` | string | Invited email address | +| `role` | string | Role to assign on acceptance | +| `roleName` | string | Human-readable role name | +| `organizationId` | string | Organization ID | +| `inviterId` | string | User ID of the inviter | +| `inviterEmail` | string | Inviter's email address | +| `inviterFirstName` | string | Inviter's first name | +| `inviterLastName` | string | Inviter's last name | +| `status` | string | Invitation status | +| `url` | string | Invitation URL | +| `expiresAt` | number | Expiration timestamp | +| `publicMetadata` | json | Public metadata | +| `createdAt` | number | Creation timestamp | +| `updatedAt` | number | Last update timestamp | +| `success` | boolean | Operation success status | + +### `clerk_list_organization_invitations` + +List pending and past invitations for a Clerk organization + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `secretKey` | string | Yes | The Clerk Secret Key for API authentication | +| `organizationId` | string | Yes | The ID of the organization \(e.g., org_2NNEqL2nrIRdJ194ndJqAHwEfxC\) | +| `status` | string | No | Filter by status: pending, accepted, revoked, or expired | +| `emailAddress` | string | No | Filter by invited email address | +| `orderBy` | string | No | Sort field \(created_at, email_address\) with +/- prefix \(default: -created_at\) | +| `limit` | number | No | Number of results per page \(e.g., 10, 50, 100; range: 1-500, default: 10\) | +| `offset` | number | No | Number of results to skip for pagination \(e.g., 0, 10, 20\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `invitations` | array | Array of Clerk organization invitation objects | +| ↳ `id` | string | Invitation ID | +| ↳ `emailAddress` | string | Invited email address | +| ↳ `role` | string | Role to assign on acceptance | +| ↳ `roleName` | string | Human-readable role name | +| ↳ `organizationId` | string | Organization ID | +| ↳ `inviterId` | string | User ID of the inviter | +| ↳ `inviterEmail` | string | Inviter's email address | +| ↳ `inviterFirstName` | string | Inviter's first name | +| ↳ `inviterLastName` | string | Inviter's last name | +| ↳ `status` | string | Invitation status | +| ↳ `url` | string | Invitation URL | +| ↳ `expiresAt` | number | Expiration timestamp | +| ↳ `publicMetadata` | json | Public metadata | +| ↳ `createdAt` | number | Creation timestamp | +| ↳ `updatedAt` | number | Last update timestamp | +| `totalCount` | number | Total number of invitations | +| `success` | boolean | Operation success status | + ### `clerk_list_sessions` List sessions for a user or client in your Clerk application @@ -439,27 +837,268 @@ Revoke a session to immediately invalidate it | `updatedAt` | number | Last update timestamp | | `success` | boolean | Operation success status | +### `clerk_list_allowlist_identifiers` +List email/phone/web3-wallet identifiers on your Clerk instance allowlist -## Triggers - -A **Trigger** is a block that starts a workflow when an event happens in this service. - -### Clerk Organization Created - -Trigger workflow when a Clerk organization is created - -#### Configuration +#### Input | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `signingSecret` | string | Yes | Copy this from your Clerk webhook endpoint to verify event signatures. | +| `secretKey` | string | Yes | The Clerk Secret Key for API authentication | +| `limit` | number | No | Number of results per page \(e.g., 10, 50, 100; range: 1-500, default: 10\) | +| `offset` | number | No | Number of results to skip for pagination \(e.g., 0, 10, 20\) | #### Output | Parameter | Type | Description | | --------- | ---- | ----------- | -| `type` | string | Event type \(e.g., user.created, session.created\) | +| `identifiers` | array | Array of Clerk allowlist identifier objects | +| ↳ `id` | string | Allowlist identifier ID | +| ↳ `identifier` | string | Email, phone, or web3 wallet identifier | +| ↳ `identifierType` | string | Type of identifier | +| ↳ `invitationId` | string | Associated invitation ID | +| ↳ `createdAt` | number | Creation timestamp | +| ↳ `updatedAt` | number | Last update timestamp | +| `totalCount` | number | Total number of allowlist identifiers | +| `success` | boolean | Operation success status | + +### `clerk_create_allowlist_identifier` + +Add an email, phone number, or web3 wallet to your Clerk instance allowlist + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `secretKey` | string | Yes | The Clerk Secret Key for API authentication | +| `identifier` | string | Yes | Email address, phone number, or web3 wallet to allow \(wildcards like *@example.com supported for email\) | +| `notify` | boolean | No | Whether to notify the identifier owner by email \(default false\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | Allowlist identifier ID | +| `identifier` | string | Email, phone, or web3 wallet identifier | +| `identifierType` | string | Type of identifier | +| `invitationId` | string | Associated invitation ID | +| `createdAt` | number | Creation timestamp | +| `updatedAt` | number | Last update timestamp | +| `success` | boolean | Operation success status | + +### `clerk_delete_allowlist_identifier` + +Remove an identifier from your Clerk instance allowlist + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `secretKey` | string | Yes | The Clerk Secret Key for API authentication | +| `identifierId` | string | Yes | ID of the allowlist identifier to delete | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | Deleted allowlist identifier ID | +| `object` | string | Object type \(allowlist_identifier\) | +| `deleted` | boolean | Whether the identifier was deleted | +| `success` | boolean | Operation success status | + +### `clerk_list_blocklist_identifiers` + +List email/phone/web3-wallet identifiers on your Clerk instance blocklist + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `secretKey` | string | Yes | The Clerk Secret Key for API authentication | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `identifiers` | array | Array of Clerk blocklist identifier objects | +| ↳ `id` | string | Blocklist identifier ID | +| ↳ `identifier` | string | Email, phone, or web3 wallet identifier | +| ↳ `identifierType` | string | Type of identifier | +| ↳ `createdAt` | number | Creation timestamp | +| ↳ `updatedAt` | number | Last update timestamp | +| `totalCount` | number | Total number of blocklist identifiers | +| `success` | boolean | Operation success status | + +### `clerk_create_blocklist_identifier` + +Add an email, phone number, or web3 wallet to your Clerk instance blocklist to prevent sign-ups + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `secretKey` | string | Yes | The Clerk Secret Key for API authentication | +| `identifier` | string | Yes | Email address, phone number, or web3 wallet to block | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | Blocklist identifier ID | +| `identifier` | string | Email, phone, or web3 wallet identifier | +| `identifierType` | string | Type of identifier | +| `createdAt` | number | Creation timestamp | +| `updatedAt` | number | Last update timestamp | +| `success` | boolean | Operation success status | + +### `clerk_delete_blocklist_identifier` + +Remove an identifier from your Clerk instance blocklist + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `secretKey` | string | Yes | The Clerk Secret Key for API authentication | +| `identifierId` | string | Yes | ID of the blocklist identifier to delete | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | Deleted blocklist identifier ID | +| `object` | string | Object type \(blocklist_identifier\) | +| `deleted` | boolean | Whether the identifier was deleted | +| `success` | boolean | Operation success status | + +### `clerk_list_jwt_templates` + +List custom JWT templates configured on your Clerk instance + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `secretKey` | string | Yes | The Clerk Secret Key for API authentication | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `templates` | array | Array of Clerk JWT template objects | +| ↳ `id` | string | JWT template ID | +| ↳ `name` | string | JWT template name | +| ↳ `claims` | json | Custom claims defined on the template | +| ↳ `lifetime` | number | Token lifetime in seconds | +| ↳ `allowedClockSkew` | number | Allowed clock skew in seconds | +| ↳ `customSigningKey` | boolean | Whether a custom signing key is configured | +| ↳ `signingAlgorithm` | string | Signing algorithm used | +| ↳ `createdAt` | number | Creation timestamp | +| ↳ `updatedAt` | number | Last update timestamp | +| `totalCount` | number | Total number of JWT templates | +| `success` | boolean | Operation success status | + +### `clerk_get_jwt_template` + +Retrieve a single custom JWT template by ID from Clerk + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `secretKey` | string | Yes | The Clerk Secret Key for API authentication | +| `templateId` | string | Yes | ID of the JWT template to retrieve | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | JWT template ID | +| `name` | string | JWT template name | +| `claims` | json | Custom claims defined on the template | +| `lifetime` | number | Token lifetime in seconds | +| `allowedClockSkew` | number | Allowed clock skew in seconds | +| `customSigningKey` | boolean | Whether a custom signing key is configured | +| `signingAlgorithm` | string | Signing algorithm used | +| `createdAt` | number | Creation timestamp | +| `updatedAt` | number | Last update timestamp | +| `success` | boolean | Operation success status | + +### `clerk_create_actor_token` + +Create an actor token to impersonate a user (God Mode / act-as-user), e.g. for support tooling + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `secretKey` | string | Yes | The Clerk Secret Key for API authentication | +| `userId` | string | Yes | ID of the user to impersonate | +| `actor` | json | Yes | Actor JSON object identifying who is impersonating, must include a "sub" field, e.g. \{"sub": "user_support_agent_id"\} | +| `expiresInSeconds` | number | No | Seconds until the token expires \(default 3600\) | +| `sessionMaxDurationInSeconds` | number | No | Max duration in seconds for sessions created with this token \(default 1800\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | Actor token ID | +| `status` | string | Actor token status | +| `userId` | string | ID of the impersonated user | +| `actor` | json | Actor object identifying who is impersonating | +| `token` | string | Signed actor token \(JWT\) | +| `url` | string | Sign-in URL for the actor token | +| `createdAt` | number | Creation timestamp | +| `updatedAt` | number | Last update timestamp | +| `success` | boolean | Operation success status | + +### `clerk_revoke_actor_token` + +Revoke an actor token before it is used or expires + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `secretKey` | string | Yes | The Clerk Secret Key for API authentication | +| `actorTokenId` | string | Yes | ID of the actor token to revoke | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | Actor token ID | +| `status` | string | Actor token status \(should be revoked\) | +| `userId` | string | ID of the impersonated user | +| `actor` | json | Actor object identifying who is impersonating | +| `token` | string | Signed actor token \(JWT\) | +| `url` | string | Sign-in URL for the actor token | +| `createdAt` | number | Creation timestamp | +| `updatedAt` | number | Last update timestamp | +| `success` | boolean | Operation success status | + + + +## Triggers + +A **Trigger** is a block that starts a workflow when an event happens in this service. + +### Clerk Organization Created + +Trigger workflow when a Clerk organization is created + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `signingSecret` | string | Yes | Copy this from your Clerk webhook endpoint to verify event signatures. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `type` | string | Event type \(e.g., user.created, session.created\) | | `object` | string | Always "event" | | `timestamp` | number | Timestamp in milliseconds when the event occurred | | `instance_id` | string | Identifier of your Clerk instance | @@ -473,6 +1112,31 @@ Trigger workflow when a Clerk organization is created | `createdAt` | number | Organization creation timestamp \(data.created_at\) | +--- + +### Clerk Organization Deleted + +Trigger workflow when a Clerk organization is deleted + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `signingSecret` | string | Yes | Copy this from your Clerk webhook endpoint to verify event signatures. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `type` | string | Event type \(e.g., user.created, session.created\) | +| `object` | string | Always "event" | +| `timestamp` | number | Timestamp in milliseconds when the event occurred | +| `instance_id` | string | Identifier of your Clerk instance | +| `data` | json | Raw event `data` object \(shape varies by event type\) | +| `organizationId` | string | Deleted Clerk organization ID \(data.id\) | +| `deleted` | boolean | Whether the organization was deleted \(data.deleted\) | + + --- ### Clerk Organization Membership Created @@ -501,6 +1165,89 @@ Trigger workflow when a Clerk organization membership is created | `createdAt` | number | Membership creation timestamp \(data.created_at\) | +--- + +### Clerk Organization Membership Deleted + +Trigger workflow when a Clerk organization membership is deleted + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `signingSecret` | string | Yes | Copy this from your Clerk webhook endpoint to verify event signatures. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `type` | string | Event type \(e.g., user.created, session.created\) | +| `object` | string | Always "event" | +| `timestamp` | number | Timestamp in milliseconds when the event occurred | +| `instance_id` | string | Identifier of your Clerk instance | +| `data` | json | Raw event `data` object \(shape varies by event type\) | +| `membershipId` | string | Deleted membership ID \(data.id\) | +| `deleted` | boolean | Whether the membership was deleted \(data.deleted\) | + + +--- + +### Clerk Organization Membership Updated + +Trigger workflow when a Clerk organization membership is updated + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `signingSecret` | string | Yes | Copy this from your Clerk webhook endpoint to verify event signatures. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `type` | string | Event type \(e.g., user.created, session.created\) | +| `object` | string | Always "event" | +| `timestamp` | number | Timestamp in milliseconds when the event occurred | +| `instance_id` | string | Identifier of your Clerk instance | +| `data` | json | Raw event `data` object \(shape varies by event type\) | +| `membershipId` | string | Membership ID \(data.id\) | +| `role` | string | Membership role, e.g. org:admin \(data.role\) | +| `organizationId` | string | Organization ID \(data.organization.id\) | +| `userId` | string | User ID of the member \(data.public_user_data.user_id\) | +| `createdAt` | number | Membership creation timestamp \(data.created_at\) | + + +--- + +### Clerk Organization Updated + +Trigger workflow when a Clerk organization is updated + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `signingSecret` | string | Yes | Copy this from your Clerk webhook endpoint to verify event signatures. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `type` | string | Event type \(e.g., user.created, session.created\) | +| `object` | string | Always "event" | +| `timestamp` | number | Timestamp in milliseconds when the event occurred | +| `instance_id` | string | Identifier of your Clerk instance | +| `data` | json | Raw event `data` object \(shape varies by event type\) | +| `organizationId` | string | Clerk organization ID \(data.id\) | +| `name` | string | Organization name \(data.name\) | +| `slug` | string | Organization slug \(data.slug\) | +| `createdBy` | string | User ID of the creator \(data.created_by\) | +| `membersCount` | number | Number of members \(data.members_count\) | +| `maxAllowedMemberships` | number | Maximum allowed memberships \(data.max_allowed_memberships\) | +| `createdAt` | number | Organization creation timestamp \(data.created_at\) | + + --- ### Clerk Session Created @@ -529,6 +1276,90 @@ Trigger workflow when a Clerk session is created | `createdAt` | number | Session creation timestamp \(data.created_at\) | +--- + +### Clerk Session Ended + +Trigger workflow when a Clerk session ends + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `signingSecret` | string | Yes | Copy this from your Clerk webhook endpoint to verify event signatures. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `type` | string | Event type \(e.g., user.created, session.created\) | +| `object` | string | Always "event" | +| `timestamp` | number | Timestamp in milliseconds when the event occurred | +| `instance_id` | string | Identifier of your Clerk instance | +| `data` | json | Raw event `data` object \(shape varies by event type\) | +| `sessionId` | string | Clerk session ID \(data.id\) | +| `userId` | string | User the session belongs to \(data.user_id\) | +| `clientId` | string | Client ID for the session \(data.client_id\) | +| `status` | string | Session status \(data.status\) | +| `createdAt` | number | Session creation timestamp \(data.created_at\) | + + +--- + +### Clerk Session Removed + +Trigger workflow when a Clerk session is removed + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `signingSecret` | string | Yes | Copy this from your Clerk webhook endpoint to verify event signatures. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `type` | string | Event type \(e.g., user.created, session.created\) | +| `object` | string | Always "event" | +| `timestamp` | number | Timestamp in milliseconds when the event occurred | +| `instance_id` | string | Identifier of your Clerk instance | +| `data` | json | Raw event `data` object \(shape varies by event type\) | +| `sessionId` | string | Clerk session ID \(data.id\) | +| `userId` | string | User the session belongs to \(data.user_id\) | +| `clientId` | string | Client ID for the session \(data.client_id\) | +| `status` | string | Session status \(data.status\) | +| `createdAt` | number | Session creation timestamp \(data.created_at\) | + + +--- + +### Clerk Session Revoked + +Trigger workflow when a Clerk session is revoked + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `signingSecret` | string | Yes | Copy this from your Clerk webhook endpoint to verify event signatures. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `type` | string | Event type \(e.g., user.created, session.created\) | +| `object` | string | Always "event" | +| `timestamp` | number | Timestamp in milliseconds when the event occurred | +| `instance_id` | string | Identifier of your Clerk instance | +| `data` | json | Raw event `data` object \(shape varies by event type\) | +| `sessionId` | string | Clerk session ID \(data.id\) | +| `userId` | string | User the session belongs to \(data.user_id\) | +| `clientId` | string | Client ID for the session \(data.client_id\) | +| `status` | string | Session status \(data.status\) | +| `createdAt` | number | Session creation timestamp \(data.created_at\) | + + --- ### Clerk User Created diff --git a/apps/docs/content/docs/en/integrations/gong.mdx b/apps/docs/content/docs/en/integrations/gong.mdx index 55b2007411b..6d111ddc52c 100644 --- a/apps/docs/content/docs/en/integrations/gong.mdx +++ b/apps/docs/content/docs/en/integrations/gong.mdx @@ -769,6 +769,71 @@ List Gong Engage flows (sales engagement sequences). | `currentPageNumber` | number | Current page number | | `cursor` | string | Pagination cursor for retrieving the next page of records | +### `gong_assign_flow_prospects` + +Assign up to 200 CRM prospects (contacts or leads) to a Gong Engage flow. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `accessKey` | string | Yes | Gong API Access Key | +| `accessKeySecret` | string | Yes | Gong API Access Key Secret | +| `flowId` | string | Yes | The Gong Engage flow ID to assign the prospects to | +| `crmProspectsIds` | string | Yes | Comma-separated list of CRM prospect IDs \(contacts or leads\) to assign | +| `flowInstanceOwnerEmail` | string | Yes | Email of the Gong user who owns the flow instance and its to-dos | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `requestId` | string | A Gong request reference ID for troubleshooting purposes | +| `prospectsAssigned` | array | Prospects successfully assigned to the flow | +| ↳ `flowId` | string | The flow ID | +| ↳ `flowName` | string | The flow name | +| ↳ `crmProspectId` | string | The CRM prospect ID | +| ↳ `flowInstanceId` | string | The created flow instance ID | +| ↳ `flowInstanceOwnerEmail` | string | Email of the flow instance owner | +| ↳ `flowInstanceOwnerFullName` | string | Full name of the flow instance owner | +| ↳ `flowInstanceCreateDate` | string | Creation time of the flow instance in ISO-8601 format | +| ↳ `flowInstanceStatus` | string | Status of the flow instance | +| ↳ `workspaceId` | string | Workspace ID | +| ↳ `exclusive` | boolean | Whether this prospect can be added to other flows | +| `prospectsNotAssigned` | array | Prospects that failed to be assigned to the flow | +| ↳ `flowId` | string | The flow ID | +| ↳ `crmProspectId` | string | The CRM prospect ID | +| ↳ `errorCode` | string | Failure reason: InvalidArgument, InvalidState, or UnexpectedError | +| ↳ `errorMessage` | string | Human-readable failure message | + +### `gong_get_prospect_flows` + +Get the Gong Engage flows currently assigned to the given CRM prospects. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `accessKey` | string | Yes | Gong API Access Key | +| `accessKeySecret` | string | Yes | Gong API Access Key Secret | +| `crmProspectsIds` | string | Yes | Comma-separated list of CRM prospect IDs \(contacts or leads\) to look up | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `requestId` | string | A Gong request reference ID for troubleshooting purposes | +| `prospectsAssigned` | array | Flows currently assigned to the requested prospects | +| ↳ `flowId` | string | The flow ID | +| ↳ `flowName` | string | The flow name | +| ↳ `crmProspectId` | string | The CRM prospect ID | +| ↳ `flowInstanceId` | string | The flow instance ID | +| ↳ `flowInstanceOwnerEmail` | string | Email of the flow instance owner | +| ↳ `flowInstanceOwnerFullName` | string | Full name of the flow instance owner | +| ↳ `flowInstanceCreateDate` | string | Creation time of the flow instance in ISO-8601 format | +| ↳ `flowInstanceStatus` | string | Status of the flow instance | +| ↳ `workspaceId` | string | Workspace ID | +| ↳ `exclusive` | boolean | Whether this prospect can be added to other flows | + ### `gong_get_coaching` Retrieve coaching metrics for a manager from Gong. @@ -904,6 +969,42 @@ Find all references to a phone number in Gong (calls, email messages, meetings, | ↳ `name` | string | Field name | | ↳ `value` | json | Field value | +### `gong_purge_email_address` + +Erase all Gong data (calls, email messages, leads, contacts) referencing an email address. Asynchronous and irreversible. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `accessKey` | string | Yes | Gong API Access Key | +| `accessKeySecret` | string | Yes | Gong API Access Key Secret | +| `emailAddress` | string | Yes | Email address whose associated data should be permanently erased from Gong | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `requestId` | string | A Gong request reference ID for troubleshooting purposes | + +### `gong_purge_phone_number` + +Erase all Gong data (calls, leads, contacts) referencing a phone number. Asynchronous and irreversible. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `accessKey` | string | Yes | Gong API Access Key | +| `accessKeySecret` | string | Yes | Gong API Access Key Secret | +| `phoneNumber` | string | Yes | Phone number whose associated data should be permanently erased from Gong. Must include a leading "+" and country code \(e.g., +14255552671\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `requestId` | string | A Gong request reference ID for troubleshooting purposes | + ## Triggers diff --git a/apps/docs/content/docs/en/integrations/google_appsheet.mdx b/apps/docs/content/docs/en/integrations/google_appsheet.mdx new file mode 100644 index 00000000000..3b34ac1095b --- /dev/null +++ b/apps/docs/content/docs/en/integrations/google_appsheet.mdx @@ -0,0 +1,135 @@ +--- +title: Google AppSheet +description: Read, add, edit, and delete rows in a Google AppSheet table +--- + +import { BlockInfoCard } from "@/components/ui/block-info-card" + + + +{/* MANUAL-CONTENT-START:intro */} +[Google AppSheet](https://about.appsheet.com/) is Google's no-code app development platform that lets teams turn spreadsheets and databases into mobile and web apps, backed by data sources like Google Sheets, Excel, and cloud databases. + +With the Google AppSheet integration in Sim, you can: + +- **Find rows**: Query a table with an optional Selector expression (`Filter`, `OrderBy`, `Top`, and more) to narrow, sort, and limit the rows returned +- **Add rows**: Insert new rows into a table, letting AppSheet generate the key column automatically or providing it explicitly +- **Edit rows**: Update existing rows by key column, changing only the fields that need to change +- **Delete rows**: Remove rows from a table by key column + +In Sim, the Google AppSheet integration enables your agents to read and write AppSheet app data as part of automated workflows — syncing order intake, routing leads, escalating tickets, or keeping a table in sync with another system, all without touching the AppSheet editor. + +### Getting Your Application Access Key + +Google AppSheet authenticates with a static Application Access Key rather than OAuth: + +1. Open your app in the [AppSheet editor](https://www.appsheet.com/) +2. Go to **Settings > Integrations** +3. Enable **IN: from cloud services to your app** +4. Under **Application Access Keys**, create a key (or use an existing one) and copy it +5. Use the Application Access Key, along with your App ID and table name, in the Sim block configuration + +The AppSheet API requires an Enterprise plan. +{/* MANUAL-CONTENT-END */} + + +## Usage Instructions + +Integrate Google AppSheet into your workflow. Find, add, edit, and delete rows in an AppSheet table using the AppSheet API. Requires an AppSheet Enterprise plan with the API enabled and an Application Access Key. + + + +## Actions + +### `google_appsheet_find_rows` + +Read rows from an AppSheet table. Omit the selector to return every row, or provide a Selector expression (Filter/Select/OrderBy/Top) to narrow and shape the results. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | AppSheet Application Access Key | +| `appId` | string | Yes | AppSheet app ID \(found in App > Settings > Integrations > IN\) | +| `tableName` | string | Yes | Name of the table to read from | +| `region` | string | No | AppSheet region subdomain: "www" \(global, default\), "eu", or "asia-southeast" | +| `selector` | string | No | Optional AppSheet expression to filter/sort/limit rows, e.g. Filter\(TableName, \[Age\] >= 21\) or Top\(OrderBy\(Filter\(TableName, true\), \[LastName\], true\), 10\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `rows` | array | Matching rows returned by AppSheet | +| `metadata` | json | Operation metadata | +| ↳ `rowCount` | number | Number of rows returned | + +### `google_appsheet_add_rows` + +Add new rows to an AppSheet table. The key column value must be provided explicitly, or omitted when its Initial value expression generates it automatically (e.g. UNIQUEID()). + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | AppSheet Application Access Key | +| `appId` | string | Yes | AppSheet app ID \(found in App > Settings > Integrations > IN\) | +| `tableName` | string | Yes | Name of the table to add rows to | +| `region` | string | No | AppSheet region subdomain: "www" \(global, default\), "eu", or "asia-southeast" | +| `rows` | json | Yes | Array of row objects to add, each a column-name/value map, e.g. \[\{ "FirstName": "Jan", "LastName": "Jones" \}\] | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `rows` | array | Rows added by AppSheet, including any generated key values | +| `metadata` | json | Operation metadata | +| ↳ `rowCount` | number | Number of rows added | + +### `google_appsheet_edit_rows` + +Update existing rows in an AppSheet table. Each row must explicitly include the key column name and value, plus any columns to change. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | AppSheet Application Access Key | +| `appId` | string | Yes | AppSheet app ID \(found in App > Settings > Integrations > IN\) | +| `tableName` | string | Yes | Name of the table to update rows in | +| `region` | string | No | AppSheet region subdomain: "www" \(global, default\), "eu", or "asia-southeast" | +| `rows` | json | Yes | Array of row objects to update, each including the key column and the columns to change, e.g. \[\{ "RowID": "123", "Status": "Done" \}\] | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `rows` | array | Rows updated by AppSheet | +| `metadata` | json | Operation metadata | +| ↳ `rowCount` | number | Number of rows updated | + +### `google_appsheet_delete_rows` + +Delete rows from an AppSheet table. Each row only needs to include the key column name and value. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | AppSheet Application Access Key | +| `appId` | string | Yes | AppSheet app ID \(found in App > Settings > Integrations > IN\) | +| `tableName` | string | Yes | Name of the table to delete rows from | +| `region` | string | No | AppSheet region subdomain: "www" \(global, default\), "eu", or "asia-southeast" | +| `rows` | json | Yes | Array of row objects identifying rows to delete by key column, e.g. \[\{ "RowID": "123" \}\] | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `rows` | array | Rows deleted by AppSheet | +| `metadata` | json | Operation metadata | +| ↳ `rowCount` | number | Number of rows deleted | + + diff --git a/apps/docs/content/docs/en/integrations/hex.mdx b/apps/docs/content/docs/en/integrations/hex.mdx index e728ab635ff..392e9935f23 100644 --- a/apps/docs/content/docs/en/integrations/hex.mdx +++ b/apps/docs/content/docs/en/integrations/hex.mdx @@ -34,7 +34,7 @@ Whether you’re empowering analysts, automating reporting, or embedding actiona ## Usage Instructions -Integrate Hex into your workflow. Run projects, check run status, manage collections and groups, list users, and view data connections. Requires a Hex API token. +Integrate Hex into your workflow. Run projects, check run status, manage collections and groups (including membership and deactivating users), list users, and view data connections. Requires a Hex API token. @@ -83,6 +83,62 @@ Create a new collection in the Hex workspace to organize projects. | ↳ `email` | string | Creator email | | ↳ `id` | string | Creator UUID | +### `hex_create_group` + +Create a new group in the Hex workspace, optionally with initial members. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Hex API token \(Personal or Workspace\) | +| `name` | string | Yes | Name for the new group | +| `memberUserIds` | json | No | JSON array of user UUIDs to add as initial group members \(e.g., \["uuid1", "uuid2"\]\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | Newly created group UUID | +| `name` | string | Group name | +| `createdAt` | string | Creation timestamp | + +### `hex_deactivate_user` + +Deactivate a user in the Hex workspace. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Hex API token \(Personal or Workspace\) | +| `userId` | string | Yes | The UUID of the user to deactivate | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Whether the user was successfully deactivated | +| `userId` | string | User UUID that was deactivated | + +### `hex_delete_group` + +Delete a group from the Hex workspace. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Hex API token \(Personal or Workspace\) | +| `groupId` | string | Yes | The UUID of the group to delete | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Whether the group was successfully deleted | +| `groupId` | string | Group UUID that was deleted | + ### `hex_get_collection` Retrieve details for a specific Hex collection by its ID. @@ -194,6 +250,7 @@ Retrieve API-triggered runs for a Hex project with optional filtering by status | `limit` | number | No | Maximum number of runs to return \(1-100, default: 25\) | | `offset` | number | No | Offset for paginated results \(default: 0\) | | `statusFilter` | string | No | Filter by run status: PENDING, RUNNING, ERRORED, COMPLETED, KILLED, UNABLE_TO_ALLOCATE_KERNEL | +| `runTriggerFilter` | string | No | Filter by how the run was triggered: ALL, API, SCHEDULED, or APP_REFRESH | #### Output @@ -211,6 +268,8 @@ Retrieve API-triggered runs for a Hex project with optional filtering by status | ↳ `projectVersion` | number | Project version number | | `total` | number | Total number of runs returned | | `traceId` | string | Top-level trace ID | +| `nextPage` | string | Cursor for the next page of runs | +| `previousPage` | string | Cursor for the previous page of runs | ### `hex_get_queried_tables` @@ -271,6 +330,8 @@ List all collections in the Hex workspace. | `apiKey` | string | Yes | Hex API token \(Personal or Workspace\) | | `limit` | number | No | Maximum number of collections to return \(1-500, default: 25\) | | `sortBy` | string | No | Sort by field: NAME | +| `after` | string | No | Cursor to fetch the page of results after this value | +| `before` | string | No | Cursor to fetch the page of results before this value | #### Output @@ -284,6 +345,8 @@ List all collections in the Hex workspace. | ↳ `email` | string | Creator email | | ↳ `id` | string | Creator UUID | | `total` | number | Total number of collections returned | +| `after` | string | Cursor for the next page of results | +| `before` | string | Cursor for the previous page of results | ### `hex_list_data_connections` @@ -297,6 +360,8 @@ List all data connections in the Hex workspace (e.g., Snowflake, PostgreSQL, Big | `limit` | number | No | Maximum number of connections to return \(1-500, default: 25\) | | `sortBy` | string | No | Sort by field: CREATED_AT or NAME | | `sortDirection` | string | No | Sort direction: ASC or DESC | +| `after` | string | No | Cursor to fetch the page of results after this value | +| `before` | string | No | Cursor to fetch the page of results before this value | #### Output @@ -311,6 +376,8 @@ List all data connections in the Hex workspace (e.g., Snowflake, PostgreSQL, Big | ↳ `includeMagic` | boolean | Whether Magic AI features are enabled | | ↳ `allowWritebackCells` | boolean | Whether writeback cells are allowed | | `total` | number | Total number of connections returned | +| `after` | string | Cursor for the next page of results | +| `before` | string | Cursor for the previous page of results | ### `hex_list_groups` @@ -324,6 +391,8 @@ List all groups in the Hex workspace with optional sorting. | `limit` | number | No | Maximum number of groups to return \(1-500, default: 25\) | | `sortBy` | string | No | Sort by field: CREATED_AT or NAME | | `sortDirection` | string | No | Sort direction: ASC or DESC | +| `after` | string | No | Cursor to fetch the page of results after this value | +| `before` | string | No | Cursor to fetch the page of results before this value | #### Output @@ -334,6 +403,8 @@ List all groups in the Hex workspace with optional sorting. | ↳ `name` | string | Group name | | ↳ `createdAt` | string | Creation timestamp | | `total` | number | Total number of groups returned | +| `after` | string | Cursor for the next page of results | +| `before` | string | Cursor for the previous page of results | ### `hex_list_projects` @@ -347,6 +418,16 @@ List all projects in your Hex workspace with optional filtering by status. | `limit` | number | No | Maximum number of projects to return \(1-100\) | | `includeArchived` | boolean | No | Include archived projects in results | | `statusFilter` | string | No | Filter by status: PUBLISHED, DRAFT, or ALL | +| `includeComponents` | boolean | No | Include components in results | +| `includeTrashed` | boolean | No | Include trashed projects in results | +| `creatorEmail` | string | No | Filter by creator email | +| `ownerEmail` | string | No | Filter by owner email | +| `collectionId` | string | No | Filter by collection UUID | +| `categories` | json | No | JSON array of category names to filter by \(e.g., \["Marketing", "Finance"\]\) | +| `sortBy` | string | No | Sort by field: CREATED_AT, LAST_EDITED_AT, or LAST_PUBLISHED_AT | +| `sortDirection` | string | No | Sort direction: ASC or DESC | +| `after` | string | No | Cursor to fetch the page of results after this value | +| `before` | string | No | Cursor to fetch the page of results before this value | #### Output @@ -368,6 +449,8 @@ List all projects in your Hex workspace with optional filtering by status. | ↳ `createdAt` | string | Creation timestamp | | ↳ `archivedAt` | string | Archived timestamp | | `total` | number | Total number of projects returned | +| `after` | string | Cursor for the next page of results | +| `before` | string | Cursor for the previous page of results | ### `hex_list_users` @@ -382,6 +465,9 @@ List all users in the Hex workspace with optional filtering and sorting. | `sortBy` | string | No | Sort by field: NAME or EMAIL | | `sortDirection` | string | No | Sort direction: ASC or DESC | | `groupId` | string | No | Filter users by group UUID | +| `userIds` | string | No | Comma-separated list of user UUIDs to filter by | +| `after` | string | No | Cursor to fetch the page of results after this value | +| `before` | string | No | Cursor to fetch the page of results before this value | #### Output @@ -392,7 +478,10 @@ List all users in the Hex workspace with optional filtering and sorting. | ↳ `name` | string | User name | | ↳ `email` | string | User email | | ↳ `role` | string | User role \(ADMIN, MANAGER, EDITOR, EXPLORER, MEMBER, GUEST, EMBEDDED_USER, ANONYMOUS\) | +| ↳ `lastLoginDate` | string | Last login timestamp | | `total` | number | Total number of users returned | +| `after` | string | Cursor for the next page of results | +| `before` | string | Cursor for the previous page of results | ### `hex_run_project` @@ -409,6 +498,8 @@ Execute a published Hex project. Optionally pass input parameters and control ca | `updateCache` | boolean | No | \(Deprecated\) If true, update the cached results after execution | | `updatePublishedResults` | boolean | No | If true, update the published app results after execution | | `useCachedSqlResults` | boolean | No | If true, use cached SQL results instead of re-running queries | +| `viewId` | string | No | Optional SavedView ID to use for the project run | +| `notifications` | json | No | JSON array of notification details to deliver once the run completes \(e.g., \[\{"type": "FAILURE", "slackChannelIds": \["C0123456789"\], "userIds": \[\], "groupIds": \[\], "includeSuccessScreenshot": false\}\]\). type is ALL, SUCCESS, or FAILURE. | #### Output @@ -421,6 +512,52 @@ Execute a published Hex project. Optionally pass input parameters and control ca | `traceId` | string | Trace ID for debugging | | `projectVersion` | number | Project version number | +### `hex_update_collection` + +Update the name or description of an existing Hex collection. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Hex API token \(Personal or Workspace\) | +| `collectionId` | string | Yes | The UUID of the collection to update | +| `name` | string | No | New name for the collection | +| `description` | string | No | New description for the collection | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | Collection UUID | +| `name` | string | Collection name | +| `description` | string | Collection description | +| `creator` | object | Collection creator | +| ↳ `email` | string | Creator email | +| ↳ `id` | string | Creator UUID | + +### `hex_update_group` + +Rename a Hex group or add/remove members from it. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Hex API token \(Personal or Workspace\) | +| `groupId` | string | Yes | The UUID of the group to update | +| `name` | string | No | New name for the group | +| `addUserIds` | json | No | JSON array of user UUIDs to add to the group \(e.g., \["uuid1", "uuid2"\]\) | +| `removeUserIds` | json | No | JSON array of user UUIDs to remove from the group \(e.g., \["uuid1", "uuid2"\]\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | Group UUID | +| `name` | string | Group name | +| `createdAt` | string | Creation timestamp | + ### `hex_update_project` Update a Hex project status label (e.g., endorsement or custom workspace statuses). diff --git a/apps/docs/content/docs/en/integrations/langsmith.mdx b/apps/docs/content/docs/en/integrations/langsmith.mdx index caf4577f8a8..7e83f3d1eb4 100644 --- a/apps/docs/content/docs/en/integrations/langsmith.mdx +++ b/apps/docs/content/docs/en/integrations/langsmith.mdx @@ -91,4 +91,81 @@ Forward multiple runs to LangSmith in a single batch. | `message` | string | Response message from LangSmith | | `messages` | array | Per-run response messages, when provided | +### `langsmith_update_run` + +Patch an existing LangSmith run with outputs, status, or timing once it completes. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | LangSmith API key | +| `runId` | string | Yes | ID of the run to update | +| `name` | string | No | Corrected run name | + +#### Output + +This tool does not produce any outputs. + +### `langsmith_get_run` + +Retrieve a single LangSmith run by ID. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | LangSmith API key | +| `runId` | string | Yes | ID of the run to retrieve | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | Run ID | +| `runId` | string | Run ID \(alias of id, for consistency with other operations\) | +| `name` | string | Run name | +| `runType` | string | Run type \(tool, chain, llm, retriever, embedding, prompt, parser\) | +| `status` | string | Run status | +| `startTime` | string | Run start time \(ISO\) | +| `endTime` | string | Run end time \(ISO\) | +| `inputs` | json | Run inputs payload | +| `outputs` | json | Run outputs payload | +| `error` | string | Error details, if the run failed | +| `tags` | array | Tags attached to the run | +| `sessionId` | string | Project \(session\) ID the run belongs to | +| `traceId` | string | Trace ID | +| `parentRunId` | string | Parent run ID | +| `totalTokens` | number | Total tokens consumed by the run | +| `totalCost` | string | Total cost of the run | + +### `langsmith_create_feedback` + +Attach a score, correction, or comment to a LangSmith run. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | LangSmith API key | +| `runId` | string | Yes | ID of the run to attach feedback to | +| `key` | string | Yes | Feedback metric name \(e.g. "correctness", "user_score"\) | +| `score` | number | No | Numeric score for the feedback metric | +| `value` | string | No | Categorical value for the feedback metric | +| `comment` | string | No | Free-text comment explaining the feedback | +| `correction` | json | No | Corrected output for the run | +| `feedbackSourceType` | string | No | Origin of the feedback \(api, app, or model\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | Feedback ID | +| `key` | string | Feedback metric name | +| `runId` | string | ID of the run the feedback was attached to | +| `score` | number | Score recorded for the feedback | +| `value` | string | Categorical value recorded for the feedback | +| `comment` | string | Comment recorded for the feedback | +| `createdAt` | string | When the feedback was created \(ISO\) | + diff --git a/apps/docs/content/docs/en/integrations/loops.mdx b/apps/docs/content/docs/en/integrations/loops.mdx index d46ec807111..1a37c87b872 100644 --- a/apps/docs/content/docs/en/integrations/loops.mdx +++ b/apps/docs/content/docs/en/integrations/loops.mdx @@ -205,7 +205,7 @@ Retrieve all mailing lists from your Loops account. Returns each list with its I ### `loops_list_transactional_emails` -Retrieve a list of published transactional email templates from your Loops account. Returns each template with its ID, name, last updated timestamp, and data variables. +Retrieve a list of published transactional email templates from your Loops account. Returns each template with its ID, name, created/updated timestamps, and data variables. #### Input @@ -222,7 +222,9 @@ Retrieve a list of published transactional email templates from your Loops accou | `transactionalEmails` | array | Array of published transactional email templates | | ↳ `id` | string | The transactional email template ID | | ↳ `name` | string | The template name | -| ↳ `lastUpdated` | string | Last updated timestamp | +| ↳ `createdAt` | string | Creation timestamp \(ISO 8601\) | +| ↳ `updatedAt` | string | Last updated timestamp \(ISO 8601\) | +| ↳ `lastUpdated` | string | Deprecated alias of updatedAt, kept for backwards compatibility | | ↳ `dataVariables` | array | Template data variable names | | `pagination` | object | Pagination information | | ↳ `totalResults` | number | Total number of results | @@ -270,6 +272,74 @@ Retrieve a list of contact properties from your Loops account. Returns each prop | ↳ `label` | string | The property display label | | ↳ `type` | string | The property data type \(string, number, boolean, date\) | +### `loops_check_contact_suppression` + +Check whether a Loops contact is on the suppression list (bounced, complained, or unsubscribed) by email address or userId. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Loops API key for authentication | +| `email` | string | No | The contact email address to check \(at least one of email or userId is required\) | +| `userId` | string | No | The contact userId to check \(at least one of email or userId is required\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `contactId` | string | The Loops-assigned contact ID | +| `email` | string | The contact email address | +| `userId` | string | The contact userId | +| `isSuppressed` | boolean | Whether the contact is on the suppression list | +| `removalQuotaLimit` | number | Total suppression-removal quota for the team | +| `removalQuotaRemaining` | number | Remaining suppression-removal quota for the team | + +### `loops_remove_contact_suppression` + +Remove a Loops contact from the suppression list by email address or userId, allowing them to receive emails again. Subject to a team removal quota. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Loops API key for authentication | +| `email` | string | No | The contact email address to remove from suppression \(at least one of email or userId is required\) | +| `userId` | string | No | The contact userId to remove from suppression \(at least one of email or userId is required\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Whether the contact was removed from suppression successfully | +| `message` | string | Status message from the API | +| `removalQuotaLimit` | number | Total suppression-removal quota for the team | +| `removalQuotaRemaining` | number | Remaining suppression-removal quota for the team | + +### `loops_get_transactional_email` + +Retrieve a single transactional email template from your Loops account by its ID, including its data variables and draft/published message IDs. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Loops API key for authentication | +| `transactionalId` | string | Yes | The ID of the transactional email template to retrieve | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | The transactional email template ID | +| `name` | string | The template name | +| `draftEmailMessageId` | string | ID of the draft email message, if any | +| `publishedEmailMessageId` | string | ID of the published email message, if any | +| `transactionalGroupId` | string | ID of the transactional group this template belongs to, if any | +| `createdAt` | string | Creation timestamp \(ISO 8601\) | +| `updatedAt` | string | Last updated timestamp \(ISO 8601\) | +| `dataVariables` | array | Template data variable names | + ## Triggers diff --git a/apps/docs/content/docs/en/integrations/meta.json b/apps/docs/content/docs/en/integrations/meta.json index f9f8e3a7c26..ae36529830d 100644 --- a/apps/docs/content/docs/en/integrations/meta.json +++ b/apps/docs/content/docs/en/integrations/meta.json @@ -77,6 +77,7 @@ "gong", "google-service-account", "google_ads", + "google_appsheet", "google_bigquery", "google_books", "google_calendar", diff --git a/apps/docs/content/docs/en/integrations/onepassword.mdx b/apps/docs/content/docs/en/integrations/onepassword.mdx index 33f1adea356..3cb2659076a 100644 --- a/apps/docs/content/docs/en/integrations/onepassword.mdx +++ b/apps/docs/content/docs/en/integrations/onepassword.mdx @@ -29,7 +29,7 @@ By connecting Sim with 1Password, you empower your agents to securely manage sec ## Usage Instructions -Access and manage secrets stored in 1Password vaults using the Connect API or Service Account SDK. List vaults, retrieve items with their fields and secrets, create new items, update existing ones, delete items, and resolve secret references. +Access and manage secrets stored in 1Password vaults using the Connect API or Service Account SDK. List vaults, retrieve items with their fields and secrets, download attached files, create new items, update existing ones, delete items, and resolve secret references. @@ -59,7 +59,7 @@ List all vaults accessible by the Connect token or Service Account | ↳ `description` | string | Vault description | | ↳ `attributeVersion` | number | Vault attribute version | | ↳ `contentVersion` | number | Vault content version | -| ↳ `type` | string | Vault type \(USER_CREATED, PERSONAL, EVERYONE, TRANSFER\) | +| ↳ `type` | string | Vault type \(USER_CREATED, PERSONAL, or EVERYONE\) | | ↳ `createdAt` | string | Creation timestamp | | ↳ `updatedAt` | string | Last update timestamp | @@ -87,7 +87,7 @@ Get details of a specific vault by ID | `attributeVersion` | number | Vault attribute version | | `contentVersion` | number | Vault content version | | `items` | number | Number of items in the vault | -| `type` | string | Vault type \(USER_CREATED, PERSONAL, EVERYONE, TRANSFER\) | +| `type` | string | Vault type \(USER_CREATED, PERSONAL, or EVERYONE\) | | `createdAt` | string | Creation timestamp | | `updatedAt` | string | Last update timestamp | @@ -123,7 +123,7 @@ List items in a vault. Returns summaries without field values. | ↳ `favorite` | boolean | Whether the item is favorited | | ↳ `tags` | array | Item tags | | ↳ `version` | number | Item version number | -| ↳ `state` | string | Item state \(ARCHIVED or DELETED\) | +| ↳ `state` | string | Item state \(ARCHIVED, or absent/null when active\) | | ↳ `createdAt` | string | Creation timestamp | | ↳ `updatedAt` | string | Last update timestamp | | ↳ `lastEditedBy` | string | ID of the last editor | @@ -147,7 +147,53 @@ Get full details of an item including all fields and secrets | Parameter | Type | Description | | --------- | ---- | ----------- | -| `response` | json | Operation response data | +| `response` | json | Deprecated — kept for backward compatibility with workflows saved before per-operation outputs were added below. Never populated; use the operation-specific outputs instead. | +| `vaults` | json | List of accessible vaults \[\{id, name, description, items, type, createdAt, updatedAt\}\] | +| `id` | string | Vault or item ID | +| `name` | string | Vault name | +| `description` | string | Vault description | +| `items` | json | Number of items in the vault \(Get Vault\) or item summaries \[\{id, title, category, tags, favorite, version, updatedAt\}\] \(List Items\) | +| `type` | string | Vault type \(USER_CREATED, PERSONAL, or EVERYONE\) | +| `title` | string | Item title | +| `category` | string | Item category \(e.g., LOGIN, API_CREDENTIAL, SECURE_NOTE\) | +| `vault` | json | Vault reference the item belongs to \{id\} | +| `fields` | json | Item fields including secrets \[\{id, label, type, purpose, value\}\] | +| `sections` | json | Item sections \[\{id, label\}\] | +| `files` | json | Files attached to the item \[\{id, name, size, section\}\] — fetch content with Get Item File | +| `tags` | json | Item tags | +| `urls` | json | URLs associated with the item \[\{href, label, primary\}\] | +| `favorite` | boolean | Whether the item is favorited | +| `version` | number | Item version number | +| `state` | string | Item state \(ARCHIVED, or absent/null when active\) | +| `lastEditedBy` | string | ID of the last editor | +| `createdAt` | string | Creation timestamp | +| `updatedAt` | string | Last update timestamp | +| `success` | boolean | Whether the item was successfully deleted | +| `value` | string | The resolved secret value | +| `reference` | string | The original secret reference URI | +| `file` | file | Downloaded file attachment | + +### `onepassword_get_item_file` + +Download the content of a file attached to an item + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `connectionMode` | string | No | Connection mode: "service_account" or "connect" | +| `serviceAccountToken` | string | No | 1Password Service Account token \(for Service Account mode\) | +| `apiKey` | string | No | 1Password Connect API token \(for Connect Server mode\) | +| `serverUrl` | string | No | 1Password Connect server URL \(for Connect Server mode\) | +| `vaultId` | string | Yes | The vault UUID | +| `itemId` | string | Yes | The item UUID the file is attached to | +| `fileId` | string | Yes | The file ID \(from the item\'s "files" array, e.g. via Get Item\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `file` | file | Downloaded file attachment | ### `onepassword_create_item` @@ -165,13 +211,37 @@ Create a new item in a vault | `category` | string | Yes | Item category \(e.g., LOGIN, PASSWORD, API_CREDENTIAL, SECURE_NOTE, SERVER, DATABASE\) | | `title` | string | No | Item title | | `tags` | string | No | Comma-separated list of tags | -| `fields` | string | No | JSON array of field objects \(e.g., \[\{"label":"username","value":"admin","type":"STRING","purpose":"USERNAME"\}\]\) | +| `fields` | string | No | JSON array of field objects \(e.g., \[\{"label":"username","value":"admin","type":"STRING","purpose":"USERNAME"\}\]\). "purpose" is honored in Connect Server mode; in Service Account mode 1Password infers it from the field label/type instead. | #### Output | Parameter | Type | Description | | --------- | ---- | ----------- | -| `response` | json | Operation response data | +| `response` | json | Deprecated — kept for backward compatibility with workflows saved before per-operation outputs were added below. Never populated; use the operation-specific outputs instead. | +| `vaults` | json | List of accessible vaults \[\{id, name, description, items, type, createdAt, updatedAt\}\] | +| `id` | string | Vault or item ID | +| `name` | string | Vault name | +| `description` | string | Vault description | +| `items` | json | Number of items in the vault \(Get Vault\) or item summaries \[\{id, title, category, tags, favorite, version, updatedAt\}\] \(List Items\) | +| `type` | string | Vault type \(USER_CREATED, PERSONAL, or EVERYONE\) | +| `title` | string | Item title | +| `category` | string | Item category \(e.g., LOGIN, API_CREDENTIAL, SECURE_NOTE\) | +| `vault` | json | Vault reference the item belongs to \{id\} | +| `fields` | json | Item fields including secrets \[\{id, label, type, purpose, value\}\] | +| `sections` | json | Item sections \[\{id, label\}\] | +| `files` | json | Files attached to the item \[\{id, name, size, section\}\] — fetch content with Get Item File | +| `tags` | json | Item tags | +| `urls` | json | URLs associated with the item \[\{href, label, primary\}\] | +| `favorite` | boolean | Whether the item is favorited | +| `version` | number | Item version number | +| `state` | string | Item state \(ARCHIVED, or absent/null when active\) | +| `lastEditedBy` | string | ID of the last editor | +| `createdAt` | string | Creation timestamp | +| `updatedAt` | string | Last update timestamp | +| `success` | boolean | Whether the item was successfully deleted | +| `value` | string | The resolved secret value | +| `reference` | string | The original secret reference URI | +| `file` | file | Downloaded file attachment | ### `onepassword_replace_item` @@ -193,7 +263,31 @@ Replace an entire item with new data (full update) | Parameter | Type | Description | | --------- | ---- | ----------- | -| `response` | json | Operation response data | +| `response` | json | Deprecated — kept for backward compatibility with workflows saved before per-operation outputs were added below. Never populated; use the operation-specific outputs instead. | +| `vaults` | json | List of accessible vaults \[\{id, name, description, items, type, createdAt, updatedAt\}\] | +| `id` | string | Vault or item ID | +| `name` | string | Vault name | +| `description` | string | Vault description | +| `items` | json | Number of items in the vault \(Get Vault\) or item summaries \[\{id, title, category, tags, favorite, version, updatedAt\}\] \(List Items\) | +| `type` | string | Vault type \(USER_CREATED, PERSONAL, or EVERYONE\) | +| `title` | string | Item title | +| `category` | string | Item category \(e.g., LOGIN, API_CREDENTIAL, SECURE_NOTE\) | +| `vault` | json | Vault reference the item belongs to \{id\} | +| `fields` | json | Item fields including secrets \[\{id, label, type, purpose, value\}\] | +| `sections` | json | Item sections \[\{id, label\}\] | +| `files` | json | Files attached to the item \[\{id, name, size, section\}\] — fetch content with Get Item File | +| `tags` | json | Item tags | +| `urls` | json | URLs associated with the item \[\{href, label, primary\}\] | +| `favorite` | boolean | Whether the item is favorited | +| `version` | number | Item version number | +| `state` | string | Item state \(ARCHIVED, or absent/null when active\) | +| `lastEditedBy` | string | ID of the last editor | +| `createdAt` | string | Creation timestamp | +| `updatedAt` | string | Last update timestamp | +| `success` | boolean | Whether the item was successfully deleted | +| `value` | string | The resolved secret value | +| `reference` | string | The original secret reference URI | +| `file` | file | Downloaded file attachment | ### `onepassword_update_item` @@ -215,7 +309,31 @@ Update an existing item using JSON Patch operations (RFC6902) | Parameter | Type | Description | | --------- | ---- | ----------- | -| `response` | json | Operation response data | +| `response` | json | Deprecated — kept for backward compatibility with workflows saved before per-operation outputs were added below. Never populated; use the operation-specific outputs instead. | +| `vaults` | json | List of accessible vaults \[\{id, name, description, items, type, createdAt, updatedAt\}\] | +| `id` | string | Vault or item ID | +| `name` | string | Vault name | +| `description` | string | Vault description | +| `items` | json | Number of items in the vault \(Get Vault\) or item summaries \[\{id, title, category, tags, favorite, version, updatedAt\}\] \(List Items\) | +| `type` | string | Vault type \(USER_CREATED, PERSONAL, or EVERYONE\) | +| `title` | string | Item title | +| `category` | string | Item category \(e.g., LOGIN, API_CREDENTIAL, SECURE_NOTE\) | +| `vault` | json | Vault reference the item belongs to \{id\} | +| `fields` | json | Item fields including secrets \[\{id, label, type, purpose, value\}\] | +| `sections` | json | Item sections \[\{id, label\}\] | +| `files` | json | Files attached to the item \[\{id, name, size, section\}\] — fetch content with Get Item File | +| `tags` | json | Item tags | +| `urls` | json | URLs associated with the item \[\{href, label, primary\}\] | +| `favorite` | boolean | Whether the item is favorited | +| `version` | number | Item version number | +| `state` | string | Item state \(ARCHIVED, or absent/null when active\) | +| `lastEditedBy` | string | ID of the last editor | +| `createdAt` | string | Creation timestamp | +| `updatedAt` | string | Last update timestamp | +| `success` | boolean | Whether the item was successfully deleted | +| `value` | string | The resolved secret value | +| `reference` | string | The original secret reference URI | +| `file` | file | Downloaded file attachment | ### `onepassword_delete_item` diff --git a/apps/docs/content/docs/en/integrations/sendgrid.mdx b/apps/docs/content/docs/en/integrations/sendgrid.mdx index 0b32ebc50af..a8cb7010e7e 100644 --- a/apps/docs/content/docs/en/integrations/sendgrid.mdx +++ b/apps/docs/content/docs/en/integrations/sendgrid.mdx @@ -206,13 +206,15 @@ Get all contact lists from SendGrid | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `apiKey` | string | Yes | SendGrid API key | -| `pageSize` | number | No | Number of lists to return per page \(default: 100\) | +| `pageSize` | number | No | Number of lists to return per page \(default: 100, max: 1000\) | +| `pageToken` | string | No | Page token from a previous response \(nextPageToken\) to fetch the next page | #### Output | Parameter | Type | Description | | --------- | ---- | ----------- | | `lists` | json | Array of lists | +| `nextPageToken` | string | Token to pass as pageToken to fetch the next page, if more results exist | ### `sendgrid_delete_list` @@ -321,13 +323,17 @@ Get all email templates from SendGrid | --------- | ---- | -------- | ----------- | | `apiKey` | string | Yes | SendGrid API key | | `generations` | string | No | Filter by generation \(legacy, dynamic, or both\) | -| `pageSize` | number | No | Number of templates to return per page \(default: 20\) | +| `pageSize` | number | No | Number of templates to return per page \(default: 20, max: 200\). ' + + 'When paginating with pageToken, pass the same pageSize used on the first request ' + + 'to keep page boundaries consistent. | +| `pageToken` | string | No | Page token from a previous response \(nextPageToken\) to fetch the next page | #### Output | Parameter | Type | Description | | --------- | ---- | ----------- | | `templates` | json | Array of templates | +| `nextPageToken` | string | Token to pass as pageToken to fetch the next page, if more results exist | ### `sendgrid_delete_template` @@ -365,6 +371,7 @@ Delete an email template from SendGrid | `templates` | json | Array of templates | | `generation` | string | Template generation | | `versions` | json | Array of template versions | +| `nextPageToken` | string | Token for the next page of results \(list_all_lists, list_templates\) | | `templateId` | string | Template ID | | `active` | boolean | Whether template version is active | | `htmlContent` | string | HTML content | diff --git a/apps/docs/content/docs/en/integrations/sharepoint.mdx b/apps/docs/content/docs/en/integrations/sharepoint.mdx index bb9bc4df74b..9dcf1db6596 100644 --- a/apps/docs/content/docs/en/integrations/sharepoint.mdx +++ b/apps/docs/content/docs/en/integrations/sharepoint.mdx @@ -107,6 +107,71 @@ Read a specific page from a SharePoint site | `totalPages` | number | Total number of pages found | | `nextPageUrl` | string | Full Microsoft Graph @odata.nextLink URL for the next page of results | +### `sharepoint_update_page` + +Update the title and/or content of a SharePoint page + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `siteId` | string | No | The ID of the SharePoint site \(internal use\) | +| `siteSelector` | string | No | Select the SharePoint site | +| `pageId` | string | Yes | The ID of the page to update. Example: a GUID like 12345678-1234-1234-1234-123456789012 | +| `pageTitle` | string | No | The new title of the page | +| `pageContent` | string | No | The new text content of the page. Replaces the entire canvas layout of the page. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `page` | object | Updated SharePoint page information | +| ↳ `id` | string | The unique ID of the page | +| ↳ `name` | string | The name of the page | +| ↳ `title` | string | The title of the page | +| ↳ `webUrl` | string | The URL to access the page | +| ↳ `pageLayout` | string | The layout type of the page | +| ↳ `createdDateTime` | string | When the page was created | +| ↳ `lastModifiedDateTime` | string | When the page was last modified | + +### `sharepoint_publish_page` + +Publish the latest version of a SharePoint page, making it available to all users + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `siteSelector` | string | No | Select the SharePoint site | +| `siteId` | string | No | The ID of the SharePoint site \(internal use\) | +| `pageId` | string | Yes | The ID of the page to publish. Example: a GUID like 12345678-1234-1234-1234-123456789012 | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `published` | boolean | Whether the page was published | +| `pageId` | string | The ID of the published page | + +### `sharepoint_delete_page` + +Delete a page from a SharePoint site + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `siteSelector` | string | No | Select the SharePoint site | +| `siteId` | string | No | The ID of the SharePoint site \(internal use\) | +| `pageId` | string | Yes | The ID of the page to delete. Example: a GUID like 12345678-1234-1234-1234-123456789012 | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `deleted` | boolean | Whether the page was deleted | +| `pageId` | string | The ID of the deleted page | + ### `sharepoint_list_sites` List details of all SharePoint sites @@ -133,8 +198,7 @@ List details of all SharePoint sites | ↳ `createdDateTime` | string | When the site was created | | ↳ `lastModifiedDateTime` | string | When the site was last modified | | ↳ `isPersonalSite` | boolean | Whether this is a personal site | -| ↳ `root` | object | root output from the tool | -| ↳ `serverRelativeUrl` | string | Server relative URL | +| ↳ `root` | object | Present \(as an empty object\) only when this site is the root of its site collection | | ↳ `siteCollection` | object | siteCollection output from the tool | | ↳ `hostname` | string | Site collection hostname | | `sites` | array | List of all accessible SharePoint sites | @@ -252,6 +316,47 @@ Add a new item to a SharePoint list | ↳ `id` | string | Item ID | | ↳ `fields` | object | Field values for the new item | +### `sharepoint_get_list_item` + +Get a single item (with field values) from a SharePoint list + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `siteSelector` | string | No | Select the SharePoint site | +| `siteId` | string | No | The ID of the SharePoint site \(internal use\) | +| `listId` | string | Yes | The ID of the list containing the item. Example: b!abc123def456 or a GUID like 12345678-1234-1234-1234-123456789012 | +| `itemId` | string | Yes | The ID of the list item to retrieve. Example: 1, 42, or 123 | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `item` | object | SharePoint list item with field values | +| ↳ `id` | string | Item ID | +| ↳ `fields` | object | Field values for the item | + +### `sharepoint_delete_list_item` + +Delete an item from a SharePoint list + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `siteSelector` | string | No | Select the SharePoint site | +| `siteId` | string | No | The ID of the SharePoint site \(internal use\) | +| `listId` | string | Yes | The ID of the list containing the item. Example: b!abc123def456 or a GUID like 12345678-1234-1234-1234-123456789012 | +| `itemId` | string | Yes | The ID of the list item to delete. Example: 1, 42, or 123 | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `deleted` | boolean | Whether the list item was deleted | +| `itemId` | string | The ID of the deleted list item | + ### `sharepoint_upload_file` Upload files to a SharePoint document library @@ -289,4 +394,66 @@ Upload files to a SharePoint document library | ↳ `error` | string | Error message | | ↳ `status` | number | HTTP status from Microsoft Graph | +### `sharepoint_download_file` + +Download a file from a SharePoint document library + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `driveId` | string | Yes | The ID of the document library \(drive\). Example: b!abc123def456 | +| `driveItemId` | string | Yes | The ID of the file \(drive item\) to download | +| `fileName` | string | No | Optional filename override \(e.g., "report.pdf", "data.xlsx"\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `file` | file | Downloaded file stored in execution files | + +### `sharepoint_get_drive_item` + +Get metadata for a file or folder in a SharePoint document library + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `driveId` | string | Yes | The ID of the document library \(drive\). Example: b!abc123def456 | +| `driveItemId` | string | Yes | The ID of the file or folder \(drive item\) to retrieve | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `driveItem` | object | Metadata for the SharePoint file or folder | +| ↳ `id` | string | The unique ID of the drive item | +| ↳ `name` | string | The name of the file or folder | +| ↳ `webUrl` | string | The URL to access the item | +| ↳ `size` | number | The size of the item in bytes | +| ↳ `createdDateTime` | string | When the item was created | +| ↳ `lastModifiedDateTime` | string | When the item was last modified | +| ↳ `file` | object | Present if the item is a file \(contains mimeType\) | +| ↳ `folder` | object | Present if the item is a folder \(contains childCount\) | +| ↳ `parentReference` | object | Reference to the parent folder/drive | + +### `sharepoint_delete_file` + +Delete a file (or folder) from a SharePoint document library + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `driveId` | string | Yes | The ID of the document library \(drive\). Example: b!abc123def456 | +| `driveItemId` | string | Yes | The ID of the file \(drive item\) to delete | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `deleted` | boolean | Whether the file was deleted | +| `itemId` | string | The ID of the deleted file | + diff --git a/apps/docs/content/docs/en/integrations/similarweb.mdx b/apps/docs/content/docs/en/integrations/similarweb.mdx index cbdf82ddc28..b3091f7e947 100644 --- a/apps/docs/content/docs/en/integrations/similarweb.mdx +++ b/apps/docs/content/docs/en/integrations/similarweb.mdx @@ -180,4 +180,32 @@ Get average desktop visit duration over time (in seconds) | ↳ `date` | string | Date \(YYYY-MM-DD\) | | ↳ `durationSeconds` | number | Average visit duration in seconds | +### `similarweb_page_views` + +Get total page views over time (desktop and mobile combined) + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | SimilarWeb API key | +| `domain` | string | Yes | Website domain to analyze \(e.g., "example.com" without www or protocol\) | +| `country` | string | Yes | 2-letter ISO country code \(e.g., "us", "gb", "de"\) or "world" for worldwide data | +| `granularity` | string | Yes | Data granularity: daily, weekly, or monthly | +| `startDate` | string | No | Start date in YYYY-MM format \(e.g., "2024-01"\) | +| `endDate` | string | No | End date in YYYY-MM format \(e.g., "2024-12"\) | +| `mainDomainOnly` | boolean | No | Exclude subdomains from results | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `domain` | string | Analyzed domain | +| `country` | string | Country filter applied | +| `granularity` | string | Data granularity | +| `lastUpdated` | string | Data last updated timestamp | +| `pageViews` | array | Page view data over time | +| ↳ `date` | string | Date \(YYYY-MM-DD\) | +| ↳ `pageViews` | number | Total page views | + diff --git a/apps/docs/content/docs/en/integrations/supabase.mdx b/apps/docs/content/docs/en/integrations/supabase.mdx index 1741ceb55a1..e5c339c3dc6 100644 --- a/apps/docs/content/docs/en/integrations/supabase.mdx +++ b/apps/docs/content/docs/en/integrations/supabase.mdx @@ -288,7 +288,7 @@ Invoke a Supabase Edge Function over HTTP ### `supabase_introspect` -Introspect Supabase database schema to get table structures, columns, and relationships +Introspect Supabase database schema from its OpenAPI spec to get table and column structures (best-effort primary/foreign key detection) #### Input @@ -309,11 +309,11 @@ Introspect Supabase database schema to get table structures, columns, and relati | ↳ `columns` | array | Array of column definitions | | ↳ `name` | string | Column name | | ↳ `type` | string | Column data type | -| ↳ `nullable` | boolean | Whether the column allows null values | +| ↳ `nullable` | boolean | Whether the column allows null values — a NOT NULL column that has a default value is misreported as nullable, since the OpenAPI spec this is derived from omits it from the required list in that case | | ↳ `default` | string | Default value for the column | -| ↳ `isPrimaryKey` | boolean | Whether the column is a primary key | -| ↳ `isForeignKey` | boolean | Whether the column is a foreign key | -| ↳ `references` | object | Foreign key reference details | +| ↳ `isPrimaryKey` | boolean | Best-effort guess based on the column being named "id" \(not authoritative\) | +| ↳ `isForeignKey` | boolean | True only if the column has a "references table.column" SQL comment; most databases will show false even for real foreign keys | +| ↳ `references` | object | Foreign key reference details, when detected via SQL comment | | ↳ `table` | string | Referenced table name | | ↳ `column` | string | Referenced column name | | ↳ `primaryKey` | array | Array of primary key column names | @@ -321,7 +321,7 @@ Introspect Supabase database schema to get table structures, columns, and relati | ↳ `column` | string | Local column name | | ↳ `referencesTable` | string | Referenced table name | | ↳ `referencesColumn` | string | Referenced column name | -| ↳ `indexes` | array | Array of index definitions | +| ↳ `indexes` | array | Always empty — index definitions are not exposed by the OpenAPI spec this tool reads | | ↳ `name` | string | Index name | | ↳ `columns` | array | Columns included in the index | | ↳ `unique` | boolean | Whether the index enforces uniqueness | @@ -512,6 +512,49 @@ Create a new storage bucket in Supabase | `results` | object | Created bucket result \(name\) | | ↳ `name` | string | Created bucket name | +### `supabase_storage_update_bucket` + +Update the configuration of an existing Supabase storage bucket + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `projectId` | string | Yes | Your Supabase project ID \(e.g., jdrkgepadsdopsntdlom\) | +| `bucket` | string | Yes | The name of the bucket to update | +| `isPublic` | boolean | No | Whether the bucket should be publicly accessible \(leave unset to keep the current value\) | +| `fileSizeLimit` | number | No | Maximum file size in bytes \(leave unset to keep the current value\) | +| `allowedMimeTypes` | array | No | Array of allowed MIME types \(e.g., \["image/png", "image/jpeg"\]\) — leave unset to keep the current value | +| `apiKey` | string | Yes | Your Supabase service role secret key | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Operation status message | +| `results` | object | Update operation result | +| ↳ `message` | string | Operation status message | + +### `supabase_storage_empty_bucket` + +Delete all objects inside a Supabase storage bucket without deleting the bucket itself + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `projectId` | string | Yes | Your Supabase project ID \(e.g., jdrkgepadsdopsntdlom\) | +| `bucket` | string | Yes | The name of the bucket to empty | +| `apiKey` | string | Yes | Your Supabase service role secret key | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Operation status message | +| `results` | object | Empty bucket operation result | +| ↳ `message` | string | Operation status message | + ### `supabase_storage_list_buckets` List all storage buckets in Supabase @@ -570,7 +613,6 @@ Get the public URL for a file in a Supabase storage bucket | `bucket` | string | Yes | The name of the storage bucket | | `path` | string | Yes | The path to the file \(e.g., "folder/file.jpg"\) | | `download` | boolean | No | If true, forces download instead of inline display \(default: false\) | -| `output` | string | No | No description | #### Output @@ -601,4 +643,27 @@ Create a temporary signed URL for a file in a Supabase storage bucket | `message` | string | Operation status message | | `signedUrl` | string | The temporary signed URL to access the file | +### `supabase_storage_create_signed_upload_url` + +Create a temporary signed URL a client can use to upload directly to a Supabase storage bucket + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `projectId` | string | Yes | Your Supabase project ID \(e.g., jdrkgepadsdopsntdlom\) | +| `bucket` | string | Yes | The name of the storage bucket | +| `path` | string | Yes | The destination path for the uploaded file \(e.g., "folder/file.jpg"\) | +| `upsert` | boolean | No | If true, allows overwriting an existing file at this path \(default: false\) | +| `apiKey` | string | Yes | Your Supabase service role secret key | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Operation status message | +| `signedUrl` | string | The temporary signed URL a client can PUT the file to | +| `path` | string | The destination object path | +| `token` | string | The upload token embedded in the signed URL | + diff --git a/apps/docs/content/docs/en/integrations/tailscale.mdx b/apps/docs/content/docs/en/integrations/tailscale.mdx index e5e97f6b7fe..e1dfc2dbc01 100644 --- a/apps/docs/content/docs/en/integrations/tailscale.mdx +++ b/apps/docs/content/docs/en/integrations/tailscale.mdx @@ -67,7 +67,8 @@ List all devices in the tailnet | Parameter | Type | Description | | --------- | ---- | ----------- | | `devices` | array | List of devices in the tailnet | -| ↳ `id` | string | Device ID | +| ↳ `id` | string | Legacy device ID | +| ↳ `nodeId` | string | Preferred device ID | | ↳ `name` | string | Device name | | ↳ `hostname` | string | Device hostname | | ↳ `user` | string | Associated user | @@ -77,6 +78,8 @@ List all devices in the tailnet | ↳ `tags` | array | Device tags | | ↳ `authorized` | boolean | Whether the device is authorized | | ↳ `blocksIncomingConnections` | boolean | Whether the device blocks incoming connections | +| ↳ `keyExpiryDisabled` | boolean | Whether the device key is exempt from expiring | +| ↳ `expires` | string | The device's auth key expiration timestamp | | ↳ `lastSeen` | string | Last seen timestamp | | ↳ `created` | string | Creation timestamp | | `count` | number | Total number of devices | @@ -97,7 +100,8 @@ Get details of a specific device by ID | Parameter | Type | Description | | --------- | ---- | ----------- | -| `id` | string | Device ID | +| `id` | string | Legacy device ID | +| `nodeId` | string | Preferred device ID | | `name` | string | Device name | | `hostname` | string | Device hostname | | `user` | string | Associated user | @@ -107,6 +111,8 @@ Get details of a specific device by ID | `tags` | array | Device tags | | `authorized` | boolean | Whether the device is authorized | | `blocksIncomingConnections` | boolean | Whether the device blocks incoming connections | +| `keyExpiryDisabled` | boolean | Whether the device key is exempt from expiring | +| `expires` | string | The device's auth key expiration timestamp | | `lastSeen` | string | Last seen timestamp | | `created` | string | Creation timestamp | | `isExternal` | boolean | Whether the device is external | @@ -235,6 +241,25 @@ Enable or disable key expiry on a device | `deviceId` | string | Device ID | | `keyExpiryDisabled` | boolean | Whether key expiry is now disabled | +### `tailscale_expire_device_key` + +Immediately expire a device's node key, requiring it to re-authenticate before it can reconnect to the tailnet + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Tailscale API key | +| `tailnet` | string | Yes | Tailnet name \(e.g., example.com\) or "-" for default | +| `deviceId` | string | Yes | Device ID to expire the key for | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Whether the device's key was successfully expired | +| `deviceId` | string | Device ID | + ### `tailscale_list_dns_nameservers` Get the DNS nameservers configured for the tailnet @@ -251,7 +276,6 @@ Get the DNS nameservers configured for the tailnet | Parameter | Type | Description | | --------- | ---- | ----------- | | `dns` | array | List of DNS nameserver addresses | -| `magicDNS` | boolean | Whether MagicDNS is enabled | ### `tailscale_set_dns_nameservers` @@ -370,6 +394,44 @@ List all users in the tailnet | ↳ `deviceCount` | number | Number of devices owned by user | | `count` | number | Total number of users | +### `tailscale_suspend_user` + +Suspend a user's access to the tailnet + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Tailscale API key | +| `tailnet` | string | Yes | Tailnet name \(e.g., example.com\) or "-" for default | +| `userId` | string | Yes | User ID to suspend | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Whether the user was successfully suspended | +| `userId` | string | ID of the suspended user | + +### `tailscale_delete_user` + +Delete a user from the tailnet + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Tailscale API key | +| `tailnet` | string | Yes | Tailnet name \(e.g., example.com\) or "-" for default | +| `userId` | string | Yes | User ID to delete | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Whether the user was successfully deleted | +| `userId` | string | ID of the deleted user | + ### `tailscale_create_auth_key` Create a new auth key for the tailnet to pre-authorize devices @@ -495,4 +557,24 @@ Get the current ACL policy for the tailnet | `acl` | string | ACL policy as JSON string | | `etag` | string | ETag for the current ACL version \(use with If-Match header for updates\) | +### `tailscale_set_acl` + +Replace the ACL policy file for the tailnet + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Tailscale API key | +| `tailnet` | string | Yes | Tailnet name \(e.g., example.com\) or "-" for default | +| `acl` | string | Yes | The new ACL policy file, as a JSON string | +| `ifMatch` | string | No | ETag from a prior Get ACL call to avoid overwriting concurrent updates. Use "ts-default" to only replace an untouched default policy file. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `acl` | string | Updated ACL policy as JSON string | +| `etag` | string | ETag for the new ACL version \(use with If-Match header for future updates\) | + diff --git a/apps/docs/content/docs/en/integrations/trello.mdx b/apps/docs/content/docs/en/integrations/trello.mdx index 86cdef85b99..c1f4b806418 100644 --- a/apps/docs/content/docs/en/integrations/trello.mdx +++ b/apps/docs/content/docs/en/integrations/trello.mdx @@ -1,6 +1,6 @@ --- title: Trello -description: Manage Trello lists, cards, and activity +description: Manage Trello lists, cards, checklists, and activity --- import { BlockInfoCard } from "@/components/ui/block-info-card" @@ -37,7 +37,7 @@ Trello's authorization flow redirects back to Sim using a `return_url`. If your {/* MANUAL-CONTENT-END */} -Integrate with Trello to list board lists, list cards, create cards, update cards, review activity, and add comments. +Integrate with Trello to list, search, create, update, and delete cards and lists, manage checklists and checklist items, assign labels and members, review activity, and add comments. @@ -52,6 +52,7 @@ List all lists on a Trello board | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `boardId` | string | Yes | Trello board ID \(24-character hex string\) | +| `filter` | string | No | Which lists to return: open, closed, or all \(defaults to open\) | #### Output @@ -75,6 +76,7 @@ List cards from a Trello board or list | --------- | ---- | -------- | ----------- | | `boardId` | string | No | Trello board ID to list open cards from. Provide either boardId or listId | | `listId` | string | No | Trello list ID to list cards from. Provide either boardId or listId | +| `filter` | string | No | Which cards to return: open, closed, or all \(defaults to open\) | #### Output @@ -97,6 +99,40 @@ List cards from a Trello board or list | ↳ `dueComplete` | boolean | Whether the due date is complete | | `count` | number | Number of cards returned | +### `trello_search` + +Search Trello cards and boards by keyword + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `query` | string | Yes | Search text, supports Trello search operators \(e.g. board:, list:, due:\) | +| `idBoards` | array | No | Restrict the search to these board IDs | +| `modelTypes` | string | No | Comma-separated result types to search: cards, boards, or all \(default all\) | +| `cardsLimit` | number | No | Maximum number of cards to return \(1-1000, default 10\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `cards` | array | Cards matching the search query | +| ↳ `id` | string | Card ID | +| ↳ `name` | string | Card name | +| ↳ `desc` | string | Card description | +| ↳ `url` | string | Full card URL | +| ↳ `idBoard` | string | Board ID containing the card | +| ↳ `idList` | string | List ID containing the card | +| ↳ `closed` | boolean | Whether the card is archived | +| `boards` | array | Boards matching the search query | +| ↳ `id` | string | Board ID | +| ↳ `name` | string | Board name | +| ↳ `desc` | string | Board description | +| ↳ `url` | string | Full board URL | +| ↳ `closed` | boolean | Whether the board is archived | +| ↳ `idOrganization` | string | Workspace/organization ID that owns the board | +| `count` | number | Total number of cards and boards returned | + ### `trello_create_card` Create a new card in a Trello list @@ -112,6 +148,7 @@ Create a new card in a Trello list | `due` | string | No | Due date \(ISO 8601 format\) | | `dueComplete` | boolean | No | Whether the due date should be marked complete | | `labelIds` | array | No | Label IDs to attach to the card | +| `memberIds` | array | No | Member IDs to assign to the card | #### Output @@ -169,6 +206,22 @@ Update an existing card on Trello | ↳ `due` | string | Card due date in ISO 8601 format | | ↳ `dueComplete` | boolean | Whether the due date is complete | +### `trello_delete_card` + +Permanently delete a Trello card + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `cardId` | string | Yes | Trello card ID to permanently delete \(24-character hex string\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Whether the card was deleted | + ### `trello_get_actions` Get activity/actions from a board or card @@ -182,6 +235,8 @@ Get activity/actions from a board or card | `filter` | string | No | Filter actions by type \(e.g., "commentCard,updateCard,createCard" or "all"\) | | `limit` | number | No | Maximum number of board actions to return | | `page` | number | No | Page number for action results | +| `since` | string | No | Only return actions after this date \(ISO 8601 timestamp\) or action ID, for paging through long histories | +| `before` | string | No | Only return actions before this date \(ISO 8601 timestamp\) or action ID, for paging through long histories | #### Output @@ -321,6 +376,31 @@ Create a new list on a Trello board | ↳ `pos` | number | List position on the board | | ↳ `idBoard` | string | Board ID containing the list | +### `trello_update_list` + +Rename, move, archive, or reopen a Trello list + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `listId` | string | Yes | Trello list ID \(24-character hex string\) | +| `name` | string | No | New name of the list | +| `closed` | boolean | No | Archive the list \(true\) or reopen it \(false\) | +| `idBoard` | string | No | Board ID to move the list to \(24-character hex string\) | +| `pos` | string | No | New position of the list \(top, bottom, or positive float\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `list` | json | Updated list \(id, name, closed, pos, idBoard\) | +| ↳ `id` | string | List ID | +| ↳ `name` | string | List name | +| ↳ `closed` | boolean | Whether the list is archived | +| ↳ `pos` | number | List position on the board | +| ↳ `idBoard` | string | Board ID containing the list | + ### `trello_get_card` Retrieve a single Trello card by ID @@ -374,6 +454,54 @@ Add a checklist to a Trello card | ↳ `idBoard` | string | Board ID containing the checklist | | ↳ `pos` | number | Checklist position on the card | +### `trello_add_checklist_item` + +Add an item to a Trello checklist + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `checklistId` | string | Yes | Trello checklist ID to add the item to \(24-character hex string\) | +| `name` | string | Yes | Name of the checklist item | +| `pos` | string | No | Position of the item \(top, bottom, or positive float\) | +| `checked` | boolean | No | Whether the item should start checked off | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `item` | json | Created checklist item \(id, name, state, pos, idChecklist\) | +| ↳ `id` | string | Checklist item ID | +| ↳ `name` | string | Checklist item name | +| ↳ `state` | string | Item state \(complete or incomplete\) | +| ↳ `pos` | number | Item position on the checklist | +| ↳ `idChecklist` | string | Checklist ID containing the item | + +### `trello_update_checklist_item` + +Check off, uncheck, or rename a Trello checklist item + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `cardId` | string | Yes | Trello card ID that owns the checklist item \(24-character hex string\) | +| `checkItemId` | string | Yes | Checklist item ID to update \(24-character hex string\) | +| `state` | string | No | Set the item state to complete or incomplete | +| `name` | string | No | New name for the checklist item | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `item` | json | Updated checklist item \(id, name, state, pos, idChecklist\) | +| ↳ `id` | string | Checklist item ID | +| ↳ `name` | string | Checklist item name | +| ↳ `state` | string | Item state \(complete or incomplete\) | +| ↳ `pos` | number | Item position on the checklist | +| ↳ `idChecklist` | string | Checklist ID containing the item | + ### `trello_add_label` Attach an existing label to a Trello card @@ -391,6 +519,23 @@ Attach an existing label to a Trello card | --------- | ---- | ----------- | | `labelIds` | array | Label IDs now applied to the card | +### `trello_remove_label` + +Detach a label from a Trello card + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `cardId` | string | Yes | Trello card ID to detach the label from \(24-character hex string\) | +| `labelId` | string | Yes | ID of the label to detach \(24-character hex string\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Whether the label was removed from the card | + ### `trello_add_member` Assign a member to a Trello card @@ -408,4 +553,41 @@ Assign a member to a Trello card | --------- | ---- | ----------- | | `memberIds` | array | Member IDs now assigned to the card | +### `trello_remove_member` + +Unassign a member from a Trello card + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `cardId` | string | Yes | Trello card ID to unassign the member from \(24-character hex string\) | +| `memberId` | string | Yes | ID of the member to unassign \(24-character hex string\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Whether the member was removed from the card | + +### `trello_list_members` + +List members of a Trello board + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `boardId` | string | Yes | Trello board ID \(24-character hex string\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `members` | array | Members on the selected board | +| ↳ `id` | string | Member ID | +| ↳ `fullName` | string | Member full name | +| ↳ `username` | string | Member username | +| `count` | number | Number of members returned | + diff --git a/apps/docs/content/docs/en/integrations/vercel.mdx b/apps/docs/content/docs/en/integrations/vercel.mdx index 361ee626a5c..66b26629ca0 100644 --- a/apps/docs/content/docs/en/integrations/vercel.mdx +++ b/apps/docs/content/docs/en/integrations/vercel.mdx @@ -49,6 +49,7 @@ List deployments for a Vercel project or team | `until` | number | No | Get deployments created before this JavaScript timestamp | | `limit` | number | No | Maximum number of deployments to return per request | | `teamId` | string | No | Team ID to scope the request | +| `slug` | string | No | Team slug to scope the request \(alternative to teamId\) | #### Output @@ -87,6 +88,7 @@ Get details of a specific Vercel deployment | `deploymentId` | string | Yes | The unique deployment identifier or hostname | | `withGitRepoInfo` | string | No | Whether to add in gitRepo information \(true/false\) | | `teamId` | string | No | Team ID to scope the request | +| `slug` | string | No | Team slug to scope the request \(alternative to teamId\) | #### Output @@ -145,6 +147,7 @@ Create a new deployment or redeploy an existing one | `gitSource` | string | No | JSON string defining the Git Repository source to deploy \(e.g. \{"type":"github","repo":"owner/repo","ref":"main"\}\) | | `forceNew` | string | No | Forces a new deployment even if there is a previous similar deployment \(0 or 1\) | | `teamId` | string | No | Team ID to scope the request | +| `slug` | string | No | Team slug to scope the request \(alternative to teamId\) | #### Output @@ -174,6 +177,7 @@ Cancel a running Vercel deployment | `apiKey` | string | Yes | Vercel Access Token | | `deploymentId` | string | Yes | The deployment ID to cancel | | `teamId` | string | No | Team ID to scope the request | +| `slug` | string | No | Team slug to scope the request \(alternative to teamId\) | #### Output @@ -198,6 +202,7 @@ Delete a Vercel deployment | `apiKey` | string | Yes | Vercel Access Token | | `deploymentId` | string | Yes | The deployment ID or URL to delete | | `teamId` | string | No | Team ID to scope the request | +| `slug` | string | No | Team slug to scope the request \(alternative to teamId\) | #### Output @@ -222,6 +227,7 @@ Get build and runtime events for a Vercel deployment | `since` | number | No | Timestamp to start pulling build logs from | | `until` | number | No | Timestamp to stop pulling build logs at | | `teamId` | string | No | Team ID to scope the request | +| `slug` | string | No | Team slug to scope the request \(alternative to teamId\) | #### Output @@ -250,6 +256,7 @@ List files in a Vercel deployment | `apiKey` | string | Yes | Vercel Access Token | | `deploymentId` | string | Yes | The deployment ID to list files for | | `teamId` | string | No | Team ID to scope the request | +| `slug` | string | No | Team slug to scope the request \(alternative to teamId\) | #### Output @@ -279,6 +286,7 @@ Promote a deployment by pointing the production deployment to the given deployme | `projectId` | string | Yes | Project ID or name | | `deploymentId` | string | Yes | The ID of the deployment to promote to production | | `teamId` | string | No | Team ID to scope the request | +| `slug` | string | No | Team slug to scope the request \(alternative to teamId\) | #### Output @@ -297,7 +305,9 @@ List all projects in a Vercel team or account | `apiKey` | string | Yes | Vercel Access Token | | `search` | string | No | Search projects by name | | `limit` | number | No | Maximum number of projects to return | +| `from` | string | No | Continuation token for pagination, taken from the previous response's pagination.next value. Query only projects updated after this timestamp or continuation token. | | `teamId` | string | No | Team ID to scope the request | +| `slug` | string | No | Team slug to scope the request \(alternative to teamId\) | #### Output @@ -307,10 +317,13 @@ List all projects in a Vercel team or account | ↳ `id` | string | Project ID | | ↳ `name` | string | Project name | | ↳ `framework` | string | Framework | +| ↳ `rootDirectory` | string | Root directory of the project | +| ↳ `nodeVersion` | string | Node.js version | | ↳ `createdAt` | number | Creation timestamp | | ↳ `updatedAt` | number | Last updated timestamp | | `count` | number | Number of projects returned | | `hasMore` | boolean | Whether more projects are available | +| `nextFrom` | string | Continuation token to pass as `from` to fetch the next page | ### `vercel_get_project` @@ -323,6 +336,7 @@ Get details of a specific Vercel project | `apiKey` | string | Yes | Vercel Access Token | | `projectId` | string | Yes | Project ID or name | | `teamId` | string | No | Team ID to scope the request | +| `slug` | string | No | Team slug to scope the request \(alternative to teamId\) | #### Output @@ -331,6 +345,8 @@ Get details of a specific Vercel project | `id` | string | Project ID | | `name` | string | Project name | | `framework` | string | Project framework | +| `rootDirectory` | string | Root directory of the project | +| `nodeVersion` | string | Node.js version | | `createdAt` | number | Creation timestamp | | `updatedAt` | number | Last updated timestamp | | `link` | object | Git repository connection | @@ -353,7 +369,11 @@ Create a new Vercel project | `buildCommand` | string | No | Custom build command | | `outputDirectory` | string | No | Custom output directory | | `installCommand` | string | No | Custom install command | +| `rootDirectory` | string | No | Subdirectory of the repository the project lives in \(for monorepos\) | +| `nodeVersion` | string | No | Node.js version to use \(e.g. 22.x, 20.x, 18.x\) | +| `devCommand` | string | No | Custom dev server command | | `teamId` | string | No | Team ID to scope the request | +| `slug` | string | No | Team slug to scope the request \(alternative to teamId\) | #### Output @@ -380,7 +400,11 @@ Update an existing Vercel project | `buildCommand` | string | No | Custom build command | | `outputDirectory` | string | No | Custom output directory | | `installCommand` | string | No | Custom install command | +| `rootDirectory` | string | No | Subdirectory of the repository the project lives in \(for monorepos\) | +| `nodeVersion` | string | No | Node.js version to use \(e.g. 22.x, 20.x, 18.x\) | +| `devCommand` | string | No | Custom dev server command | | `teamId` | string | No | Team ID to scope the request | +| `slug` | string | No | Team slug to scope the request \(alternative to teamId\) | #### Output @@ -402,6 +426,7 @@ Delete a Vercel project | `apiKey` | string | Yes | Vercel Access Token | | `projectId` | string | Yes | Project ID or name | | `teamId` | string | No | Team ID to scope the request | +| `slug` | string | No | Team slug to scope the request \(alternative to teamId\) | #### Output @@ -420,6 +445,7 @@ Pause a Vercel project | `apiKey` | string | Yes | Vercel Access Token | | `projectId` | string | Yes | Project ID or name | | `teamId` | string | No | Team ID to scope the request | +| `slug` | string | No | Team slug to scope the request \(alternative to teamId\) | #### Output @@ -440,6 +466,7 @@ Unpause a Vercel project | `apiKey` | string | Yes | Vercel Access Token | | `projectId` | string | Yes | Project ID or name | | `teamId` | string | No | Team ID to scope the request | +| `slug` | string | No | Team slug to scope the request \(alternative to teamId\) | #### Output @@ -460,6 +487,7 @@ List all domains for a Vercel project | `apiKey` | string | Yes | Vercel Access Token | | `projectId` | string | Yes | Project ID or name | | `teamId` | string | No | Team ID to scope the request | +| `slug` | string | No | Team slug to scope the request \(alternative to teamId\) | | `limit` | number | No | Maximum number of domains to return | #### Output @@ -499,6 +527,7 @@ Add a domain to a Vercel project | `redirectStatusCode` | number | No | HTTP status code for redirect \(301, 302, 307, 308\) | | `gitBranch` | string | No | Git branch to link the domain to | | `teamId` | string | No | Team ID to scope the request | +| `slug` | string | No | Team slug to scope the request \(alternative to teamId\) | #### Output @@ -531,6 +560,7 @@ Remove a domain from a Vercel project | `projectId` | string | Yes | Project ID or name | | `domain` | string | Yes | Domain name to remove | | `teamId` | string | No | Team ID to scope the request | +| `slug` | string | No | Team slug to scope the request \(alternative to teamId\) | #### Output @@ -553,6 +583,7 @@ Update a project domain's configuration on Vercel | `redirectStatusCode` | number | No | HTTP status code for redirect \(301, 302, 307, 308\) | | `gitBranch` | string | No | Git branch to link the domain to | | `teamId` | string | No | Team ID to scope the request | +| `slug` | string | No | Team slug to scope the request \(alternative to teamId\) | #### Output @@ -585,6 +616,7 @@ Verify a Vercel project domain by checking its verification challenge | `projectId` | string | Yes | Project ID or name | | `domain` | string | Yes | Domain name to verify | | `teamId` | string | No | Team ID to scope the request | +| `slug` | string | No | Team slug to scope the request \(alternative to teamId\) | #### Output @@ -610,7 +642,10 @@ Retrieve environment variables for a Vercel project | --------- | ---- | -------- | ----------- | | `apiKey` | string | Yes | Vercel Access Token | | `projectId` | string | Yes | Project ID or name | +| `decrypt` | boolean | No | If true, decrypted variable values are returned instead of ciphertext | +| `gitBranch` | string | No | Filter results to the environment variables for this git branch \(must have target=preview\) | | `teamId` | string | No | Team ID to scope the request | +| `slug` | string | No | Team slug to scope the request \(alternative to teamId\) | #### Output @@ -645,6 +680,7 @@ Create an environment variable for a Vercel project | `gitBranch` | string | No | Git branch to associate with the variable \(requires target to include preview\) | | `comment` | string | No | Comment to add context to the variable \(max 500 characters\) | | `teamId` | string | No | Team ID to scope the request | +| `slug` | string | No | Team slug to scope the request \(alternative to teamId\) | #### Output @@ -678,6 +714,7 @@ Update an environment variable for a Vercel project | `gitBranch` | string | No | Git branch to associate with the variable \(requires target to include preview\) | | `comment` | string | No | Comment to add context to the variable \(max 500 characters\) | | `teamId` | string | No | Team ID to scope the request | +| `slug` | string | No | Team slug to scope the request \(alternative to teamId\) | #### Output @@ -705,6 +742,7 @@ Delete an environment variable from a Vercel project | `projectId` | string | Yes | Project ID or name | | `envId` | string | Yes | Environment variable ID to delete | | `teamId` | string | No | Team ID to scope the request | +| `slug` | string | No | Team slug to scope the request \(alternative to teamId\) | #### Output @@ -723,6 +761,7 @@ List all domains in a Vercel account or team | `apiKey` | string | Yes | Vercel Access Token | | `limit` | number | No | Maximum number of domains to return \(default 20\) | | `teamId` | string | No | Team ID to scope the request | +| `slug` | string | No | Team slug to scope the request \(alternative to teamId\) | #### Output @@ -744,6 +783,10 @@ List all domains in a Vercel account or team | ↳ `id` | string | Creator ID | | ↳ `username` | string | Creator username | | ↳ `email` | string | Creator email | +| ↳ `customNameservers` | array | Custom nameservers | +| ↳ `userId` | string | Owner user ID | +| ↳ `teamId` | string | Owner team ID | +| ↳ `transferStartedAt` | number | Transfer start timestamp | | `count` | number | Number of domains returned | | `hasMore` | boolean | Whether more domains are available | @@ -758,6 +801,7 @@ Get information about a specific domain in a Vercel account | `apiKey` | string | Yes | Vercel Access Token | | `domain` | string | Yes | The domain name to retrieve | | `teamId` | string | No | Team ID to scope the request | +| `slug` | string | No | Team slug to scope the request \(alternative to teamId\) | #### Output @@ -794,6 +838,7 @@ Add a new domain to a Vercel account or team | `apiKey` | string | Yes | Vercel Access Token | | `name` | string | Yes | The domain name to add | | `teamId` | string | No | Team ID to scope the request | +| `slug` | string | No | Team slug to scope the request \(alternative to teamId\) | #### Output @@ -827,6 +872,7 @@ Delete a domain from a Vercel account or team | `apiKey` | string | Yes | Vercel Access Token | | `domain` | string | Yes | The domain name to delete | | `teamId` | string | No | Team ID to scope the request | +| `slug` | string | No | Team slug to scope the request \(alternative to teamId\) | #### Output @@ -846,6 +892,7 @@ Get the configuration for a domain in a Vercel account | `apiKey` | string | Yes | Vercel Access Token | | `domain` | string | Yes | The domain name to get configuration for | | `teamId` | string | No | Team ID to scope the request | +| `slug` | string | No | Team slug to scope the request \(alternative to teamId\) | #### Output @@ -873,6 +920,7 @@ List all DNS records for a domain in a Vercel account | `domain` | string | Yes | The domain name to list records for | | `limit` | number | No | Maximum number of records to return | | `teamId` | string | No | Team ID to scope the request | +| `slug` | string | No | Team slug to scope the request \(alternative to teamId\) | #### Output @@ -906,10 +954,19 @@ Create a DNS record for a domain in a Vercel account | `domain` | string | Yes | The domain name to create the record for | | `recordName` | string | Yes | The subdomain or record name | | `recordType` | string | Yes | DNS record type \(A, AAAA, ALIAS, CAA, CNAME, HTTPS, MX, SRV, TXT, NS\) | -| `value` | string | Yes | The value of the DNS record | +| `value` | string | No | The value of the DNS record \(not used for SRV/HTTPS records\) | | `ttl` | number | No | Time to live in seconds | | `mxPriority` | number | No | Priority for MX records | +| `srvTarget` | string | No | Target hostname for SRV records \(required when recordType is SRV\) | +| `srvWeight` | number | No | Weight for SRV records \(required when recordType is SRV\) | +| `srvPort` | number | No | Port for SRV records \(required when recordType is SRV\) | +| `srvPriority` | number | No | Priority for SRV records \(required when recordType is SRV\) | +| `httpsTarget` | string | No | Target hostname for HTTPS records \(required when recordType is HTTPS\) | +| `httpsPriority` | number | No | Priority for HTTPS records \(required when recordType is HTTPS\) | +| `httpsParams` | string | No | Optional service parameters for HTTPS records \(e.g. "alpn=h2,h3"\) | +| `comment` | string | No | A comment to add context on what this DNS record is for \(max 500 characters\) | | `teamId` | string | No | Team ID to scope the request | +| `slug` | string | No | Team slug to scope the request \(alternative to teamId\) | #### Output @@ -933,8 +990,16 @@ Update an existing DNS record for a domain in a Vercel account | `type` | string | No | DNS record type \(A, AAAA, ALIAS, CAA, CNAME, HTTPS, MX, SRV, TXT, NS\) | | `ttl` | number | No | Time to live in seconds \(60 to 2147483647\) | | `mxPriority` | number | No | Priority for MX records | +| `srvTarget` | string | No | Target hostname for SRV records \(required together when updating SRV data\) | +| `srvWeight` | number | No | Weight for SRV records \(required together when updating SRV data\) | +| `srvPort` | number | No | Port for SRV records \(required together when updating SRV data\) | +| `srvPriority` | number | No | Priority for SRV records \(required together when updating SRV data\) | +| `httpsTarget` | string | No | Target hostname for HTTPS records \(required together when updating HTTPS data\) | +| `httpsPriority` | number | No | Priority for HTTPS records \(required together when updating HTTPS data\) | +| `httpsParams` | string | No | Optional service parameters for HTTPS records \(e.g. "alpn=h2,h3"\) | | `comment` | string | No | A comment to add context on what this DNS record is for \(max 500 characters\) | | `teamId` | string | No | Team ID to scope the request | +| `slug` | string | No | Team slug to scope the request \(alternative to teamId\) | #### Output @@ -963,6 +1028,7 @@ Delete a DNS record for a domain in a Vercel account | `domain` | string | Yes | The domain name the record belongs to | | `recordId` | string | Yes | The ID of the DNS record to delete | | `teamId` | string | No | Team ID to scope the request | +| `slug` | string | No | Team slug to scope the request \(alternative to teamId\) | #### Output @@ -983,6 +1049,7 @@ List aliases for a Vercel project or team | `domain` | string | No | Filter aliases by domain | | `limit` | number | No | Maximum number of aliases to return | | `teamId` | string | No | Team ID to scope the request | +| `slug` | string | No | Team slug to scope the request \(alternative to teamId\) | #### Output @@ -1014,6 +1081,7 @@ Get details about a specific alias by ID or hostname | `apiKey` | string | Yes | Vercel Access Token | | `aliasId` | string | Yes | Alias ID or hostname to look up | | `teamId` | string | No | Team ID to scope the request | +| `slug` | string | No | Team slug to scope the request \(alternative to teamId\) | #### Output @@ -1042,7 +1110,9 @@ Assign an alias (domain/subdomain) to a deployment | `apiKey` | string | Yes | Vercel Access Token | | `deploymentId` | string | Yes | Deployment ID to assign the alias to | | `alias` | string | Yes | The domain or subdomain to assign as an alias | +| `redirect` | string | No | Hostname to 307-redirect the alias to instead of serving the deployment directly | | `teamId` | string | No | Team ID to scope the request | +| `slug` | string | No | Team slug to scope the request \(alternative to teamId\) | #### Output @@ -1064,6 +1134,7 @@ Delete an alias by its ID | `apiKey` | string | Yes | Vercel Access Token | | `aliasId` | string | Yes | Alias ID to delete | | `teamId` | string | No | Team ID to scope the request | +| `slug` | string | No | Team slug to scope the request \(alternative to teamId\) | #### Output @@ -1322,6 +1393,7 @@ Create a new deployment check | `externalId` | string | No | External identifier for the check | | `rerequestable` | boolean | No | Whether the check can be rerequested | | `teamId` | string | No | Team ID to scope the request | +| `slug` | string | No | Team slug to scope the request \(alternative to teamId\) | #### Output @@ -1356,6 +1428,7 @@ Get details of a specific deployment check | `deploymentId` | string | Yes | Deployment ID the check belongs to | | `checkId` | string | Yes | Check ID to retrieve | | `teamId` | string | No | Team ID to scope the request | +| `slug` | string | No | Team slug to scope the request \(alternative to teamId\) | #### Output @@ -1389,6 +1462,7 @@ List all checks for a deployment | `apiKey` | string | Yes | Vercel Access Token | | `deploymentId` | string | Yes | Deployment ID to list checks for | | `teamId` | string | No | Team ID to scope the request | +| `slug` | string | No | Team slug to scope the request \(alternative to teamId\) | #### Output @@ -1432,6 +1506,7 @@ Update an existing deployment check | `path` | string | No | Page path being checked | | `output` | string | No | JSON string with check output metrics | | `teamId` | string | No | Team ID to scope the request | +| `slug` | string | No | Team slug to scope the request \(alternative to teamId\) | #### Output @@ -1466,6 +1541,8 @@ Rerequest a deployment check | `deploymentId` | string | Yes | Deployment ID the check belongs to | | `checkId` | string | Yes | Check ID to rerequest | | `teamId` | string | No | Team ID to scope the request | +| `slug` | string | No | Team slug to scope the request \(alternative to teamId\) | +| `autoUpdate` | boolean | No | Whether to mark the check as running immediately on rerequest | #### Output diff --git a/apps/docs/content/docs/en/integrations/wordpress.mdx b/apps/docs/content/docs/en/integrations/wordpress.mdx index 32bdf1606ed..432851c9a02 100644 --- a/apps/docs/content/docs/en/integrations/wordpress.mdx +++ b/apps/docs/content/docs/en/integrations/wordpress.mdx @@ -29,7 +29,7 @@ In Sim, the WordPress integration enables your agents to automate content publis ## Usage Instructions -Integrate with WordPress to create, update, and manage posts, pages, media, comments, categories, tags, and users. Supports WordPress.com sites via OAuth and self-hosted WordPress sites using Application Passwords authentication. +Integrate with WordPress.com to create, update, and manage posts, pages, media, comments, categories, tags, and users. Connects to WordPress.com sites via OAuth. @@ -507,7 +507,6 @@ Delete a media item from WordPress.com | --------- | ---- | -------- | ----------- | | `siteId` | string | Yes | WordPress.com site ID or domain \(e.g., 12345678 or mysite.wordpress.com\) | | `mediaId` | number | Yes | The ID of the media item to delete | -| `force` | boolean | No | Force delete \(media has no trash, so deletion is permanent\) | #### Output @@ -715,6 +714,86 @@ List categories from WordPress.com | `total` | number | Total number of categories | | `totalPages` | number | Total number of result pages | +### `wordpress_get_category` + +Get a single category from WordPress.com by ID + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `siteId` | string | Yes | WordPress.com site ID or domain \(e.g., 12345678 or mysite.wordpress.com\) | +| `categoryId` | number | Yes | The ID of the category to retrieve | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `category` | object | The retrieved category | +| ↳ `id` | number | Category ID | +| ↳ `count` | number | Number of posts in this category | +| ↳ `description` | string | Category description | +| ↳ `link` | string | Category archive URL | +| ↳ `name` | string | Category name | +| ↳ `slug` | string | Category slug | +| ↳ `taxonomy` | string | Taxonomy name | +| ↳ `parent` | number | Parent category ID | + +### `wordpress_update_category` + +Update an existing category in WordPress.com + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `siteId` | string | Yes | WordPress.com site ID or domain \(e.g., 12345678 or mysite.wordpress.com\) | +| `categoryId` | number | Yes | The ID of the category to update | +| `name` | string | No | Category name | +| `description` | string | No | Category description | +| `parent` | number | No | Parent category ID for hierarchical categories | +| `slug` | string | No | URL slug for the category | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `category` | object | The updated category | +| ↳ `id` | number | Category ID | +| ↳ `count` | number | Number of posts in this category | +| ↳ `description` | string | Category description | +| ↳ `link` | string | Category archive URL | +| ↳ `name` | string | Category name | +| ↳ `slug` | string | Category slug | +| ↳ `taxonomy` | string | Taxonomy name | +| ↳ `parent` | number | Parent category ID | + +### `wordpress_delete_category` + +Delete a category from WordPress.com + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `siteId` | string | Yes | WordPress.com site ID or domain \(e.g., 12345678 or mysite.wordpress.com\) | +| `categoryId` | number | Yes | The ID of the category to delete | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `deleted` | boolean | Whether the category was deleted | +| `category` | object | The deleted category | +| ↳ `id` | number | Category ID | +| ↳ `count` | number | Number of posts in this category | +| ↳ `description` | string | Category description | +| ↳ `link` | string | Category archive URL | +| ↳ `name` | string | Category name | +| ↳ `slug` | string | Category slug | +| ↳ `taxonomy` | string | Taxonomy name | +| ↳ `parent` | number | Parent category ID | + ### `wordpress_create_tag` Create a new tag in WordPress.com @@ -770,6 +849,82 @@ List tags from WordPress.com | `total` | number | Total number of tags | | `totalPages` | number | Total number of result pages | +### `wordpress_get_tag` + +Get a single tag from WordPress.com by ID + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `siteId` | string | Yes | WordPress.com site ID or domain \(e.g., 12345678 or mysite.wordpress.com\) | +| `tagId` | number | Yes | The ID of the tag to retrieve | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `tag` | object | The retrieved tag | +| ↳ `id` | number | Tag ID | +| ↳ `count` | number | Number of posts with this tag | +| ↳ `description` | string | Tag description | +| ↳ `link` | string | Tag archive URL | +| ↳ `name` | string | Tag name | +| ↳ `slug` | string | Tag slug | +| ↳ `taxonomy` | string | Taxonomy name | + +### `wordpress_update_tag` + +Update an existing tag in WordPress.com + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `siteId` | string | Yes | WordPress.com site ID or domain \(e.g., 12345678 or mysite.wordpress.com\) | +| `tagId` | number | Yes | The ID of the tag to update | +| `name` | string | No | Tag name | +| `description` | string | No | Tag description | +| `slug` | string | No | URL slug for the tag | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `tag` | object | The updated tag | +| ↳ `id` | number | Tag ID | +| ↳ `count` | number | Number of posts with this tag | +| ↳ `description` | string | Tag description | +| ↳ `link` | string | Tag archive URL | +| ↳ `name` | string | Tag name | +| ↳ `slug` | string | Tag slug | +| ↳ `taxonomy` | string | Taxonomy name | + +### `wordpress_delete_tag` + +Delete a tag from WordPress.com + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `siteId` | string | Yes | WordPress.com site ID or domain \(e.g., 12345678 or mysite.wordpress.com\) | +| `tagId` | number | Yes | The ID of the tag to delete | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `deleted` | boolean | Whether the tag was deleted | +| `tag` | object | The deleted tag | +| ↳ `id` | number | Tag ID | +| ↳ `count` | number | Number of posts with this tag | +| ↳ `description` | string | Tag description | +| ↳ `link` | string | Tag archive URL | +| ↳ `name` | string | Tag name | +| ↳ `slug` | string | Tag slug | +| ↳ `taxonomy` | string | Taxonomy name | + ### `wordpress_get_current_user` Get information about the currently authenticated WordPress.com user @@ -874,8 +1029,8 @@ Search across all content types in WordPress.com (posts, pages, media) | `query` | string | Yes | Search query | | `perPage` | number | No | Number of results per request \(default: 10, max: 100\) | | `page` | number | No | Page number for pagination | -| `type` | string | No | Filter by content type: post, page, attachment | -| `subtype` | string | No | Filter by post type slug \(e.g., post, page\) | +| `type` | string | No | Filter by search index type: post, term, or post-format | +| `subtype` | string | No | Filter by subtype within the selected type \(e.g., post or page when type is post\) | #### Output @@ -885,8 +1040,8 @@ Search across all content types in WordPress.com (posts, pages, media) | ↳ `id` | number | Content ID | | ↳ `title` | string | Content title | | ↳ `url` | string | Content URL | -| ↳ `type` | string | Content type \(post, page, attachment\) | -| ↳ `subtype` | string | Post type slug | +| ↳ `type` | string | Content type \(post, term, or post-format\) | +| ↳ `subtype` | string | Subtype within the content type \(e.g., post, page\) | | `total` | number | Total number of results | | `totalPages` | number | Total number of result pages | diff --git a/apps/sim/blocks/blocks/google_appsheet.ts b/apps/sim/blocks/blocks/google_appsheet.ts new file mode 100644 index 00000000000..85a11e99236 --- /dev/null +++ b/apps/sim/blocks/blocks/google_appsheet.ts @@ -0,0 +1,277 @@ +import { GoogleAppsheetIcon } from '@/components/icons' +import type { BlockConfig, BlockMeta } from '@/blocks/types' +import { AuthMode, IntegrationType } from '@/blocks/types' +import type { GoogleAppsheetResponse } from '@/tools/google_appsheet/types' + +export const GoogleAppsheetBlock: BlockConfig = { + type: 'google_appsheet', + name: 'Google AppSheet', + description: 'Read, add, edit, and delete rows in a Google AppSheet table', + authMode: AuthMode.ApiKey, + longDescription: + 'Integrate Google AppSheet into your workflow. Find, add, edit, and delete rows in an AppSheet table using the AppSheet API. Requires an AppSheet Enterprise plan with the API enabled and an Application Access Key.', + docsLink: 'https://docs.sim.ai/integrations/google_appsheet', + category: 'tools', + integrationType: IntegrationType.Databases, + bgColor: '#FFFFFF', + icon: GoogleAppsheetIcon, + + subBlocks: [ + { + id: 'operation', + title: 'Operation', + type: 'dropdown', + options: [ + { label: 'Find Rows', id: 'google_appsheet_find_rows' }, + { label: 'Add Rows', id: 'google_appsheet_add_rows' }, + { label: 'Edit Rows', id: 'google_appsheet_edit_rows' }, + { label: 'Delete Rows', id: 'google_appsheet_delete_rows' }, + ], + value: () => 'google_appsheet_find_rows', + }, + { + id: 'appId', + title: 'App ID', + type: 'short-input', + placeholder: 'App > Settings > Integrations > IN', + required: true, + }, + { + id: 'tableName', + title: 'Table Name', + type: 'short-input', + placeholder: 'e.g. Orders', + required: true, + }, + // Find Rows operation inputs + { + id: 'selector', + title: 'Selector', + type: 'long-input', + placeholder: + 'Optional expression, e.g. Filter(Orders, [Status] = "Open") or Top(OrderBy(Filter(Orders, true), [Date], true), 10)', + condition: { field: 'operation', value: 'google_appsheet_find_rows' }, + mode: 'advanced', + wandConfig: { + enabled: true, + prompt: `Generate an AppSheet Selector expression based on the user's description. The table name in the expression is a placeholder - use the literal word matching the table being queried. + +Format examples: +- Filter(TableName, [Status] = "Open") +- Filter(TableName, AND([Age] >= 21, [State] = "CA")) +- OrderBy(Filter(TableName, true), [LastName], true) +- Top(OrderBy(Filter(TableName, true), [Date], true), 10) + +Return ONLY the Selector expression - no explanations, no quotes around the entire expression.`, + placeholder: 'Describe the filter/sort criteria (e.g., "open orders sorted by date")...', + }, + }, + // Add/Edit/Delete Rows operation inputs (shared JSON array field) + { + id: 'rows', + title: 'Rows (JSON Array)', + type: 'code', + language: 'json', + placeholder: 'For Add: `[{ "FirstName": "Jan", "LastName": "Jones" }]`', + condition: { + field: 'operation', + value: [ + 'google_appsheet_add_rows', + 'google_appsheet_edit_rows', + 'google_appsheet_delete_rows', + ], + }, + required: { + field: 'operation', + value: [ + 'google_appsheet_add_rows', + 'google_appsheet_edit_rows', + 'google_appsheet_delete_rows', + ], + }, + wandConfig: { + enabled: true, + prompt: `Generate an AppSheet rows JSON array based on the user's description. +Each element is an object mapping column names to values. + +Current rows: {context} + +For Add, provide the columns for each new row: +[{ "FirstName": "Jan", "LastName": "Jones" }] + +For Edit, include the key column plus the columns to change: +[{ "RowID": "123", "Status": "Done" }] + +For Delete, include only the key column: +[{ "RowID": "123" }] + +Return ONLY the valid JSON array - no explanations, no markdown.`, + placeholder: 'Describe the rows to add, edit, or delete...', + }, + }, + { + id: 'region', + title: 'Region', + type: 'dropdown', + options: [ + { label: 'Global (www)', id: 'www' }, + { label: 'Europe (eu)', id: 'eu' }, + { label: 'Asia Pacific (asia-southeast)', id: 'asia-southeast' }, + ], + value: () => 'www', + mode: 'advanced', + }, + // API Key (common to all operations) + { + id: 'apiKey', + title: 'Application Access Key', + type: 'short-input', + placeholder: 'Enter your AppSheet Application Access Key', + password: true, + required: true, + }, + ], + + tools: { + access: [ + 'google_appsheet_find_rows', + 'google_appsheet_add_rows', + 'google_appsheet_edit_rows', + 'google_appsheet_delete_rows', + ], + config: { + tool: (params) => params.operation, + params: (params) => { + const { rows, ...rest } = params + const result: Record = { ...rest } + if (params.operation !== 'google_appsheet_find_rows' && rows) { + let parsedRows: unknown + try { + parsedRows = typeof rows === 'string' ? JSON.parse(rows) : rows + } catch (error: any) { + throw new Error(`Invalid JSON in Rows field: ${error.message}`) + } + if (!Array.isArray(parsedRows)) { + throw new Error('Rows must be a JSON array of row objects, e.g. [{ "RowID": "123" }]') + } + result.rows = parsedRows + } + return result + }, + }, + }, + + inputs: { + operation: { type: 'string', description: 'Operation to perform' }, + appId: { type: 'string', description: 'AppSheet app ID' }, + tableName: { type: 'string', description: 'Name of the table to operate on' }, + region: { type: 'string', description: 'AppSheet region subdomain' }, + apiKey: { type: 'string', description: 'AppSheet Application Access Key' }, + selector: { type: 'string', description: 'Optional AppSheet Selector expression' }, + rows: { type: 'json', description: 'Array of row objects for the operation' }, + }, + + outputs: { + rows: { + type: 'json', + description: 'Rows returned by the AppSheet operation: [{ columnName: value, ... }]', + }, + metadata: { type: 'json', description: 'Operation metadata: { rowCount: number }' }, + }, +} + +export const GoogleAppsheetBlockMeta = { + tags: ['spreadsheet', 'automation', 'google-workspace'], + url: 'https://about.appsheet.com', + templates: [ + { + icon: GoogleAppsheetIcon, + title: 'AppSheet order intake', + prompt: + 'Build a workflow triggered by a form submission that adds a new row to an AppSheet Orders table, then posts a confirmation message to Slack with the order details.', + modules: ['agent', 'workflows'], + category: 'operations', + tags: ['automation'], + alsoIntegrations: ['slack'], + }, + { + icon: GoogleAppsheetIcon, + title: 'AppSheet daily status digest', + prompt: + 'Create a scheduled workflow that runs daily, finds all AppSheet rows where Status is "Open", summarizes them with an agent, and emails the summary to the operations team.', + modules: ['scheduled', 'agent', 'workflows'], + category: 'operations', + tags: ['automation', 'monitoring'], + alsoIntegrations: ['gmail'], + }, + { + icon: GoogleAppsheetIcon, + title: 'AppSheet inventory sync', + prompt: + 'Build a workflow that reads updated rows from an AppSheet Inventory table, transforms the quantities with an agent, and writes the reconciled totals into a Google Sheet.', + modules: ['agent', 'workflows'], + category: 'operations', + tags: ['automation', 'analysis'], + alsoIntegrations: ['google_sheets'], + }, + { + icon: GoogleAppsheetIcon, + title: 'AppSheet ticket escalation', + prompt: + 'Build a workflow that finds AppSheet rows where Priority is "High" and Status is not "Resolved", and edits each row to add an Escalated flag, then creates a Linear issue for each one.', + modules: ['agent', 'workflows'], + category: 'support', + tags: ['automation', 'ticketing'], + alsoIntegrations: ['linear'], + }, + { + icon: GoogleAppsheetIcon, + title: 'AppSheet cleanup job', + prompt: + 'Create a scheduled workflow that finds AppSheet rows older than 90 days with Status "Archived" and deletes them, then logs a summary of how many rows were removed to a table.', + modules: ['scheduled', 'tables', 'workflows'], + category: 'operations', + tags: ['automation'], + }, + { + icon: GoogleAppsheetIcon, + title: 'AppSheet lead router', + prompt: + 'Build a workflow that finds new AppSheet rows in a Leads table, uses an agent to classify each lead by region, and edits the row to assign the correct sales rep.', + modules: ['agent', 'workflows'], + category: 'sales', + tags: ['automation', 'crm'], + }, + { + icon: GoogleAppsheetIcon, + title: 'AppSheet field service report', + prompt: + 'Build a workflow that finds all AppSheet rows completed today in a Work Orders table, generates a summary report with an agent, and saves it as a file for the team.', + modules: ['agent', 'files', 'workflows'], + category: 'operations', + tags: ['automation', 'analysis'], + }, + ], + skills: [ + { + name: 'add-appsheet-row', + description: 'Add a new row to an AppSheet table in response to an external event.', + content: + '# Add AppSheet Row\n\nCreate a new row in an AppSheet table, e.g. when a form is submitted or an external event fires.\n\n## Steps\n1. Set App ID and Table Name for the target table.\n2. Provide the Rows JSON array with one object per new row, e.g. `[{ "FirstName": "Jan", "LastName": "Jones" }]`.\n3. Give the key column an explicit value, or omit it if its Initial value expression (e.g. UNIQUEID()) generates it automatically.\n4. Run the Add Rows operation and confirm the returned row includes the generated key.\n\n## Output\nThe newly created row(s), including any generated key values, plus a row count.', + }, + { + name: 'find-appsheet-rows', + description: + 'Query an AppSheet table with a Selector expression to filter, sort, or limit rows.', + content: + '# Find AppSheet Rows\n\nRead rows from an AppSheet table, optionally filtered and sorted with a Selector expression.\n\n## Steps\n1. Set App ID and Table Name for the target table.\n2. Leave Selector blank to return every row, or provide an expression such as `Filter(TableName, [Status] = "Open")`.\n3. Combine `OrderBy()` and `Top()` to sort and limit results, e.g. `Top(OrderBy(Filter(TableName, true), [Date], true), 10)`.\n4. Run the Find Rows operation.\n\n## Output\nThe matching rows and a row count, ready to feed into an agent or a downstream integration.', + }, + { + name: 'sync-appsheet-updates-to-sheet', + description: + 'Mirror updated AppSheet rows into a Google Sheet to maintain a real-time audit trail.', + content: + '# Sync AppSheet Updates to a Sheet\n\nKeep a Google Sheet in sync with changes to an AppSheet table, mirroring the pattern used in AppSheet-Zapier integrations for audit trails.\n\n## Steps\n1. Use Find Rows with a Selector expression that isolates recently changed rows (e.g. filtered by a LastModified column).\n2. For each row, append or update the corresponding row in a Google Sheet.\n3. Schedule the workflow to run on an interval so the sheet stays current.\n\n## Output\nA Google Sheet that reflects the latest AppSheet row data, useful as a shareable audit trail or reporting source.', + }, + ], +} as const satisfies BlockMeta diff --git a/apps/sim/blocks/registry-maps.ts b/apps/sim/blocks/registry-maps.ts index 801cd3c2abf..fd1d8de138a 100644 --- a/apps/sim/blocks/registry-maps.ts +++ b/apps/sim/blocks/registry-maps.ts @@ -92,6 +92,7 @@ import { GmailBlock, GmailBlockMeta, GmailV2Block, GmailV2BlockMeta } from '@/bl import { GongBlock, GongBlockMeta } from '@/blocks/blocks/gong' import { GoogleSearchBlock, GoogleSearchBlockMeta } from '@/blocks/blocks/google' import { GoogleAdsBlock, GoogleAdsBlockMeta } from '@/blocks/blocks/google_ads' +import { GoogleAppsheetBlock, GoogleAppsheetBlockMeta } from '@/blocks/blocks/google_appsheet' import { GoogleBigQueryBlock, GoogleBigQueryBlockMeta } from '@/blocks/blocks/google_bigquery' import { GoogleBooksBlock, GoogleBooksBlockMeta } from '@/blocks/blocks/google_books' import { @@ -429,6 +430,7 @@ export const BLOCK_REGISTRY: Record = { gmail_v2: GmailV2Block, gong: GongBlock, google_ads: GoogleAdsBlock, + google_appsheet: GoogleAppsheetBlock, google_bigquery: GoogleBigQueryBlock, google_books: GoogleBooksBlock, google_calendar: GoogleCalendarBlock, @@ -725,6 +727,7 @@ export const BLOCK_META_REGISTRY: Record = { gmail_v2: GmailV2BlockMeta, gong: GongBlockMeta, google_ads: GoogleAdsBlockMeta, + google_appsheet: GoogleAppsheetBlockMeta, google_bigquery: GoogleBigQueryBlockMeta, google_books: GoogleBooksBlockMeta, google_calendar: GoogleCalendarBlockMeta, diff --git a/apps/sim/components/icons.tsx b/apps/sim/components/icons.tsx index 2bc753071f2..00db61dd498 100644 --- a/apps/sim/components/icons.tsx +++ b/apps/sim/components/icons.tsx @@ -1742,6 +1742,21 @@ export function AmplitudeIcon(props: SVGProps) { ) } +export function GoogleAppsheetIcon(props: SVGProps) { + return ( + + + + + + + + ) +} + export function GoogleBooksIcon(props: SVGProps) { return ( diff --git a/apps/sim/lib/integrations/icon-mapping.ts b/apps/sim/lib/integrations/icon-mapping.ts index fbb302ad9f1..e9df1348307 100644 --- a/apps/sim/lib/integrations/icon-mapping.ts +++ b/apps/sim/lib/integrations/icon-mapping.ts @@ -77,6 +77,7 @@ import { GmailIcon, GongIcon, GoogleAdsIcon, + GoogleAppsheetIcon, GoogleBigQueryIcon, GoogleBooksIcon, GoogleCalendarIcon, @@ -310,6 +311,7 @@ export const blockTypeToIconMap: Record = { gmail_v2: GmailIcon, gong: GongIcon, google_ads: GoogleAdsIcon, + google_appsheet: GoogleAppsheetIcon, google_bigquery: GoogleBigQueryIcon, google_books: GoogleBooksIcon, google_calendar_v2: GoogleCalendarIcon, diff --git a/apps/sim/lib/integrations/integrations.json b/apps/sim/lib/integrations/integrations.json index f542ae6daea..449e5c883d8 100644 --- a/apps/sim/lib/integrations/integrations.json +++ b/apps/sim/lib/integrations/integrations.json @@ -6,7 +6,7 @@ "slug": "1password", "name": "1Password", "description": "Manage secrets and items in 1Password vaults", - "longDescription": "Access and manage secrets stored in 1Password vaults using the Connect API or Service Account SDK. List vaults, retrieve items with their fields and secrets, create new items, update existing ones, delete items, and resolve secret references.", + "longDescription": "Access and manage secrets stored in 1Password vaults using the Connect API or Service Account SDK. List vaults, retrieve items with their fields and secrets, download attached files, create new items, update existing ones, delete items, and resolve secret references.", "bgColor": "#FFFFFF", "iconName": "OnePasswordIcon", "docsUrl": "https://docs.sim.ai/integrations/onepassword", @@ -27,6 +27,10 @@ "name": "Get Item", "description": "Get full details of an item including all fields and secrets" }, + { + "name": "Get Item File", + "description": "Download the content of a file attached to an item" + }, { "name": "Create Item", "description": "Create a new item in a vault" @@ -48,7 +52,7 @@ "description": "Resolve a secret reference (op://vault/item/field) to its value. Service Account mode only." } ], - "operationCount": 9, + "operationCount": 10, "triggers": [], "triggerCount": 0, "authType": "api-key", @@ -345,28 +349,40 @@ "longDescription": "Integrate Ahrefs SEO tools into your workflow. Analyze domain ratings, backlinks, organic keywords, top pages, and more. Requires an Ahrefs Enterprise plan with API access.", "bgColor": "#FFFFFF", "iconName": "AhrefsIcon", - "docsUrl": "https://docs.ahrefs.com/docs/api/reference/introduction", + "docsUrl": "https://docs.sim.ai/integrations/ahrefs", "operations": [ { "name": "Domain Rating", "description": "Get the Domain Rating (DR) and Ahrefs Rank for a target domain. Domain Rating shows the strength of a website's backlink profile on a scale from 0 to 100." }, + { + "name": "Metrics Overview", + "description": "Get a one-call organic and paid search overview for a target domain or URL: organic traffic, organic keywords, paid traffic, paid keywords, and estimated traffic cost." + }, { "name": "Backlinks", "description": "Get a list of backlinks pointing to a target domain or URL. Returns details about each backlink including source URL, anchor text, and domain rating." }, { "name": "Backlinks Stats", - "description": "Get backlink statistics for a target domain or URL. Returns totals for different backlink types including dofollow, nofollow, text, image, and redirect links." + "description": "Get backlink and referring domain totals for a target domain or URL, both currently live and across all time." }, { "name": "Referring Domains", "description": "Get a list of domains that link to a target domain or URL. Returns unique referring domains with their domain rating, backlink counts, and discovery dates." }, + { + "name": "Broken Backlinks", + "description": "Get a list of broken backlinks pointing to a target domain or URL. Useful for identifying link reclamation opportunities." + }, { "name": "Organic Keywords", "description": "Get organic keywords that a target domain or URL ranks for in Google search results. Returns keyword details including search volume, ranking position, and estimated traffic." }, + { + "name": "Organic Competitors", + "description": "Get domains that compete with a target domain or URL for the same organic keywords, ranked by keyword overlap." + }, { "name": "Top Pages", "description": "Get the top pages of a target domain sorted by organic traffic. Returns page URLs with their traffic, keyword counts, and estimated traffic value." @@ -374,13 +390,9 @@ { "name": "Keyword Overview", "description": "Get detailed metrics for a keyword including search volume, keyword difficulty, CPC, clicks, and traffic potential." - }, - { - "name": "Broken Backlinks", - "description": "Get a list of broken backlinks pointing to a target domain or URL. Useful for identifying link reclamation opportunities." } ], - "operationCount": 8, + "operationCount": 10, "triggers": [], "triggerCount": 0, "authType": "api-key", @@ -673,8 +685,8 @@ "slug": "amplitude", "name": "Amplitude", "description": "Track events and query analytics from Amplitude", - "longDescription": "Integrate Amplitude into your workflow to track events, identify users and groups, search for users, query analytics, and retrieve revenue data.", - "bgColor": "#1B1F3B", + "longDescription": "Integrate Amplitude into your workflow to track events, identify users and groups, search for users, query analytics, analyze funnels and retention, and retrieve revenue data.", + "bgColor": "#13294B", "iconName": "AmplitudeIcon", "docsUrl": "https://docs.sim.ai/integrations/amplitude", "operations": [ @@ -700,7 +712,7 @@ }, { "name": "User Profile", - "description": "Get a user profile including properties, cohort memberships, and computed properties." + "description": "Get a user profile including properties, cohort memberships, and computed properties. Not available for EU data-residency projects." }, { "name": "Event Segmentation", @@ -721,9 +733,17 @@ { "name": "Get Revenue", "description": "Get revenue LTV data including ARPU, ARPPU, total revenue, and paying user counts." + }, + { + "name": "Funnels", + "description": "Analyze conversion rates and drop-off between a sequence of events." + }, + { + "name": "Retention", + "description": "Measure how many users return to perform an action after a starting action." } ], - "operationCount": 11, + "operationCount": 13, "triggers": [], "triggerCount": 0, "authType": "api-key", @@ -2651,7 +2671,7 @@ "slug": "clerk", "name": "Clerk", "description": "Manage users, organizations, and sessions in Clerk", - "longDescription": "Integrate Clerk authentication and user management into your workflow. Create, update, delete, and list users. Manage organizations and their memberships. Monitor and control user sessions.", + "longDescription": "Integrate Clerk authentication and user management into your workflow. Create, update, delete, ban, lock, and list users. Manage organizations, their memberships, and invitations. Monitor and control user sessions. Maintain allowlist/blocklist identifiers, JWT templates, and actor tokens.", "bgColor": "#131316", "iconName": "ClerkIcon", "docsUrl": "https://docs.sim.ai/integrations/clerk", @@ -2676,6 +2696,26 @@ "name": "Delete User", "description": "Delete a user from your Clerk application" }, + { + "name": "Ban User", + "description": "Ban a user, preventing them from signing in" + }, + { + "name": "Unban User", + "description": "Remove a ban from a user, allowing them to sign in again" + }, + { + "name": "Lock User", + "description": "Lock a user account, blocking sign-in attempts" + }, + { + "name": "Unlock User", + "description": "Unlock a previously locked user account" + }, + { + "name": "Get User OAuth Token", + "description": "Retrieve a user's OAuth access token for a connected external provider (e.g. Google, GitHub, Microsoft) obtained via Clerk SSO" + }, { "name": "List Organizations", "description": "List all organizations in your Clerk application with optional filtering" @@ -2688,6 +2728,38 @@ "name": "Create Organization", "description": "Create a new organization in your Clerk application" }, + { + "name": "Update Organization", + "description": "Update an existing organization in your Clerk application" + }, + { + "name": "Delete Organization", + "description": "Delete an organization from your Clerk application" + }, + { + "name": "List Organization Memberships", + "description": "List members of a Clerk organization with optional filtering and pagination" + }, + { + "name": "Add Organization Member", + "description": "Add a user as a member of a Clerk organization with a given role" + }, + { + "name": "Update Organization Membership", + "description": "Change a member's role within a Clerk organization" + }, + { + "name": "Remove Organization Member", + "description": "Remove a member from a Clerk organization" + }, + { + "name": "Create Organization Invitation", + "description": "Invite a user by email to join a Clerk organization with a given role" + }, + { + "name": "List Organization Invitations", + "description": "List pending and past invitations for a Clerk organization" + }, { "name": "List Sessions", "description": "List sessions for a user or client in your Clerk application" @@ -2699,9 +2771,49 @@ { "name": "Revoke Session", "description": "Revoke a session to immediately invalidate it" + }, + { + "name": "List Allowlist Identifiers", + "description": "List email/phone/web3-wallet identifiers on your Clerk instance allowlist" + }, + { + "name": "Create Allowlist Identifier", + "description": "Add an email, phone number, or web3 wallet to your Clerk instance allowlist" + }, + { + "name": "Delete Allowlist Identifier", + "description": "Remove an identifier from your Clerk instance allowlist" + }, + { + "name": "List Blocklist Identifiers", + "description": "List email/phone/web3-wallet identifiers on your Clerk instance blocklist" + }, + { + "name": "Create Blocklist Identifier", + "description": "Add an email, phone number, or web3 wallet to your Clerk instance blocklist to prevent sign-ups" + }, + { + "name": "Delete Blocklist Identifier", + "description": "Remove an identifier from your Clerk instance blocklist" + }, + { + "name": "List JWT Templates", + "description": "List custom JWT templates configured on your Clerk instance" + }, + { + "name": "Get JWT Template", + "description": "Retrieve a single custom JWT template by ID from Clerk" + }, + { + "name": "Create Actor Token", + "description": "Create an actor token to impersonate a user (God Mode / act-as-user), e.g. for support tooling" + }, + { + "name": "Revoke Actor Token", + "description": "Revoke an actor token before it is used or expires" } ], - "operationCount": 11, + "operationCount": 34, "triggers": [ { "id": "clerk_user_created", @@ -2723,23 +2835,58 @@ "name": "Clerk Session Created", "description": "Trigger workflow when a Clerk session is created" }, + { + "id": "clerk_session_ended", + "name": "Clerk Session Ended", + "description": "Trigger workflow when a Clerk session ends" + }, + { + "id": "clerk_session_removed", + "name": "Clerk Session Removed", + "description": "Trigger workflow when a Clerk session is removed" + }, + { + "id": "clerk_session_revoked", + "name": "Clerk Session Revoked", + "description": "Trigger workflow when a Clerk session is revoked" + }, { "id": "clerk_organization_created", "name": "Clerk Organization Created", "description": "Trigger workflow when a Clerk organization is created" }, + { + "id": "clerk_organization_updated", + "name": "Clerk Organization Updated", + "description": "Trigger workflow when a Clerk organization is updated" + }, + { + "id": "clerk_organization_deleted", + "name": "Clerk Organization Deleted", + "description": "Trigger workflow when a Clerk organization is deleted" + }, { "id": "clerk_organization_membership_created", "name": "Clerk Organization Membership Created", "description": "Trigger workflow when a Clerk organization membership is created" }, + { + "id": "clerk_organization_membership_updated", + "name": "Clerk Organization Membership Updated", + "description": "Trigger workflow when a Clerk organization membership is updated" + }, + { + "id": "clerk_organization_membership_deleted", + "name": "Clerk Organization Membership Deleted", + "description": "Trigger workflow when a Clerk organization membership is deleted" + }, { "id": "clerk_webhook", "name": "Clerk Webhook", "description": "Trigger workflow on any Clerk webhook event" } ], - "triggerCount": 7, + "triggerCount": 14, "authType": "none", "category": "tools", "integrationType": "security", @@ -6105,6 +6252,14 @@ "name": "List Flows", "description": "List Gong Engage flows (sales engagement sequences)." }, + { + "name": "Assign Flow Prospects", + "description": "Assign up to 200 CRM prospects (contacts or leads) to a Gong Engage flow." + }, + { + "name": "Get Prospect Flows", + "description": "Get the Gong Engage flows currently assigned to the given CRM prospects." + }, { "name": "Get Coaching", "description": "Retrieve coaching metrics for a manager from Gong." @@ -6116,9 +6271,17 @@ { "name": "Lookup Phone", "description": "Find all references to a phone number in Gong (calls, email messages, meetings, CRM data, and associated contacts)." + }, + { + "name": "Purge Email Address", + "description": "Erase all Gong data (calls, email messages, leads, contacts) referencing an email address. Asynchronous and irreversible." + }, + { + "name": "Purge Phone Number", + "description": "Erase all Gong data (calls, leads, contacts) referencing a phone number. Asynchronous and irreversible." } ], - "operationCount": 21, + "operationCount": 25, "triggers": [ { "id": "gong_webhook", @@ -6181,6 +6344,41 @@ "integrationType": "analytics", "tags": ["marketing", "google-workspace", "data-analytics"] }, + { + "type": "google_appsheet", + "slug": "google-appsheet", + "name": "Google AppSheet", + "description": "Read, add, edit, and delete rows in a Google AppSheet table", + "longDescription": "Integrate Google AppSheet into your workflow. Find, add, edit, and delete rows in an AppSheet table using the AppSheet API. Requires an AppSheet Enterprise plan with the API enabled and an Application Access Key.", + "bgColor": "#FFFFFF", + "iconName": "GoogleAppsheetIcon", + "docsUrl": "https://docs.sim.ai/integrations/google_appsheet", + "operations": [ + { + "name": "Find Rows", + "description": "Read rows from an AppSheet table. Omit the selector to return every row, or provide a Selector expression (Filter/Select/OrderBy/Top) to narrow and shape the results." + }, + { + "name": "Add Rows", + "description": "Add new rows to an AppSheet table. The key column value must be provided explicitly, or omitted when its Initial value expression generates it automatically (e.g. UNIQUEID())." + }, + { + "name": "Edit Rows", + "description": "Update existing rows in an AppSheet table. Each row must explicitly include the key column name and value, plus any columns to change." + }, + { + "name": "Delete Rows", + "description": "Delete rows from an AppSheet table. Each row only needs to include the key column name and value." + } + ], + "operationCount": 4, + "triggers": [], + "triggerCount": 0, + "authType": "api-key", + "category": "tools", + "integrationType": "databases", + "tags": ["spreadsheet", "automation", "google-workspace"] + }, { "type": "google_bigquery", "slug": "google-bigquery", @@ -7704,7 +7902,7 @@ "slug": "hex", "name": "Hex", "description": "Run and manage Hex projects", - "longDescription": "Integrate Hex into your workflow. Run projects, check run status, manage collections and groups, list users, and view data connections. Requires a Hex API token.", + "longDescription": "Integrate Hex into your workflow. Run projects, check run status, manage collections and groups (including membership and deactivating users), list users, and view data connections. Requires a Hex API token.", "bgColor": "#14151A", "iconName": "HexIcon", "docsUrl": "https://docs.sim.ai/integrations/hex", @@ -7765,6 +7963,10 @@ "name": "Create Collection", "description": "Create a new collection in the Hex workspace to organize projects." }, + { + "name": "Update Collection", + "description": "Update the name or description of an existing Hex collection." + }, { "name": "List Data Connections", "description": "List all data connections in the Hex workspace (e.g., Snowflake, PostgreSQL, BigQuery)." @@ -7772,9 +7974,25 @@ { "name": "Get Data Connection", "description": "Retrieve details for a specific data connection including type, description, and configuration flags." + }, + { + "name": "Create Group", + "description": "Create a new group in the Hex workspace, optionally with initial members." + }, + { + "name": "Update Group", + "description": "Rename a Hex group or add/remove members from it." + }, + { + "name": "Delete Group", + "description": "Delete a group from the Hex workspace." + }, + { + "name": "Deactivate User", + "description": "Deactivate a user in the Hex workspace." } ], - "operationCount": 16, + "operationCount": 21, "triggers": [], "triggerCount": 0, "authType": "api-key", @@ -9281,9 +9499,21 @@ { "name": "Create Runs Batch", "description": "Forward multiple runs to LangSmith in a single batch." + }, + { + "name": "Update Run", + "description": "Patch an existing LangSmith run with outputs, status, or timing once it completes." + }, + { + "name": "Get Run", + "description": "Retrieve a single LangSmith run by ID." + }, + { + "name": "Create Feedback", + "description": "Attach a score, correction, or comment to a LangSmith run." } ], - "operationCount": 2, + "operationCount": 5, "triggers": [], "triggerCount": 0, "authType": "api-key", @@ -10205,7 +10435,7 @@ }, { "name": "List Transactional Emails", - "description": "Retrieve a list of published transactional email templates from your Loops account. Returns each template with its ID, name, last updated timestamp, and data variables." + "description": "Retrieve a list of published transactional email templates from your Loops account. Returns each template with its ID, name, created/updated timestamps, and data variables." }, { "name": "Create Contact Property", @@ -10214,9 +10444,21 @@ { "name": "List Contact Properties", "description": "Retrieve a list of contact properties from your Loops account. Returns each property with its key, label, and data type. Can filter to show all properties or only custom ones." + }, + { + "name": "Check Contact Suppression", + "description": "Check whether a Loops contact is on the suppression list (bounced, complained, or unsubscribed) by email address or userId." + }, + { + "name": "Remove Contact Suppression", + "description": "Remove a Loops contact from the suppression list by email address or userId, allowing them to receive emails again. Subject to a team removal quota." + }, + { + "name": "Get Transactional Email", + "description": "Retrieve a single transactional email template from your Loops account by its ID, including its data variables and draft/published message IDs." } ], - "operationCount": 10, + "operationCount": 13, "triggers": [ { "id": "loops_email_delivered", @@ -15283,6 +15525,18 @@ "name": "Read Page", "description": "Read a specific page from a SharePoint site" }, + { + "name": "Update Page", + "description": "Update the title and/or content of a SharePoint page" + }, + { + "name": "Publish Page", + "description": "Publish the latest version of a SharePoint page, making it available to all users" + }, + { + "name": "Delete Page", + "description": "Delete a page from a SharePoint site" + }, { "name": "List Sites", "description": "List details of all SharePoint sites" @@ -15303,12 +15557,32 @@ "name": "Add List Item", "description": "Add a new item to a SharePoint list" }, + { + "name": "Get List Item", + "description": "Get a single item (with field values) from a SharePoint list" + }, + { + "name": "Delete List Item", + "description": "Delete an item from a SharePoint list" + }, { "name": "Upload File", "description": "Upload files to a SharePoint document library" + }, + { + "name": "Download File", + "description": "Download a file from a SharePoint document library" + }, + { + "name": "Get Drive Item", + "description": "Get metadata for a file or folder in a SharePoint document library" + }, + { + "name": "Delete File", + "description": "Delete a file (or folder) from a SharePoint document library" } ], - "operationCount": 8, + "operationCount": 16, "triggers": [], "triggerCount": 0, "authType": "oauth", @@ -15450,9 +15724,13 @@ { "name": "Visit Duration (Desktop)", "description": "Get average desktop visit duration over time (in seconds)" + }, + { + "name": "Page Views", + "description": "Get total page views over time (desktop and mobile combined)" } ], - "operationCount": 5, + "operationCount": 6, "triggers": [], "triggerCount": 0, "authType": "api-key", @@ -17173,7 +17451,7 @@ }, { "name": "Introspect Schema", - "description": "Introspect Supabase database schema to get table structures, columns, and relationships" + "description": "Introspect Supabase database schema from its OpenAPI spec to get table and column structures (best-effort primary/foreign key detection)" }, { "name": "Storage: Upload File", @@ -17207,10 +17485,22 @@ "name": "Storage: Create Signed URL", "description": "Create a temporary signed URL for a file in a Supabase storage bucket" }, + { + "name": "Storage: Create Signed Upload URL", + "description": "Create a temporary signed URL a client can use to upload directly to a Supabase storage bucket" + }, { "name": "Storage: Create Bucket", "description": "Create a new storage bucket in Supabase" }, + { + "name": "Storage: Update Bucket", + "description": "Update the configuration of an existing Supabase storage bucket" + }, + { + "name": "Storage: Empty Bucket", + "description": "Delete all objects inside a Supabase storage bucket without deleting the bucket itself" + }, { "name": "Storage: List Buckets", "description": "List all storage buckets in Supabase" @@ -17220,7 +17510,7 @@ "description": "Delete a storage bucket in Supabase" } ], - "operationCount": 23, + "operationCount": 26, "triggers": [], "triggerCount": 0, "authType": "api-key", @@ -17270,6 +17560,10 @@ "name": "Update Device Key", "description": "Enable or disable key expiry on a device" }, + { + "name": "Expire Device Key", + "description": "Immediately expire a device's node key, requiring it to re-authenticate before it can reconnect to the tailnet" + }, { "name": "List DNS Nameservers", "description": "Get the DNS nameservers configured for the tailnet" @@ -17298,6 +17592,14 @@ "name": "List Users", "description": "List all users in the tailnet" }, + { + "name": "Suspend User", + "description": "Suspend a user's access to the tailnet" + }, + { + "name": "Delete User", + "description": "Delete a user from the tailnet" + }, { "name": "Create Auth Key", "description": "Create a new auth key for the tailnet to pre-authorize devices" @@ -17317,9 +17619,13 @@ { "name": "Get ACL", "description": "Get the current ACL policy for the tailnet" + }, + { + "name": "Set ACL", + "description": "Replace the ACL policy file for the tailnet" } ], - "operationCount": 20, + "operationCount": 24, "triggers": [], "triggerCount": 0, "authType": "api-key", @@ -17816,8 +18122,8 @@ "type": "trello", "slug": "trello", "name": "Trello", - "description": "Manage Trello lists, cards, and activity", - "longDescription": "Integrate with Trello to list board lists, list cards, create cards, update cards, review activity, and add comments.", + "description": "Manage Trello lists, cards, checklists, and activity", + "longDescription": "Integrate with Trello to list, search, create, update, and delete cards and lists, manage checklists and checklist items, assign labels and members, review activity, and add comments.", "bgColor": "#0052CC", "iconName": "TrelloIcon", "docsUrl": "https://docs.sim.ai/integrations/trello", @@ -17830,6 +18136,10 @@ "name": "List Cards", "description": "List cards from a Trello board or list" }, + { + "name": "Search", + "description": "Search Trello cards and boards by keyword" + }, { "name": "Create Card", "description": "Create a new card in a Trello list" @@ -17842,6 +18152,10 @@ "name": "Update Card", "description": "Update an existing card on Trello" }, + { + "name": "Delete Card", + "description": "Permanently delete a Trello card" + }, { "name": "Get Actions", "description": "Get activity/actions from a board or card" @@ -17854,14 +18168,34 @@ "name": "Add Checklist", "description": "Add a checklist to a Trello card" }, + { + "name": "Add Checklist Item", + "description": "Add an item to a Trello checklist" + }, + { + "name": "Update Checklist Item", + "description": "Check off, uncheck, or rename a Trello checklist item" + }, { "name": "Add Label", "description": "Attach an existing label to a Trello card" }, + { + "name": "Remove Label", + "description": "Detach a label from a Trello card" + }, { "name": "Add Member", "description": "Assign a member to a Trello card" }, + { + "name": "Remove Member", + "description": "Unassign a member from a Trello card" + }, + { + "name": "List Members", + "description": "List members of a Trello board" + }, { "name": "Create Board", "description": "Create a new Trello board" @@ -17873,9 +18207,13 @@ { "name": "Create List", "description": "Create a new list on a Trello board" + }, + { + "name": "Update List", + "description": "Rename, move, archive, or reopen a Trello list" } ], - "operationCount": 13, + "operationCount": 21, "triggers": [], "triggerCount": 0, "authType": "oauth", @@ -19048,7 +19386,7 @@ "slug": "wordpress", "name": "WordPress", "description": "Manage WordPress content", - "longDescription": "Integrate with WordPress to create, update, and manage posts, pages, media, comments, categories, tags, and users. Supports WordPress.com sites via OAuth and self-hosted WordPress sites using Application Passwords authentication.", + "longDescription": "Integrate with WordPress.com to create, update, and manage posts, pages, media, comments, categories, tags, and users. Connects to WordPress.com sites via OAuth.", "bgColor": "#21759B", "iconName": "WordpressIcon", "docsUrl": "https://docs.sim.ai/integrations/wordpress", @@ -19129,6 +19467,18 @@ "name": "Create Category", "description": "Create a new category in WordPress.com" }, + { + "name": "Update Category", + "description": "Update an existing category in WordPress.com" + }, + { + "name": "Delete Category", + "description": "Delete a category from WordPress.com" + }, + { + "name": "Get Category", + "description": "Get a single category from WordPress.com by ID" + }, { "name": "List Categories", "description": "List categories from WordPress.com" @@ -19137,6 +19487,18 @@ "name": "Create Tag", "description": "Create a new tag in WordPress.com" }, + { + "name": "Update Tag", + "description": "Update an existing tag in WordPress.com" + }, + { + "name": "Delete Tag", + "description": "Delete a tag from WordPress.com" + }, + { + "name": "Get Tag", + "description": "Get a single tag from WordPress.com by ID" + }, { "name": "List Tags", "description": "List tags from WordPress.com" @@ -19158,7 +19520,7 @@ "description": "Search across all content types in WordPress.com (posts, pages, media)" } ], - "operationCount": 26, + "operationCount": 32, "triggers": [], "triggerCount": 0, "authType": "oauth", diff --git a/apps/sim/tools/google_appsheet/add_rows.ts b/apps/sim/tools/google_appsheet/add_rows.ts new file mode 100644 index 00000000000..817d26d32f6 --- /dev/null +++ b/apps/sim/tools/google_appsheet/add_rows.ts @@ -0,0 +1,102 @@ +import type { + GoogleAppsheetAddParams, + GoogleAppsheetAddResponse, +} from '@/tools/google_appsheet/types' +import { buildAppsheetActionUrl, readAppsheetResponseBody } from '@/tools/google_appsheet/utils' +import type { ToolConfig } from '@/tools/types' + +export const googleAppsheetAddRowsTool: ToolConfig< + GoogleAppsheetAddParams, + GoogleAppsheetAddResponse +> = { + id: 'google_appsheet_add_rows', + name: 'AppSheet Add Rows', + description: + 'Add new rows to an AppSheet table. The key column value must be provided explicitly, or omitted when its Initial value expression generates it automatically (e.g. UNIQUEID()).', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AppSheet Application Access Key', + }, + appId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'AppSheet app ID (found in App > Settings > Integrations > IN)', + }, + tableName: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Name of the table to add rows to', + }, + region: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'AppSheet region subdomain: "www" (global, default), "eu", or "asia-southeast"', + }, + rows: { + type: 'json', + required: true, + visibility: 'user-or-llm', + description: + 'Array of row objects to add, each a column-name/value map, e.g. [{ "FirstName": "Jan", "LastName": "Jones" }]', + }, + }, + + request: { + url: (params) => buildAppsheetActionUrl(params.appId, params.tableName, params.region), + method: 'POST', + headers: (params) => ({ + ApplicationAccessKey: params.apiKey, + 'Content-Type': 'application/json', + }), + body: (params) => ({ + Action: 'Add', + Properties: {}, + Rows: params.rows, + }), + }, + + transformResponse: async (response: Response) => { + const data = await readAppsheetResponseBody(response) + + if (!response.ok) { + throw new Error(data.error?.message || data.message || 'Failed to add AppSheet rows') + } + + const rows = data.Rows ?? data.rows ?? [] + + return { + success: true, + output: { + rows, + metadata: { + rowCount: rows.length, + }, + }, + } + }, + + outputs: { + rows: { + type: 'array', + description: 'Rows added by AppSheet, including any generated key values', + items: { + type: 'object', + }, + }, + metadata: { + type: 'json', + description: 'Operation metadata', + properties: { + rowCount: { type: 'number', description: 'Number of rows added' }, + }, + }, + }, +} diff --git a/apps/sim/tools/google_appsheet/delete_rows.ts b/apps/sim/tools/google_appsheet/delete_rows.ts new file mode 100644 index 00000000000..a9e47343c68 --- /dev/null +++ b/apps/sim/tools/google_appsheet/delete_rows.ts @@ -0,0 +1,102 @@ +import type { + GoogleAppsheetDeleteParams, + GoogleAppsheetDeleteResponse, +} from '@/tools/google_appsheet/types' +import { buildAppsheetActionUrl, readAppsheetResponseBody } from '@/tools/google_appsheet/utils' +import type { ToolConfig } from '@/tools/types' + +export const googleAppsheetDeleteRowsTool: ToolConfig< + GoogleAppsheetDeleteParams, + GoogleAppsheetDeleteResponse +> = { + id: 'google_appsheet_delete_rows', + name: 'AppSheet Delete Rows', + description: + 'Delete rows from an AppSheet table. Each row only needs to include the key column name and value.', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AppSheet Application Access Key', + }, + appId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'AppSheet app ID (found in App > Settings > Integrations > IN)', + }, + tableName: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Name of the table to delete rows from', + }, + region: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'AppSheet region subdomain: "www" (global, default), "eu", or "asia-southeast"', + }, + rows: { + type: 'json', + required: true, + visibility: 'user-or-llm', + description: + 'Array of row objects identifying rows to delete by key column, e.g. [{ "RowID": "123" }]', + }, + }, + + request: { + url: (params) => buildAppsheetActionUrl(params.appId, params.tableName, params.region), + method: 'POST', + headers: (params) => ({ + ApplicationAccessKey: params.apiKey, + 'Content-Type': 'application/json', + }), + body: (params) => ({ + Action: 'Delete', + Properties: {}, + Rows: params.rows, + }), + }, + + transformResponse: async (response: Response) => { + const data = await readAppsheetResponseBody(response) + + if (!response.ok) { + throw new Error(data.error?.message || data.message || 'Failed to delete AppSheet rows') + } + + const rows = data.Rows ?? data.rows ?? [] + + return { + success: true, + output: { + rows, + metadata: { + rowCount: rows.length, + }, + }, + } + }, + + outputs: { + rows: { + type: 'array', + description: 'Rows deleted by AppSheet', + items: { + type: 'object', + }, + }, + metadata: { + type: 'json', + description: 'Operation metadata', + properties: { + rowCount: { type: 'number', description: 'Number of rows deleted' }, + }, + }, + }, +} diff --git a/apps/sim/tools/google_appsheet/edit_rows.ts b/apps/sim/tools/google_appsheet/edit_rows.ts new file mode 100644 index 00000000000..2e1b53be494 --- /dev/null +++ b/apps/sim/tools/google_appsheet/edit_rows.ts @@ -0,0 +1,102 @@ +import type { + GoogleAppsheetEditParams, + GoogleAppsheetEditResponse, +} from '@/tools/google_appsheet/types' +import { buildAppsheetActionUrl, readAppsheetResponseBody } from '@/tools/google_appsheet/utils' +import type { ToolConfig } from '@/tools/types' + +export const googleAppsheetEditRowsTool: ToolConfig< + GoogleAppsheetEditParams, + GoogleAppsheetEditResponse +> = { + id: 'google_appsheet_edit_rows', + name: 'AppSheet Edit Rows', + description: + 'Update existing rows in an AppSheet table. Each row must explicitly include the key column name and value, plus any columns to change.', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AppSheet Application Access Key', + }, + appId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'AppSheet app ID (found in App > Settings > Integrations > IN)', + }, + tableName: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Name of the table to update rows in', + }, + region: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'AppSheet region subdomain: "www" (global, default), "eu", or "asia-southeast"', + }, + rows: { + type: 'json', + required: true, + visibility: 'user-or-llm', + description: + 'Array of row objects to update, each including the key column and the columns to change, e.g. [{ "RowID": "123", "Status": "Done" }]', + }, + }, + + request: { + url: (params) => buildAppsheetActionUrl(params.appId, params.tableName, params.region), + method: 'POST', + headers: (params) => ({ + ApplicationAccessKey: params.apiKey, + 'Content-Type': 'application/json', + }), + body: (params) => ({ + Action: 'Edit', + Properties: {}, + Rows: params.rows, + }), + }, + + transformResponse: async (response: Response) => { + const data = await readAppsheetResponseBody(response) + + if (!response.ok) { + throw new Error(data.error?.message || data.message || 'Failed to edit AppSheet rows') + } + + const rows = data.Rows ?? data.rows ?? [] + + return { + success: true, + output: { + rows, + metadata: { + rowCount: rows.length, + }, + }, + } + }, + + outputs: { + rows: { + type: 'array', + description: 'Rows updated by AppSheet', + items: { + type: 'object', + }, + }, + metadata: { + type: 'json', + description: 'Operation metadata', + properties: { + rowCount: { type: 'number', description: 'Number of rows updated' }, + }, + }, + }, +} diff --git a/apps/sim/tools/google_appsheet/find_rows.ts b/apps/sim/tools/google_appsheet/find_rows.ts new file mode 100644 index 00000000000..08a74a46b19 --- /dev/null +++ b/apps/sim/tools/google_appsheet/find_rows.ts @@ -0,0 +1,102 @@ +import type { + GoogleAppsheetFindParams, + GoogleAppsheetFindResponse, +} from '@/tools/google_appsheet/types' +import { buildAppsheetActionUrl, readAppsheetResponseBody } from '@/tools/google_appsheet/utils' +import type { ToolConfig } from '@/tools/types' + +export const googleAppsheetFindRowsTool: ToolConfig< + GoogleAppsheetFindParams, + GoogleAppsheetFindResponse +> = { + id: 'google_appsheet_find_rows', + name: 'AppSheet Find Rows', + description: + 'Read rows from an AppSheet table. Omit the selector to return every row, or provide a Selector expression (Filter/Select/OrderBy/Top) to narrow and shape the results.', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AppSheet Application Access Key', + }, + appId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'AppSheet app ID (found in App > Settings > Integrations > IN)', + }, + tableName: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Name of the table to read from', + }, + region: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'AppSheet region subdomain: "www" (global, default), "eu", or "asia-southeast"', + }, + selector: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Optional AppSheet expression to filter/sort/limit rows, e.g. Filter(TableName, [Age] >= 21) or Top(OrderBy(Filter(TableName, true), [LastName], true), 10)', + }, + }, + + request: { + url: (params) => buildAppsheetActionUrl(params.appId, params.tableName, params.region), + method: 'POST', + headers: (params) => ({ + ApplicationAccessKey: params.apiKey, + 'Content-Type': 'application/json', + }), + body: (params) => ({ + Action: 'Find', + Properties: params.selector ? { Selector: params.selector } : {}, + Rows: [], + }), + }, + + transformResponse: async (response: Response) => { + const data = await readAppsheetResponseBody(response) + + if (!response.ok) { + throw new Error(data.error?.message || data.message || 'Failed to find AppSheet rows') + } + + const rows = data.Rows ?? data.rows ?? [] + + return { + success: true, + output: { + rows, + metadata: { + rowCount: rows.length, + }, + }, + } + }, + + outputs: { + rows: { + type: 'array', + description: 'Matching rows returned by AppSheet', + items: { + type: 'object', + }, + }, + metadata: { + type: 'json', + description: 'Operation metadata', + properties: { + rowCount: { type: 'number', description: 'Number of rows returned' }, + }, + }, + }, +} diff --git a/apps/sim/tools/google_appsheet/index.ts b/apps/sim/tools/google_appsheet/index.ts new file mode 100644 index 00000000000..d0737550262 --- /dev/null +++ b/apps/sim/tools/google_appsheet/index.ts @@ -0,0 +1,13 @@ +import { googleAppsheetAddRowsTool } from '@/tools/google_appsheet/add_rows' +import { googleAppsheetDeleteRowsTool } from '@/tools/google_appsheet/delete_rows' +import { googleAppsheetEditRowsTool } from '@/tools/google_appsheet/edit_rows' +import { googleAppsheetFindRowsTool } from '@/tools/google_appsheet/find_rows' + +export { + googleAppsheetAddRowsTool, + googleAppsheetDeleteRowsTool, + googleAppsheetEditRowsTool, + googleAppsheetFindRowsTool, +} + +export * from './types' diff --git a/apps/sim/tools/google_appsheet/types.ts b/apps/sim/tools/google_appsheet/types.ts new file mode 100644 index 00000000000..fcb523dd527 --- /dev/null +++ b/apps/sim/tools/google_appsheet/types.ts @@ -0,0 +1,72 @@ +import type { ToolResponse } from '@/tools/types' + +interface GoogleAppsheetBaseParams { + apiKey: string + appId: string + tableName: string + region?: string +} + +export type GoogleAppsheetRow = Record + +// Find Rows Types +export interface GoogleAppsheetFindParams extends GoogleAppsheetBaseParams { + selector?: string +} + +export interface GoogleAppsheetFindResponse extends ToolResponse { + output: { + rows: GoogleAppsheetRow[] + metadata: { + rowCount: number + } + } +} + +// Add Rows Types +export interface GoogleAppsheetAddParams extends GoogleAppsheetBaseParams { + rows: GoogleAppsheetRow[] +} + +export interface GoogleAppsheetAddResponse extends ToolResponse { + output: { + rows: GoogleAppsheetRow[] + metadata: { + rowCount: number + } + } +} + +// Edit Rows Types +export interface GoogleAppsheetEditParams extends GoogleAppsheetBaseParams { + rows: GoogleAppsheetRow[] +} + +export interface GoogleAppsheetEditResponse extends ToolResponse { + output: { + rows: GoogleAppsheetRow[] + metadata: { + rowCount: number + } + } +} + +// Delete Rows Types +export interface GoogleAppsheetDeleteParams extends GoogleAppsheetBaseParams { + rows: GoogleAppsheetRow[] +} + +export interface GoogleAppsheetDeleteResponse extends ToolResponse { + output: { + rows: GoogleAppsheetRow[] + metadata: { + rowCount: number + } + } +} + +export type GoogleAppsheetResponse = + | GoogleAppsheetFindResponse + | GoogleAppsheetAddResponse + | GoogleAppsheetEditResponse + | GoogleAppsheetDeleteResponse diff --git a/apps/sim/tools/google_appsheet/utils.test.ts b/apps/sim/tools/google_appsheet/utils.test.ts new file mode 100644 index 00000000000..d23ffd34341 --- /dev/null +++ b/apps/sim/tools/google_appsheet/utils.test.ts @@ -0,0 +1,50 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { buildAppsheetActionUrl, readAppsheetResponseBody } from '@/tools/google_appsheet/utils' + +describe('buildAppsheetActionUrl', () => { + it('defaults to the global www region when unset', () => { + expect(buildAppsheetActionUrl('app-1', 'Orders')).toBe( + 'https://www.appsheet.com/api/v2/apps/app-1/tables/Orders/Action' + ) + }) + + it('builds the URL for a valid non-default region', () => { + expect(buildAppsheetActionUrl('app-1', 'Orders', 'eu')).toBe( + 'https://eu.appsheet.com/api/v2/apps/app-1/tables/Orders/Action' + ) + }) + + it('trims and URL-encodes appId and tableName', () => { + expect(buildAppsheetActionUrl(' app 1 ', ' My Table ')).toBe( + 'https://www.appsheet.com/api/v2/apps/app%201/tables/My%20Table/Action' + ) + }) + + it('rejects a region outside the known AppSheet regions', () => { + expect(() => buildAppsheetActionUrl('app-1', 'Orders', 'attacker.example.com')).toThrow( + /Invalid AppSheet region/ + ) + }) +}) + +describe('readAppsheetResponseBody', () => { + it('parses a JSON body', async () => { + const response = new Response('{"Rows":[{"id":"1"}]}') + expect(await readAppsheetResponseBody(response)).toEqual({ Rows: [{ id: '1' }] }) + }) + + it('returns an empty object for an empty body', async () => { + const response = new Response('') + expect(await readAppsheetResponseBody(response)).toEqual({}) + }) + + it('wraps a non-JSON body as a message instead of throwing', async () => { + const response = new Response('502 Bad Gateway') + expect(await readAppsheetResponseBody(response)).toEqual({ + message: '502 Bad Gateway', + }) + }) +}) diff --git a/apps/sim/tools/google_appsheet/utils.ts b/apps/sim/tools/google_appsheet/utils.ts new file mode 100644 index 00000000000..dc635e1f0d8 --- /dev/null +++ b/apps/sim/tools/google_appsheet/utils.ts @@ -0,0 +1,36 @@ +const DEFAULT_REGION = 'www' +const ALLOWED_REGIONS = new Set(['www', 'eu', 'asia-southeast']) + +/** + * Builds the AppSheet API Action endpoint URL for a given app/table/region. + * Region defaults to the global `www.appsheet.com` domain when unset, and is + * validated against the known AppSheet regions since it is interpolated into + * the request host — an unvalidated value would let a caller redirect the + * Application Access Key to an arbitrary host. + */ +export function buildAppsheetActionUrl(appId: string, tableName: string, region?: string): string { + const trimmedRegion = (region || DEFAULT_REGION).trim() + if (!ALLOWED_REGIONS.has(trimmedRegion)) { + throw new Error( + `Invalid AppSheet region "${trimmedRegion}". Must be one of: ${Array.from(ALLOWED_REGIONS).join(', ')}.` + ) + } + const host = `${trimmedRegion}.appsheet.com` + return `https://${host}/api/v2/apps/${encodeURIComponent(appId.trim())}/tables/${encodeURIComponent(tableName.trim())}/Action` +} + +/** + * Safely reads an AppSheet API response body. AppSheet does not consistently + * document whether every Action returns a JSON body (e.g. Delete may return an + * empty body on some accounts), so this avoids `response.json()` throwing on + * empty or non-JSON content. + */ +export async function readAppsheetResponseBody(response: Response): Promise> { + const text = await response.text() + if (!text) return {} + try { + return JSON.parse(text) + } catch { + return { message: text } + } +} diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index b947a88a56b..c293f27acf2 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -1257,6 +1257,12 @@ import { googleAdsListCustomersTool, googleAdsSearchTool, } from '@/tools/google_ads' +import { + googleAppsheetAddRowsTool, + googleAppsheetDeleteRowsTool, + googleAppsheetEditRowsTool, + googleAppsheetFindRowsTool, +} from '@/tools/google_appsheet' import { googleBigQueryGetTableTool, googleBigQueryInsertRowsTool, @@ -7560,6 +7566,10 @@ export const tools: Record = { google_ads_campaign_performance: googleAdsCampaignPerformanceTool, google_ads_list_ad_groups: googleAdsListAdGroupsTool, google_ads_ad_performance: googleAdsAdPerformanceTool, + google_appsheet_find_rows: googleAppsheetFindRowsTool, + google_appsheet_add_rows: googleAppsheetAddRowsTool, + google_appsheet_edit_rows: googleAppsheetEditRowsTool, + google_appsheet_delete_rows: googleAppsheetDeleteRowsTool, google_bigquery_query: googleBigQueryQueryTool, google_bigquery_list_datasets: googleBigQueryListDatasetsTool, google_bigquery_list_tables: googleBigQueryListTablesTool, From b4b666bfbed836ac80c8407f898de1cf56a9402f Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Thu, 2 Jul 2026 12:12:30 -0700 Subject: [PATCH 26/28] improvement(forking): fork time ux (#5348) * improvement(forking): fork time ux * add storage quota * address comments * merge latest staging * address comments --- .../api/workflows/[id]/deployed/route.test.ts | 202 +++++ .../app/api/workflows/[id]/deployed/route.ts | 40 +- .../app/api/workflows/[id]/execute/route.ts | 21 + .../api/workspaces/[id]/fork/diff/route.ts | 29 +- .../api/workspaces/[id]/fork/promote/route.ts | 1 + .../fork-resource-picker.tsx | 97 +- .../cleared-refs-list.test.ts | 55 +- .../cleared-refs-list.ts | 49 + .../components/resource-reconfigure.tsx | 90 +- .../copy-reconciliation.test.ts | 55 ++ .../copy-reconciliation.ts | 31 + .../promote-workspace-modal.tsx | 342 ++++--- .../workflow/workflow-handler.test.ts | 128 +++ .../handlers/workflow/workflow-handler.ts | 32 + apps/sim/lib/api/contracts/workflows.ts | 8 + apps/sim/lib/api/contracts/workspace-fork.ts | 90 +- .../persistence/remap-internal-ids.test.ts | 162 +++- .../persistence/remap-internal-ids.ts | 42 +- .../workspaces/fork/copy/cleanup-failed.ts | 8 + .../workspaces/fork/copy/copy-files.test.ts | 158 ++++ .../lib/workspaces/fork/copy/copy-files.ts | 45 +- .../fork/copy/copy-resources.test.ts | 120 +++ .../workspaces/fork/copy/copy-resources.ts | 45 +- .../fork/copy/copy-workflows.test.ts | 237 ++++- .../workspaces/fork/copy/copy-workflows.ts | 45 +- .../fork/copy/storage-quota.test.ts | 140 +++ .../lib/workspaces/fork/copy/storage-quota.ts | 126 +++ .../lib/workspaces/fork/create-fork.test.ts | 187 ++++ apps/sim/lib/workspaces/fork/create-fork.ts | 21 + .../fork/mapping/mapping-service.ts | 7 +- .../workspaces/fork/mapping/resources.test.ts | 70 ++ .../lib/workspaces/fork/mapping/resources.ts | 88 +- .../fork/promote/cleared-refs.test.ts | 847 +++++++++++++++++- .../workspaces/fork/promote/cleared-refs.ts | 259 +++++- .../fork/promote/copy-unmapped.test.ts | 147 ++- .../workspaces/fork/promote/copy-unmapped.ts | 34 +- .../fork/promote/promote-plan.test.ts | 106 ++- .../workspaces/fork/promote/promote-plan.ts | 56 +- .../workspaces/fork/promote/promote.test.ts | 401 +++++++++ .../lib/workspaces/fork/promote/promote.ts | 138 ++- .../fork/promote/sync-blockers.test.ts | 119 +++ .../workspaces/fork/promote/sync-blockers.ts | 65 ++ .../fork/remap/remap-references.test.ts | 258 ++++++ .../workspaces/fork/remap/remap-references.ts | 148 ++- .../fork/remap/remap-table-groups.test.ts | 35 +- .../fork/remap/remap-table-groups.ts | 19 +- apps/sim/tools/workflow/executor.test.ts | 33 + apps/sim/tools/workflow/executor.ts | 2 + scripts/check-api-validation-contracts.ts | 2 +- 49 files changed, 5015 insertions(+), 425 deletions(-) create mode 100644 apps/sim/app/api/workflows/[id]/deployed/route.test.ts create mode 100644 apps/sim/lib/workspaces/fork/copy/copy-files.test.ts create mode 100644 apps/sim/lib/workspaces/fork/copy/storage-quota.test.ts create mode 100644 apps/sim/lib/workspaces/fork/copy/storage-quota.ts create mode 100644 apps/sim/lib/workspaces/fork/create-fork.test.ts create mode 100644 apps/sim/lib/workspaces/fork/promote/promote.test.ts create mode 100644 apps/sim/lib/workspaces/fork/promote/sync-blockers.test.ts create mode 100644 apps/sim/lib/workspaces/fork/promote/sync-blockers.ts diff --git a/apps/sim/app/api/workflows/[id]/deployed/route.test.ts b/apps/sim/app/api/workflows/[id]/deployed/route.test.ts new file mode 100644 index 00000000000..a8854c4afdf --- /dev/null +++ b/apps/sim/app/api/workflows/[id]/deployed/route.test.ts @@ -0,0 +1,202 @@ +/** + * Tests for the workflow deployed-state API route. + * Covers internal-JWT authorization (acting user required + workspace read + * permission) and the unchanged session path. + * + * @vitest-environment node + */ + +import { + workflowAuthzMockFns, + workflowsPersistenceUtilsMock, + workflowsPersistenceUtilsMockFns, + workflowsUtilsMock, + workflowsUtilsMockFns, +} from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockVerifyInternalToken } = vi.hoisted(() => ({ + mockVerifyInternalToken: vi.fn(), +})) + +vi.mock('@/lib/auth/internal', () => ({ + verifyInternalToken: mockVerifyInternalToken, +})) + +vi.mock('@/lib/workflows/persistence/utils', () => workflowsPersistenceUtilsMock) + +vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock) + +import { GET } from './route' + +const mockAuthorizeWorkflowByWorkspacePermission = + workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission +const mockLoadDeployedWorkflowState = workflowsPersistenceUtilsMockFns.mockLoadDeployedWorkflowState +const mockValidateWorkflowPermissions = workflowsUtilsMockFns.mockValidateWorkflowPermissions + +const DEPLOYED_STATE = { + blocks: { 'block-1': { id: 'block-1', type: 'starter' } }, + edges: [], + loops: {}, + parallels: {}, + variables: {}, +} + +function createRequest(options?: { bearerToken?: string }) { + const headers: Record = {} + if (options?.bearerToken) { + headers.Authorization = `Bearer ${options.bearerToken}` + } + return new NextRequest('http://localhost:3000/api/workflows/workflow-123/deployed', { headers }) +} + +const routeParams = () => ({ params: Promise.resolve({ id: 'workflow-123' }) }) + +describe('GET /api/workflows/[id]/deployed', () => { + beforeEach(() => { + vi.clearAllMocks() + mockVerifyInternalToken.mockResolvedValue({ valid: false }) + mockLoadDeployedWorkflowState.mockResolvedValue(DEPLOYED_STATE) + }) + + describe('internal JWT path', () => { + it('returns 200 when the token carries a user with read permission', async () => { + mockVerifyInternalToken.mockResolvedValue({ valid: true, userId: 'user-123' }) + mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ + allowed: true, + status: 200, + workflow: { id: 'workflow-123', workspaceId: 'workspace-456' }, + workspacePermission: 'read', + }) + + const response = await GET(createRequest({ bearerToken: 'internal-token' }), routeParams()) + + expect(response.status).toBe(200) + const data = await response.json() + expect(data.deployedState).toEqual(DEPLOYED_STATE) + expect(mockAuthorizeWorkflowByWorkspacePermission).toHaveBeenCalledWith({ + workflowId: 'workflow-123', + userId: 'user-123', + action: 'read', + }) + expect(mockValidateWorkflowPermissions).not.toHaveBeenCalled() + }) + + it('returns 403 when the acting user lacks read permission', async () => { + mockVerifyInternalToken.mockResolvedValue({ valid: true, userId: 'user-123' }) + mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ + allowed: false, + status: 403, + message: 'Unauthorized: Access denied to read this workflow', + workflow: { id: 'workflow-123', workspaceId: 'workspace-456' }, + workspacePermission: null, + }) + + const response = await GET(createRequest({ bearerToken: 'internal-token' }), routeParams()) + + expect(response.status).toBe(403) + const data = await response.json() + expect(data.error).toBe('Unauthorized: Access denied to read this workflow') + expect(mockLoadDeployedWorkflowState).not.toHaveBeenCalled() + }) + + it('returns 403 when the token carries no acting user (fail closed)', async () => { + mockVerifyInternalToken.mockResolvedValue({ valid: true, userId: undefined }) + + const response = await GET(createRequest({ bearerToken: 'internal-token' }), routeParams()) + + expect(response.status).toBe(403) + const data = await response.json() + expect(data.error).toBe('Forbidden') + expect(mockAuthorizeWorkflowByWorkspacePermission).not.toHaveBeenCalled() + expect(mockLoadDeployedWorkflowState).not.toHaveBeenCalled() + }) + + it('returns 404 when the workflow does not exist', async () => { + mockVerifyInternalToken.mockResolvedValue({ valid: true, userId: 'user-123' }) + mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ + allowed: false, + status: 404, + message: 'Workflow not found', + workflow: null, + workspacePermission: null, + }) + + const response = await GET(createRequest({ bearerToken: 'internal-token' }), routeParams()) + + expect(response.status).toBe(404) + const data = await response.json() + expect(data.error).toBe('Workflow not found') + expect(mockLoadDeployedWorkflowState).not.toHaveBeenCalled() + }) + }) + + describe('session path', () => { + it('returns 200 when session permissions validate', async () => { + mockValidateWorkflowPermissions.mockResolvedValue({ + error: null, + session: { user: { id: 'user-123' } }, + workflow: { id: 'workflow-123' }, + }) + + const response = await GET(createRequest(), routeParams()) + + expect(response.status).toBe(200) + const data = await response.json() + expect(data.deployedState).toEqual(DEPLOYED_STATE) + expect(mockValidateWorkflowPermissions).toHaveBeenCalledWith( + 'workflow-123', + expect.any(String), + 'read' + ) + expect(mockAuthorizeWorkflowByWorkspacePermission).not.toHaveBeenCalled() + }) + + it('propagates validateWorkflowPermissions errors unchanged', async () => { + mockValidateWorkflowPermissions.mockResolvedValue({ + error: { message: 'Unauthorized', status: 401 }, + session: null, + workflow: null, + }) + + const response = await GET(createRequest(), routeParams()) + + expect(response.status).toBe(401) + const data = await response.json() + expect(data.error).toBe('Unauthorized') + }) + + it('falls back to session validation when the bearer token is not a valid internal token', async () => { + mockVerifyInternalToken.mockResolvedValue({ valid: false }) + mockValidateWorkflowPermissions.mockResolvedValue({ + error: { message: 'Unauthorized', status: 401 }, + session: null, + workflow: null, + }) + + const response = await GET(createRequest({ bearerToken: 'not-internal' }), routeParams()) + + expect(response.status).toBe(401) + expect(mockValidateWorkflowPermissions).toHaveBeenCalled() + expect(mockAuthorizeWorkflowByWorkspacePermission).not.toHaveBeenCalled() + }) + }) + + it('returns null deployedState when loading the snapshot fails', async () => { + mockVerifyInternalToken.mockResolvedValue({ valid: true, userId: 'user-123' }) + mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ + allowed: true, + status: 200, + workflow: { id: 'workflow-123', workspaceId: 'workspace-456' }, + workspacePermission: 'admin', + }) + mockLoadDeployedWorkflowState.mockRejectedValue(new Error('no active deployment')) + + const response = await GET(createRequest({ bearerToken: 'internal-token' }), routeParams()) + + expect(response.status).toBe(200) + const data = await response.json() + expect(data.deployedState).toBeNull() + }) +}) diff --git a/apps/sim/app/api/workflows/[id]/deployed/route.ts b/apps/sim/app/api/workflows/[id]/deployed/route.ts index d68f2b6d6ed..60e8feaf7ec 100644 --- a/apps/sim/app/api/workflows/[id]/deployed/route.ts +++ b/apps/sim/app/api/workflows/[id]/deployed/route.ts @@ -1,4 +1,5 @@ import { createLogger } from '@sim/logger' +import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow' import type { NextRequest, NextResponse } from 'next/server' import { getDeployedWorkflowStateContract } from '@/lib/api/contracts/deployments' import { parseRequest } from '@/lib/api/server' @@ -19,6 +20,18 @@ function addNoCacheHeaders(response: NextResponse): NextResponse { return response } +/** + * GET /api/workflows/[id]/deployed + * Returns the active deployed state snapshot for a workflow. + * + * Internal (server-to-server) calls must carry the acting user in the internal + * JWT payload (`generateInternalToken(userId)` — the executor's + * `buildAuthHeaders(ctx.userId)` always embeds it) and are authorized as that + * user with the same workspace-read semantics as the sibling + * `/api/workflows/[id]` route. Internal calls without a user id are rejected + * (fail closed). Session calls are authorized via + * `validateWorkflowPermissions` as before. + */ export const GET = withRouteHandler( async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { const requestId = generateRequestId() @@ -29,14 +42,39 @@ export const GET = withRouteHandler( try { const authHeader = request.headers.get('authorization') let isInternalCall = false + let internalCallUserId: string | undefined if (authHeader?.startsWith('Bearer ')) { const token = authHeader.split(' ')[1] const verification = await verifyInternalToken(token) isInternalCall = verification.valid + internalCallUserId = verification.userId } - if (!isInternalCall) { + if (isInternalCall) { + if (!internalCallUserId) { + logger.warn(`[${requestId}] Internal call without acting user denied for workflow ${id}`) + return addNoCacheHeaders(createErrorResponse('Forbidden', 403)) + } + + const authorization = await authorizeWorkflowByWorkspacePermission({ + workflowId: id, + userId: internalCallUserId, + action: 'read', + }) + if (!authorization.workflow) { + logger.warn(`[${requestId}] Workflow ${id} not found for internal call`) + return addNoCacheHeaders(createErrorResponse('Workflow not found', 404)) + } + if (!authorization.allowed) { + logger.warn( + `[${requestId}] Internal call user ${internalCallUserId} denied read access to workflow ${id}` + ) + return addNoCacheHeaders( + createErrorResponse(authorization.message || 'Access denied', authorization.status) + ) + } + } else { const { error } = await validateWorkflowPermissions(id, requestId, 'read') if (error) { const response = createErrorResponse(error.message, error.status) diff --git a/apps/sim/app/api/workflows/[id]/execute/route.ts b/apps/sim/app/api/workflows/[id]/execute/route.ts index 246a62ab694..4cd544c27bb 100644 --- a/apps/sim/app/api/workflows/[id]/execute/route.ts +++ b/apps/sim/app/api/workflows/[id]/execute/route.ts @@ -527,6 +527,7 @@ async function handleExecutePost( startBlockId, stopAfterBlockId, runFromBlock: rawRunFromBlock, + parentWorkspaceId, } = validation.data const triggerBlockId = parsedTriggerBlockId ?? startBlockId @@ -642,6 +643,7 @@ async function handleExecutePost( stopAfterBlockId: _stopAfterBlockId, runFromBlock: _runFromBlock, workflowId: _workflowId, // Also exclude workflowId used for internal JWT auth + parentWorkspaceId: _parentWorkspaceId, ...rest } = body return Object.keys(rest).length > 0 ? rest : validatedInput @@ -729,6 +731,25 @@ async function handleExecutePost( ) } + /** + * Workflow-in-workflow invocations (e.g. the agent `workflow_executor` + * tool) declare the parent execution's workspace. Reject execution when + * the target workflow lives in a different workspace so a stale or + * foreign workflow id cannot silently execute with the parent's context. + * The error intentionally omits the target's workspace id. + */ + if (parentWorkspaceId && workflowAuthorization.workflow?.workspaceId !== parentWorkspaceId) { + reqLogger.warn('Blocked cross-workspace child workflow execution', { + parentWorkspaceId, + }) + return NextResponse.json( + { + error: `Child workflow ${workflowId} belongs to a different workspace and cannot be executed`, + }, + { status: 403 } + ) + } + if (req.signal.aborted) { return clientCancelledResponse() } diff --git a/apps/sim/app/api/workspaces/[id]/fork/diff/route.ts b/apps/sim/app/api/workspaces/[id]/fork/diff/route.ts index e717958e081..7f2d2a4eb9a 100644 --- a/apps/sim/app/api/workspaces/[id]/fork/diff/route.ts +++ b/apps/sim/app/api/workspaces/[id]/fork/diff/route.ts @@ -19,7 +19,10 @@ import { loadForkDependentValues, } from '@/lib/workspaces/fork/mapping/dependent-value-store' import { listForkResourceCandidates } from '@/lib/workspaces/fork/mapping/resources' -import { collectForkClearedRefCandidates } from '@/lib/workspaces/fork/promote/cleared-refs' +import { + annotateForkClearedRefSourceLiveness, + collectForkClearedRefCandidates, +} from '@/lib/workspaces/fork/promote/cleared-refs' import { computeForkPromotePlan } from '@/lib/workspaces/fork/promote/promote-plan' import { buildForkBlockIdResolver } from '@/lib/workspaces/fork/remap/block-identity' import { readTargetDraftDependentValue } from '@/lib/workspaces/fork/remap/remap-references' @@ -127,15 +130,21 @@ export const GET = withRouteHandler( sourceLabels.set(`${kind}:${candidate.id}`, candidate.label) } const sourceWorkflowNames = new Map(sourceWorkflowRows.map((row) => [row.id, row.name])) - const clearedRefs = collectForkClearedRefCandidates({ - items: plan.items, - sourceStates, - resolver: plan.resolver, - workflowIdMap: plan.workflowIdMap, - resolveBlockId, - sourceLabels, - sourceWorkflowNames, - }) + // Annotate each reference-cause entry's source liveness so the client can phrase the blocker + // reason (a deleted source can't be copied - it must be mapped to a live target resource). + const clearedRefs = await annotateForkClearedRefSourceLiveness( + db, + auth.sourceWorkspaceId, + collectForkClearedRefCandidates({ + items: plan.items, + sourceStates, + resolver: plan.resolver, + workflowIdMap: plan.workflowIdMap, + resolveBlockId, + sourceLabels, + sourceWorkflowNames, + }) + ) const toRef = (reference: (typeof plan.unmappedRequired)[number]) => ({ kind: reference.kind, diff --git a/apps/sim/app/api/workspaces/[id]/fork/promote/route.ts b/apps/sim/app/api/workspaces/[id]/fork/promote/route.ts index 6b6228620eb..7cfadf34bed 100644 --- a/apps/sim/app/api/workspaces/[id]/fork/promote/route.ts +++ b/apps/sim/app/api/workspaces/[id]/fork/promote/route.ts @@ -48,6 +48,7 @@ export const POST = withRouteHandler( redeployed: result.redeployed, deployFailed: result.deployFailed, unmappedRequired: result.unmappedRequired, + blockers: result.blockers, needsConfiguration: result.needsConfiguration, clearedOptional: result.clearedOptional, } diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/fork-resource-picker/fork-resource-picker.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/fork-resource-picker/fork-resource-picker.tsx index 964aadd907e..82ffbf88cae 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/fork-resource-picker/fork-resource-picker.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/fork-resource-picker/fork-resource-picker.tsx @@ -1,8 +1,7 @@ 'use client' import { useId, useMemo, useState } from 'react' -import { Checkbox, ChevronDown, ChipInput, cn } from '@sim/emcn' -import { Search } from 'lucide-react' +import { Checkbox, ChevronDown, cn } from '@sim/emcn' import { ForkFileTree, type ForkFlatFile, @@ -15,14 +14,11 @@ export interface ForkResourcePickerItem { label: string } -/** Show the inline search once a kind has more entries than fit comfortably. */ -const SEARCH_THRESHOLD = 8 - interface ResourceKindRowProps { label: string items: ForkResourcePickerItem[] selected: Set - /** Toggle the given ids on/off. Used for select-all over the currently-VISIBLE (filtered) subset. */ + /** Toggle the given ids on/off. Used by the select-all header checkbox. */ onToggleMany: (ids: string[], checked: boolean) => void onToggleItem: (id: string, checked: boolean) => void disabled?: boolean @@ -30,10 +26,10 @@ interface ResourceKindRowProps { /** * One expandable resource kind in the fork / sync copy picker: a tri-state "select all" header - * (count of selected / total) plus, when expanded, a searchable scrollable list of individual - * resources so the user can copy a specific subset. Shared by the fork modal's "Copy resources" - * and the sync modal's "Copy resources" so the two surfaces stay identical. Files nest in a - * folder tree instead - use {@link FileKindRow}. + * (count of selected / total) plus, when expanded, a scrollable list of individual resources so + * the user can copy a specific subset. Shared by the fork modal's "Copy resources" and the sync + * modal's "Copy resources" so the two surfaces stay identical. Files nest in a folder tree + * instead - use {@link FileKindRow}. */ export function ResourceKindRow({ label, @@ -44,19 +40,10 @@ export function ResourceKindRow({ disabled = false, }: ResourceKindRowProps) { const [expanded, setExpanded] = useState(false) - const [query, setQuery] = useState('') const fieldId = useId() - const filtered = useMemo(() => { - const trimmed = query.trim().toLowerCase() - if (!trimmed) return items - return items.filter((item) => item.label.toLowerCase().includes(trimmed)) - }, [items, query]) - - // Count + header state + select-all are scoped to the VISIBLE (filtered) items so a search never - // selects or counts hidden ones. With no filter, `filtered === items`, so behavior is unchanged. - const total = filtered.length - const selectedCount = filtered.reduce((count, item) => count + (selected.has(item.id) ? 1 : 0), 0) + const total = items.length + const selectedCount = items.reduce((count, item) => count + (selected.has(item.id) ? 1 : 0), 0) const headerState = selectedCount === 0 ? false : selectedCount === total ? true : 'indeterminate' return ( @@ -68,7 +55,7 @@ export function ResourceKindRow({ checked={headerState} onCheckedChange={() => onToggleMany( - filtered.map((item) => item.id), + items.map((item) => item.id), headerState !== true ) } @@ -92,46 +79,32 @@ export function ResourceKindRow({ {expanded ? ( -
- {total > SEARCH_THRESHOLD ? ( - setQuery(event.target.value)} - placeholder={`Search ${label.toLowerCase()}`} - disabled={disabled} - /> - ) : null} -
- {filtered.map((item) => { - const isChecked = selected.has(item.id) - const itemId = `${fieldId}-${item.id}` - return ( - - ) - })} - {filtered.length === 0 ? ( -

No matches

- ) : null} -
+
+ {items.map((item) => { + const isChecked = selected.has(item.id) + const itemId = `${fieldId}-${item.id}` + return ( + + ) + })}
) : null}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/cleared-refs-list.test.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/cleared-refs-list.test.ts index 677c6c8f4fc..c8311b2bd1b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/cleared-refs-list.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/cleared-refs-list.test.ts @@ -3,7 +3,11 @@ */ import { describe, expect, it } from 'vitest' import type { ForkClearedRef } from '@/lib/api/contracts/workspace-fork' -import { selectVisibleClearedRefs } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/cleared-refs-list' +import { + forkBlockerResolution, + selectVisibleClearedRefs, + splitForkClearedRefs, +} from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/cleared-refs-list' type ReferenceRef = Extract type WorkflowRef = Extract @@ -20,8 +24,9 @@ const base = { const referenceRef = ( kind: ReferenceRef['kind'], sourceId: string, - fieldLabel = 'Field' -): ReferenceRef => ({ ...base, fieldLabel, cause: 'reference', kind, sourceId }) + fieldLabel = 'Field', + sourceDeleted = false +): ReferenceRef => ({ ...base, fieldLabel, cause: 'reference', kind, sourceId, sourceDeleted }) const workflowRef = (sourceId: string, fieldLabel = 'Workflow'): WorkflowRef => ({ ...base, @@ -118,3 +123,47 @@ describe('selectVisibleClearedRefs', () => { ).toEqual([workflowReference]) }) }) + +describe('splitForkClearedRefs', () => { + it('splits reference/workflow causes into blockers and dependents into informational', () => { + const tableReference = referenceRef('table', 'tbl-1') + const workflowReference = workflowRef('wf-other') + const labelDependent = dependentRef('credential', 'cred-1', 'Label') + const { blockers, informational } = splitForkClearedRefs([ + tableReference, + workflowReference, + labelDependent, + ]) + expect(blockers).toEqual([tableReference, workflowReference]) + expect(informational).toEqual([labelDependent]) + }) + + it('treats an unmapped MCP server and a source-deleted reference as blockers', () => { + const mcpReference = referenceRef('mcp-server', 'srv-1') + const deletedReference = referenceRef('skill', 'sk-gone', 'Skill', true) + const { blockers, informational } = splitForkClearedRefs([mcpReference, deletedReference]) + expect(blockers).toEqual([mcpReference, deletedReference]) + expect(informational).toEqual([]) + }) +}) + +describe('forkBlockerResolution', () => { + it('phrases each blocker reason with its actionable resolution', () => { + expect(forkBlockerResolution(referenceRef('table', 'tbl-1'))).toBe( + 'map it to a target or select it for copy' + ) + expect(forkBlockerResolution(referenceRef('mcp-server', 'srv-1'))).toBe( + 'map it to an MCP server in the target workspace' + ) + expect(forkBlockerResolution(referenceRef('knowledge-base', 'kb-gone', 'KB', true))).toBe( + 'deleted in the source — map it to an existing knowledge base in the target' + ) + expect(forkBlockerResolution(workflowRef('wf-other', 'Workflow'))).toBe( + 'deploy "Source" in the source or remove the reference' + ) + }) + + it('returns null for non-blocking dependent entries', () => { + expect(forkBlockerResolution(dependentRef('credential', 'cred-1'))).toBeNull() + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/cleared-refs-list.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/cleared-refs-list.ts index 3f0bba304fe..d55a460fa07 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/cleared-refs-list.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/cleared-refs-list.ts @@ -1,4 +1,5 @@ import type { ForkClearedRef } from '@/lib/api/contracts/workspace-fork' +import { forkSyncBlockerReasonFor } from '@/lib/workspaces/fork/promote/sync-blockers' /** Whether a resource is resolved by the current selection (mapped to a target OR selected for copy). */ export type ClearedRefResolvedPredicate = (kind: string, sourceId: string) => boolean @@ -37,3 +38,51 @@ export function selectVisibleClearedRefs( return true }) } + +/** + * Split the visible would-clear entries into sync BLOCKERS (cause `reference`/`workflow` - the + * sync is disabled while any remain) and the informational remainder (`dependent` entries, owned + * by the reconfigure flow - they clear but never block). Pure, so the modal's gate and the two + * sections stay one testable rule. + */ +export function splitForkClearedRefs(visibleRefs: ForkClearedRef[]): { + blockers: ForkClearedRef[] + informational: ForkClearedRef[] +} { + const blockers: ForkClearedRef[] = [] + const informational: ForkClearedRef[] = [] + for (const ref of visibleRefs) { + if (forkSyncBlockerReasonFor(ref)) blockers.push(ref) + else informational.push(ref) + } + return { blockers, informational } +} + +/** Human label per blocker kind for the resolution copy (singular, lowercase mid-sentence). */ +const BLOCKER_KIND_LABEL: Record = { + table: 'table', + 'knowledge-base': 'knowledge base', + file: 'file', + 'custom-tool': 'custom tool', + skill: 'skill', + 'mcp-server': 'MCP server', +} + +/** + * The actionable resolution line for a blocking entry, phrased for "{block} would lose {field} + * in {workflow} - {resolution}". Null for non-blocking (dependent) entries. + */ +export function forkBlockerResolution(ref: ForkClearedRef): string | null { + const reason = forkSyncBlockerReasonFor(ref) + if (!reason) return null + switch (reason) { + case 'unmapped-copyable': + return 'map it to a target or select it for copy' + case 'unmapped-mcp-server': + return 'map it to an MCP server in the target workspace' + case 'source-deleted': + return `deleted in the source — map it to an existing ${BLOCKER_KIND_LABEL[ref.kind] ?? 'resource'} in the target` + case 'workflow-missing': + return `deploy "${ref.sourceLabel}" in the source or remove the reference` + } +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/components/resource-reconfigure.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/components/resource-reconfigure.tsx index 5c7c6eedba5..075d46dc7e4 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/components/resource-reconfigure.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/components/resource-reconfigure.tsx @@ -3,7 +3,6 @@ import { type Dispatch, type SetStateAction, useMemo, useState } from 'react' import { ChevronDown, cn } from '@sim/emcn' import type { ForkDependentReconfig, ForkResourceUsage } from '@/lib/api/contracts/workspace-fork' -import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' import { DependentFieldSelector } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/components/dependent-field-selector' import { dependentKey, @@ -57,8 +56,8 @@ interface ResourceReconfigureProps { * Always-on per-resource reconfigure listing: every workflow the resource is used in, each a * chevron row that expands to its blocks + dependent selectors so the user can (re)configure * them at any time - not only right after a target swap. A workflow with nothing configurable - * (a secret/file, or a credential with no dependent selector here) renders greyed and - * non-expandable with a tooltip, so the usage is still visible. + * (a secret/file, or a credential with no dependent selector here) renders as a plain + * non-interactive row without a chevron, with a tooltip, so the usage is still visible. */ export function ResourceReconfigure({ workflows, @@ -88,24 +87,26 @@ export function ResourceReconfigure({ }, [workflows, dependents]) if (workflows.length === 0) return null + // Muted caption label over the list (no divider) so this reads as subordinate to the + // resource-name section header above, mirroring the "Recent runs" listing in + // mothership-view's resource-content. return ( -
- -
- {workflowBlocks.map((workflow) => ( - - ))} -
-
+
+ Workflows +
+ {workflowBlocks.map((workflow) => ( + + ))} +
) } @@ -120,7 +121,7 @@ interface ReconfigWorkflowRowProps { setReconfig: Dispatch>> } -/** One workflow row: a chevron header (greyed + non-expandable when nothing to configure). */ +/** One workflow row: a chevron header (a plain non-interactive row when nothing to configure). */ function ReconfigWorkflowRow({ workflowName, blocks, @@ -141,28 +142,31 @@ function ReconfigWorkflowRow({ return (
- {/* Chevron styling mirrors the Activity panel's collapsible rows exactly. A greyed, - non-expandable row uses a native title tooltip to explain why. */} - + {/* Chevron styling mirrors the Activity panel's collapsible rows exactly. A row with + nothing to configure renders as muted plain text (no chevron, not a button) with a + native title tooltip explaining why it isn't expandable. */} + {configurable ? ( + + ) : ( +
+ {workflowName} +
+ )} {configurable && open ? blocks.map((block) => ( ): ForkCopyableUnmappe label: 'KB', parentId: null, parentLabel: null, + referenced: true, ...overrides, }) @@ -82,6 +85,23 @@ describe('copy-vs-map reconciliation', () => { }) }) +describe('forkDefaultCopySelection', () => { + it('seeds every referenced candidate and leaves unreferenced ones unselected', () => { + const selection = forkDefaultCopySelection([ + copyable({ kind: 'knowledge-base', sourceId: 'kb-1', referenced: true }), + copyable({ kind: 'table', sourceId: 'tbl-new', referenced: false }), + copyable({ kind: 'file', sourceId: 'workspace/SRC/new.png', referenced: false }), + ]) + expect(selection).toEqual(new Set(['knowledge-base:kb-1'])) + }) + + it('seeds nothing when every candidate is unreferenced', () => { + expect( + forkDefaultCopySelection([copyable({ kind: 'skill', sourceId: 'sk-new', referenced: false })]) + ).toEqual(new Set()) + }) +}) + describe('isForkRequiredComplete', () => { it('a required ref is satisfied by a mapping target', () => { const entries = [ @@ -100,12 +120,47 @@ describe('isForkRequiredComplete', () => { expect(isForkRequiredComplete(entries, {}, new Set())).toBe(false) }) + it('a referenced MCP server (map-only, required) blocks until mapped - copy cannot satisfy it', () => { + const entries = [ + entry({ kind: 'mcp-server', resourceType: 'mcp_server', sourceId: 'srv-1', required: true }), + ] + // MCP servers are never copy candidates, so the copy set can't contain them; only a + // mapping target resolves the entry. + expect(isForkRequiredComplete(entries, {}, new Set())).toBe(false) + expect(isForkRequiredComplete(entries, { 'mcp-server:srv-1': 'srv-tgt' }, new Set())).toBe(true) + }) + + it('a source-deleted referenced resource (required, no copy candidate) blocks until mapped', () => { + // A deleted source is dropped from the copy candidates (its label lookup fails), so the + // only resolution is mapping the dead id to a live target resource. + const entries = [ + entry({ kind: 'table', resourceType: 'table', sourceId: 'tbl-gone', required: true }), + ] + expect(isForkRequiredComplete(entries, {}, new Set())).toBe(false) + expect(isForkRequiredComplete(entries, { 'table:tbl-gone': 'tbl-live' }, new Set())).toBe(true) + }) + it('optional refs never block', () => { const entries = [entry({ kind: 'table', sourceId: 't1', required: false })] expect(isForkRequiredComplete(entries, {}, new Set())).toBe(true) }) }) +describe('forkRequiredKindsLabel', () => { + it('names credentials and secrets by kind, together or alone', () => { + expect(forkRequiredKindsLabel(new Set(['credential', 'env-var']))).toBe( + 'credentials and secrets' + ) + expect(forkRequiredKindsLabel(new Set(['credential']))).toBe('credentials') + expect(forkRequiredKindsLabel(new Set(['env-var']))).toBe('secrets') + }) + + it('falls back to "references" for any other (or empty) kind set', () => { + expect(forkRequiredKindsLabel(new Set(['table']))).toBe('references') + expect(forkRequiredKindsLabel(new Set())).toBe('references') + }) +}) + describe('forkRequiredPending', () => { it('is true when a required ref is neither mapped nor selected for copy', () => { const items = [entry({ kind: 'credential', sourceId: 'c1', required: true })] diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/copy-reconciliation.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/copy-reconciliation.ts index f1707ad1a3b..afea9cd5010 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/copy-reconciliation.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/copy-reconciliation.ts @@ -35,6 +35,20 @@ export function forkVisibleCopyables( return copyableUnmapped.filter((candidate) => !mappedKeys.has(forkRefKey(candidate))) } +/** + * The copy selection seeded once the diff settles: every REFERENCED candidate (deselecting one + * clears its references, so the common case needs no clicks). Unreferenced candidates - used by + * no synced workflow - start unselected: copying them is opt-in, so scratch data created in the + * source is never pushed by surprise. + */ +export function forkDefaultCopySelection(copyableUnmapped: ForkCopyableUnmapped[]): Set { + const keys = new Set() + for (const candidate of copyableUnmapped) { + if (candidate.referenced) keys.add(forkRefKey(candidate)) + } + return keys +} + /** Keys of the visible copy candidates actually selected for copy. */ export function forkCopyingKeys( visibleCopyables: ForkCopyableUnmapped[], @@ -83,3 +97,20 @@ export function forkRequiredPending( !copyingKeys.has(forkRefKey(entry)) ) } + +/** + * Human label for the kinds still failing the required gate, for "Map all required {label} first" + * messaging - shared by the Sync button's disabled tooltip (client gate) and the server gate's + * failure toast so both name the obstacle identically. Credentials and secrets are named + * explicitly: they are the map-only kinds that fail the required gate WITHOUT also appearing in + * the cleared-ref blockers (the collector excludes them), so they need their own wording. Any + * other kind falls back to "references". + */ +export function forkRequiredKindsLabel(kinds: ReadonlySet): string { + const credentials = kinds.has('credential') + const secrets = kinds.has('env-var') + if (credentials && secrets) return 'credentials and secrets' + if (credentials) return 'credentials' + if (secrets) return 'secrets' + return 'references' +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/promote-workspace-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/promote-workspace-modal.tsx index c2186faff72..9e5129e6dc3 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/promote-workspace-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/promote-workspace-modal.tsx @@ -11,6 +11,7 @@ import { ChipModalFooter, type ChipModalFooterSlotAction, ChipModalHeader, + cn, toast, } from '@sim/emcn' import { getErrorMessage } from '@sim/utils/errors' @@ -28,13 +29,19 @@ import { FileKindRow, ResourceKindRow, } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/fork-resource-picker/fork-resource-picker' -import { selectVisibleClearedRefs } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/cleared-refs-list' +import { + forkBlockerResolution, + selectVisibleClearedRefs, + splitForkClearedRefs, +} from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/cleared-refs-list' import { ResourceReconfigure } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/promote-workspace-modal/components/resource-reconfigure' import { effectiveForkTarget, forkCopyingKeys, + forkDefaultCopySelection, forkMappedCopyableKeys, forkRefKey, + forkRequiredKindsLabel, forkRequiredPending, forkVisibleCopyables, isForkRequiredComplete, @@ -127,7 +134,7 @@ const MAPPING_SECTION: Record forkRefKey(candidate) /** Sentinel option value for the editor's "Copy instead" entry - handled via onSelect, never sent. */ const COPY_INSTEAD_VALUE = '__copy_instead__' +/** Archived-workflow names shown in the sync confirm before truncating to "and X more". */ +const ARCHIVED_PREVIEW_LIMIT = 5 + interface EdgeOption { value: string label: string @@ -278,27 +288,33 @@ export function PromoteWorkspaceModal({ // Group the visible copy candidates by kind so each renders as its own expandable section // (chevron + tri-state select-all + count), matching the fork picker. Files nest in a folder ▸ - // file tree inside their section; every other kind is a flat searchable list. - const copyablesByKind = useMemo(() => { - const groups = new Map() + // file tree inside their section; every other kind is a flat list. Referenced and unreferenced + // candidates group separately: unreferenced ones (used by no synced workflow) render under a + // muted "Not used by any workflow" grouping and default to unselected. + const { referencedByKind, unreferencedByKind } = useMemo(() => { + const referenced = new Map() + const unreferenced = new Map() for (const candidate of visibleCopyables) { + const groups = candidate.referenced ? referenced : unreferenced const list = groups.get(candidate.kind) if (list) list.push(candidate) else groups.set(candidate.kind, [candidate]) } - return groups + return { referencedByKind: referenced, unreferencedByKind: unreferenced } }, [visibleCopyables]) - // Default every copyable referenced resource to "copy" once the diff loads, so the common case + // Default every REFERENCED copyable resource to "copy" once the diff loads, so the common case // (bring the referenced resources along) needs no clicks; the user can deselect to clear instead. - // Seed ONLY from a settled diff for the current direction: on a direction switch the reset clears - // `copyDefaulted`, but `useForkDiff` keeps the previous direction's payload (placeholderData) until - // the new fetch resolves - seeding from it would latch against stale keys and leave the real - // copyables unchecked, clearing their references on Sync. + // Unreferenced candidates start unselected (see `forkDefaultCopySelection`) - copying them is + // opt-in since nothing references them. Seed ONLY from a settled diff for the current direction: + // on a direction switch the reset clears `copyDefaulted`, but `useForkDiff` keeps the previous + // direction's payload (placeholderData) until the new fetch resolves - seeding from it would + // latch against stale keys and leave the real copyables unchecked, clearing their references + // on Sync. useEffect(() => { if (!open || diff.isPlaceholderData || copyableUnmapped.length === 0 || copyDefaulted) return setCopyDefaulted(true) - setCopySelected(new Set(copyableUnmapped.map(copyableKey))) + setCopySelected(forkDefaultCopySelection(copyableUnmapped)) }, [open, diff.isPlaceholderData, copyableUnmapped, copyDefaulted]) // Group dependents by their parent (kind:sourceId) once, so each mapping entry below gets a @@ -422,18 +438,20 @@ export function PromoteWorkspaceModal({ } } - // The references this sync will blank, reactively narrowed to the current selection. A resource + // The references this sync would blank, reactively narrowed to the current selection. A resource // is "resolved" once it has a mapping target OR is selected for copy - the same predicate drives // a `reference` (its own resource) and a `dependent` (its PARENT resource), so mapping or copying - // a parent KB makes its child document drop off. `workflow` refs always clear (not resolvable here). - const clearedRefsToShow = useMemo(() => { + // a parent KB makes its child document drop off. Then split: `reference`/`workflow` entries are + // BLOCKERS (Sync stays disabled while any remain - mirroring the server's zero-cleared-refs + // gate); `dependent` entries stay informational (the reconfigure flow owns them). + const { blockers: blockingRefs, informational: dependentClears } = useMemo(() => { const isResolved = (kind: string, sourceId: string) => { const key = `${kind}:${sourceId}` const entry = entriesByParent.get(key) const mapped = entry ? (targets[key] ?? entry.targetId ?? '') !== '' : false return mapped || copyingKeys.has(key) } - return selectVisibleClearedRefs(clearedRefs, isResolved) + return splitForkClearedRefs(selectVisibleClearedRefs(clearedRefs, isResolved)) }, [clearedRefs, entriesByParent, targets, copyingKeys]) // Per-kind status for the overview listing: "Fully mapped" or "n/total mapped", @@ -461,6 +479,14 @@ export function PromoteWorkspaceModal({ } }) + // Kinds whose required gate is still failing, so the Sync tooltip can name the actual + // obstacle. An unmapped credential/secret is NEVER a cleared-ref blocker (the collector + // excludes required kinds), so the required gate must not borrow the blocker message - + // it would point at a "Blocking sync" section that isn't rendered. + const pendingRequiredKinds = new Set( + kindSummaries.filter((summary) => summary.requiredPending).map((summary) => summary.kind) + ) + // Step 0 is the overview; each subsequent step edits one resource kind, entered via // "Edit mappings". Reconfigure cards render inline under the changed mapping (not as // their own steps) so the credential/KB context stays visible. `safeStep` guards @@ -479,8 +505,18 @@ export function PromoteWorkspaceModal({ mapping.isPlaceholderData || !diff.data || diff.isPlaceholderData + // Zero-blockers invariant (mirrors the server gate): Sync stays disabled while ANY reference + // would clear in a synced target workflow. `requiredComplete` covers the mapping entries + // (credentials/secrets and unresolved resource refs); `blockingRefs` additionally covers + // workflow-to-workflow references, which have no mapping entry to resolve. + const syncBlocked = blockingRefs.length > 0 const syncDisabled = - submitting || !otherWorkspaceId || !requiredComplete || !reconfigComplete || dataPending + submitting || + !otherWorkspaceId || + !requiredComplete || + !reconfigComplete || + syncBlocked || + dataPending const headsUp = (diff.data?.mcpReauthServerIds.length ?? 0) > 0 || (diff.data?.inlineSecretSources.length ?? 0) > 0 @@ -553,20 +589,22 @@ export function PromoteWorkspaceModal({ }) if (!result.promoteRunId) { + if (result.blockers.length > 0) { + // The server's authoritative gate re-found would-clear references (something changed + // between the preview and Sync). The mutation's settled invalidation refetches the + // diff, so the refreshed blocker list is already on its way in. + const count = result.blockers.length + toast.error( + `Sync blocked: ${count} reference${count === 1 ? '' : 's'} would break in the target. Review the updated list and try again.` + ) + return + } if (result.unmappedRequired.length > 0) { // Name the actual blocking kinds rather than always blaming credentials: the server // blocks on required REFERENCES (credentials and/or secrets); required dependents are // gated client-side before this runs (see the Sync button's disabled tooltip). const kinds = new Set(result.unmappedRequired.map((reference) => reference.kind)) - const what = - kinds.has('credential') && kinds.has('env-var') - ? 'credentials and secrets' - : kinds.has('credential') - ? 'credentials' - : kinds.has('env-var') - ? 'secrets' - : 'references' - toast.error(`Map all required ${what} first`) + toast.error(`Map all required ${forkRequiredKindsLabel(kinds)} first`) return } toast.error('Sync did not complete') @@ -631,6 +669,83 @@ export function PromoteWorkspaceModal({ ) }, [diff.data?.workflows]) + // Target workflows this sync archives (their source was deleted), named in the confirm modal so + // the overwrite warning is concrete - the push-to-parent case is the high-stakes one, so the + // target workspace is named explicitly there. + const archivedWorkflowNames = useMemo( + () => + workflowChanges + .filter((change) => change.action === 'archive') + .map((change) => change.currentName), + [workflowChanges] + ) + const targetWorkspaceName = + direction === 'push' ? (parent?.name ?? 'the parent workspace') : 'this workspace' + + // One expandable row per copyable kind present in `byKind` - shared by the referenced group + // and the unreferenced "Not used by any workflow" group so both render exactly like the fork + // picker (files as a folder tree, every other kind flat). + const renderCopyKindSections = ( + byKind: ReadonlyMap + ) => + COPYABLE_KIND_SECTIONS.map((section) => { + const candidates = byKind.get(section.kind) + if (!candidates || candidates.length === 0) return null + // The picker rows track item ids; copy selection is keyed `${kind}:${id}` + // (matching `copyableKey`), so derive the per-kind selected-id subset and + // re-prefix on toggle. + const selectedIds = new Set( + candidates + .filter((candidate) => copySelected.has(copyableKey(candidate))) + .map((candidate) => candidate.sourceId) + ) + const toggleMany = (ids: string[], checked: boolean) => + setCopySelected((prev) => { + const next = new Set(prev) + for (const id of ids) { + const key = `${section.kind}:${id}` + if (checked) next.add(key) + else next.delete(key) + } + return next + }) + const toggleAll = (selectAll: boolean) => + toggleMany( + candidates.map((candidate) => candidate.sourceId), + selectAll + ) + return section.kind === 'file' ? ( + ({ + id: candidate.sourceId, + label: candidate.label, + folderId: candidate.parentId, + folderName: candidate.parentLabel, + }))} + selected={selectedIds} + onToggleAll={toggleAll} + onToggleItem={(id, checked) => toggleMany([id], checked)} + onToggleMany={toggleMany} + disabled={submitting} + /> + ) : ( + ({ + id: candidate.sourceId, + label: candidate.label, + }))} + selected={selectedIds} + onToggleMany={toggleMany} + onToggleItem={(id, checked) => toggleMany([id], checked)} + disabled={submitting} + /> + ) + }) + // Right-cluster action sitting immediately left of the primary. The overview pairs // "Edit mappings" with Sync (entering the step walk); every editing step pairs Back // with Next (or with Sync on the last step). Back out of step 1 lands on the @@ -782,10 +897,31 @@ export function PromoteWorkspaceModal({ ) : null} - {clearedRefsToShow.length > 0 ? ( + {syncBlocked ? ( + +
+ {blockingRefs.map((ref, index) => ( +
+ {ref.blockLabel} would lose{' '} + {ref.fieldLabel} in{' '} + {ref.workflowName} — {forkBlockerResolution(ref)} +
+ ))} +
+

+ Sync is blocked while any of these remain, so every synced workflow stays fully + operational in the target. +

+
+ ) : null} + + {dependentClears.length > 0 ? (
- {clearedRefsToShow.map((ref, index) => ( + {dependentClears.map((ref, index) => (

- Map or copy a reference to keep it. Fields that reference another workflow, or - that hang off a remapped credential or knowledge base, are cleared regardless. + Fields that hang off a remapped credential or knowledge base are cleared — + re-pick them in the target after the sync.

) : null} @@ -806,67 +942,33 @@ export function PromoteWorkspaceModal({ {visibleCopyables.length > 0 ? (
- {COPYABLE_KIND_SECTIONS.map((section) => { - const candidates = copyablesByKind.get(section.kind) - if (!candidates || candidates.length === 0) return null - // The picker rows track item ids; copy selection is keyed `${kind}:${id}` - // (matching `copyableKey`), so derive the per-kind selected-id subset and - // re-prefix on toggle. - const selectedIds = new Set( - candidates - .filter((candidate) => copySelected.has(copyableKey(candidate))) - .map((candidate) => candidate.sourceId) - ) - const toggleMany = (ids: string[], checked: boolean) => - setCopySelected((prev) => { - const next = new Set(prev) - for (const id of ids) { - const key = `${section.kind}:${id}` - if (checked) next.add(key) - else next.delete(key) - } - return next - }) - const toggleAll = (selectAll: boolean) => - toggleMany( - candidates.map((candidate) => candidate.sourceId), - selectAll - ) - return section.kind === 'file' ? ( - ({ - id: candidate.sourceId, - label: candidate.label, - folderId: candidate.parentId, - folderName: candidate.parentLabel, - }))} - selected={selectedIds} - onToggleAll={toggleAll} - onToggleItem={(id, checked) => toggleMany([id], checked)} - onToggleMany={toggleMany} - disabled={submitting} - /> - ) : ( - ({ - id: candidate.sourceId, - label: candidate.label, - }))} - selected={selectedIds} - onToggleMany={toggleMany} - onToggleItem={(id, checked) => toggleMany([id], checked)} - disabled={submitting} - /> - ) - })} -

- These referenced resources aren't in the target yet. Selected ones are copied - during the sync; deselected ones have their references cleared. -

+ {referencedByKind.size > 0 ? ( + <> + {renderCopyKindSections(referencedByKind)} +

+ These referenced resources aren't in the target yet. Selected ones are + copied during the sync; a deselected one blocks the sync until it's mapped + or selected again. +

+ + ) : null} + {unreferencedByKind.size > 0 ? ( + <> +
0 && 'mt-1' + )} + > + Not used by any workflow +
+ {renderCopyKindSections(unreferencedByKind)} +

+ These aren't referenced by any synced workflow. Selected ones are copied + during the sync; deselected ones are simply left out. +

+ + ) : null}
) : null} @@ -882,17 +984,7 @@ export function PromoteWorkspaceModal({ ? takenTargetOwners(currentGroup.items, targets, entry) : EMPTY_TARGET_OWNERS return ( - - * - - ) : undefined - } - > + ) : null} {/* Always-on: every workflow this resource is used in, each expandable to - its blocks + dependent selectors (greyed when nothing to configure). */} + its blocks + dependent selectors (a plain row when nothing to configure). */} setConfirmSyncOpen(true), disabled: syncDisabled, - disabledTooltip: !requiredComplete - ? 'Map all required secrets first' - : !reconfigComplete - ? 'Reconfigure all required fields first' - : dataPending - ? 'Loading sync details…' - : undefined, + // Priority mirrors the resolution flow: clear the blockers, map the required + // resources, reconfigure their dependents - each failing gate names ITS + // obstacle (an unmapped credential/secret is a required-mapping failure, not + // a cleared-ref blocker; see `pendingRequiredKinds`). + disabledTooltip: syncBlocked + ? 'Resolve every blocking reference first — map it, copy it, or fix it in the source' + : !requiredComplete + ? `Map all required ${forkRequiredKindsLabel(pendingRequiredKinds)} first` + : !reconfigComplete + ? 'Reconfigure all required fields first' + : dataPending + ? 'Loading sync details…' + : undefined, } } /> @@ -993,7 +1091,29 @@ export function PromoteWorkspaceModal({ pending: submitting, pendingLabel: 'Syncing...', }} - /> + > + {archivedWorkflowNames.length > 0 ? ( +
+

+ Will be archived in {targetWorkspaceName}{' '} + (deleted in the source): +

+ {archivedWorkflowNames.slice(0, ARCHIVED_PREVIEW_LIMIT).map((name, index) => ( +
+ {name} +
+ ))} + {archivedWorkflowNames.length > ARCHIVED_PREVIEW_LIMIT ? ( +
+ and {archivedWorkflowNames.length - ARCHIVED_PREVIEW_LIMIT} more +
+ ) : null} +
+ ) : null} + ) } diff --git a/apps/sim/executor/handlers/workflow/workflow-handler.test.ts b/apps/sim/executor/handlers/workflow/workflow-handler.test.ts index 2823d3d1383..1ff65759ed1 100644 --- a/apps/sim/executor/handlers/workflow/workflow-handler.test.ts +++ b/apps/sim/executor/handlers/workflow/workflow-handler.test.ts @@ -5,6 +5,21 @@ import { WorkflowBlockHandler } from '@/executor/handlers/workflow/workflow-hand import type { ExecutionContext } from '@/executor/types' import type { SerializedBlock } from '@/serializer/types' +const { mockExecutorExecute, mockCreateSnapshot } = vi.hoisted(() => ({ + mockExecutorExecute: vi.fn(), + mockCreateSnapshot: vi.fn(), +})) + +vi.mock('@/executor', () => ({ + Executor: class { + execute = mockExecutorExecute + }, +})) + +vi.mock('@/lib/logs/execution/snapshot/service', () => ({ + snapshotService: { createSnapshotWithDeduplication: mockCreateSnapshot }, +})) + vi.mock('@/lib/auth/internal', () => ({ generateInternalToken: vi.fn().mockResolvedValue('test-token'), })) @@ -161,6 +176,119 @@ describe('WorkflowBlockHandler', () => { }) }) + describe('workspace containment', () => { + const inputs = { workflowId: 'child-workflow-id' } + + it('should fail a cross-workspace child in the draft loader path', async () => { + const ctx = { ...mockContext, workspaceId: 'workspace-parent' } + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + data: { + name: 'Foreign Workflow', + workspaceId: 'workspace-other', + state: { blocks: {}, edges: [], loops: {}, parallels: {} }, + }, + }), + }) + + await expect(handler.execute(ctx, mockBlock, inputs)).rejects.toThrow( + 'Child workflow child-workflow-id belongs to a different workspace and cannot be executed' + ) + expect(mockCreateSnapshot).not.toHaveBeenCalled() + expect(mockExecutorExecute).not.toHaveBeenCalled() + }) + + it('should fail a cross-workspace child in the deployed loader path', async () => { + const ctx = { + ...mockContext, + workspaceId: 'workspace-parent', + isDeployedContext: true, + } + + mockFetch.mockImplementation(async (url: unknown) => { + if (String(url).includes('/deployed')) { + return { + ok: true, + json: () => + Promise.resolve({ + data: { + deployedState: { blocks: {}, edges: [], loops: {}, parallels: {} }, + }, + }), + } + } + return { + ok: true, + json: () => + Promise.resolve({ + data: { + name: 'Foreign Workflow', + workspaceId: 'workspace-other', + variables: {}, + }, + }), + } + }) + + await expect(handler.execute(ctx, mockBlock, inputs)).rejects.toThrow( + 'Child workflow child-workflow-id belongs to a different workspace and cannot be executed' + ) + expect(mockCreateSnapshot).not.toHaveBeenCalled() + expect(mockExecutorExecute).not.toHaveBeenCalled() + }) + + it('should execute a same-workspace child as before', async () => { + const ctx = { ...mockContext, workspaceId: 'workspace-parent' } + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + data: { + name: 'Child Workflow', + workspaceId: 'workspace-parent', + state: { blocks: {}, edges: [], loops: {}, parallels: {} }, + }, + }), + }) + mockCreateSnapshot.mockResolvedValue({ snapshot: { id: 'snapshot-1' } }) + mockExecutorExecute.mockResolvedValue({ success: true, output: { data: 'ok' } }) + + const result = await handler.execute(ctx, mockBlock, inputs) + + expect(result).toMatchObject({ + success: true, + childWorkflowId: 'child-workflow-id', + childWorkflowName: 'Child Workflow', + childWorkflowSnapshotId: 'snapshot-1', + result: { data: 'ok' }, + }) + expect(mockExecutorExecute).toHaveBeenCalledWith('child-workflow-id') + }) + + it('should fail closed when the executing context has no workspace', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + data: { + name: 'Child Workflow', + workspaceId: 'workspace-parent', + state: { blocks: {}, edges: [], loops: {}, parallels: {} }, + }, + }), + }) + + await expect(handler.execute(mockContext, mockBlock, inputs)).rejects.toThrow( + 'Cannot execute child workflow child-workflow-id: executing context has no workspace' + ) + expect(mockExecutorExecute).not.toHaveBeenCalled() + }) + }) + describe('loadChildWorkflow', () => { it('should return null for 404 responses', async () => { const workflowId = 'non-existent-workflow' diff --git a/apps/sim/executor/handlers/workflow/workflow-handler.ts b/apps/sim/executor/handlers/workflow/workflow-handler.ts index 2a8d3d73c31..eea6b4a3004 100644 --- a/apps/sim/executor/handlers/workflow/workflow-handler.ts +++ b/apps/sim/executor/handlers/workflow/workflow-handler.ts @@ -109,6 +109,8 @@ export class WorkflowBlockHandler implements BlockHandler { throw new Error(`Child workflow ${workflowId} not found`) } + this.assertChildWorkflowInWorkspace(workflowId, childWorkflow.workspaceId, ctx.workspaceId) + childWorkflowName = childWorkflow.name || 'Unknown Workflow' logger.info( @@ -324,6 +326,34 @@ export class WorkflowBlockHandler implements BlockHandler { return { chain, rootError: rootError.trim() || 'Unknown error' } } + /** + * Ensures the child workflow belongs to the same workspace as the executing + * context before any child execution starts. Blocks silent cross-workspace + * execution (e.g. a manual workflow id still pointing at the source + * workspace after a fork), which would otherwise run the foreign workflow + * with the parent workspace's environment and billing. Fails closed when the + * executing context carries no workspace id: every server execution path + * populates it via execution-core, so a missing value indicates a context + * that must not silently bypass the check. The error message intentionally + * omits the foreign workspace id. + */ + private assertChildWorkflowInWorkspace( + childWorkflowId: string, + childWorkspaceId: string | null | undefined, + parentWorkspaceId: string | undefined + ): void { + if (!parentWorkspaceId) { + throw new Error( + `Cannot execute child workflow ${childWorkflowId}: executing context has no workspace` + ) + } + if (childWorkspaceId !== parentWorkspaceId) { + throw new Error( + `Child workflow ${childWorkflowId} belongs to a different workspace and cannot be executed` + ) + } + } + private async loadChildWorkflow(workflowId: string, userId?: string) { const headers = await buildAuthHeaders(userId) const url = buildAPIUrl(`/api/workflows/${workflowId}`) @@ -378,6 +408,7 @@ export class WorkflowBlockHandler implements BlockHandler { return { name: workflowData.name, + workspaceId: (workflowData.workspaceId ?? null) as string | null, serializedState: serializedWorkflow, variables: workflowVariables, workflowState: workflowStateWithVariables, @@ -461,6 +492,7 @@ export class WorkflowBlockHandler implements BlockHandler { return { name: childName, + workspaceId: (wfData?.workspaceId ?? null) as string | null, serializedState: serializedWorkflow, variables: workflowVariables, workflowState: workflowStateWithVariables, diff --git a/apps/sim/lib/api/contracts/workflows.ts b/apps/sim/lib/api/contracts/workflows.ts index 828d91db17b..72bb635cf71 100644 --- a/apps/sim/lib/api/contracts/workflows.ts +++ b/apps/sim/lib/api/contracts/workflows.ts @@ -1,4 +1,5 @@ import { z } from 'zod' +import { workspaceIdSchema } from '@/lib/api/contracts/primitives' import { defineRouteContract } from '@/lib/api/contracts/types' const subBlockValuesSchema = z.record(z.string(), z.record(z.string(), z.unknown())) @@ -343,6 +344,13 @@ export const executeWorkflowBodySchema = z.object({ startBlockId: z.string().optional(), stopAfterBlockId: z.string().optional(), runFromBlock: executeWorkflowRunFromBlockSchema.optional(), + /** + * Workspace of the parent execution when this call is a workflow-in-workflow + * invocation (e.g. the agent `workflow_executor` tool). When present, the + * route rejects execution of a workflow that lives in a different workspace. + * Direct API callers omit it and are unaffected. + */ + parentWorkspaceId: workspaceIdSchema.optional(), }) export type ExecuteWorkflowBody = z.input diff --git a/apps/sim/lib/api/contracts/workspace-fork.ts b/apps/sim/lib/api/contracts/workspace-fork.ts index cf7e2430dfa..ec1280b9d15 100644 --- a/apps/sim/lib/api/contracts/workspace-fork.ts +++ b/apps/sim/lib/api/contracts/workspace-fork.ts @@ -331,24 +331,33 @@ const forkClearedRefBaseSchema = z.object({ }) /** - * A reference in a synced source workflow that WILL be blanked in the target by this sync, with the + * A reference in a synced source workflow that this sync would blank in the target, with the * labels to phrase it as "{blockLabel} will lose {fieldLabel} in workflow {workflowName}". A * discriminated union on `cause` so clients narrow exhaustively (only `dependent` carries the parent * fields): - * - `reference`: an unmapped remappable resource (`kind`) - drops off the list once the user maps - * OR copies it (matched to a mapping entry by `${kind}:${sourceId}`). - * - `workflow`: a `workflow-selector`/`workflow_input` ref to a workflow not in the target - - * always cleared (cannot be fixed in the modal). - * - `dependent`: a create-target dependent selector a remapped parent clears. Carries the parent - * (`parentKind`/`parentSourceId`); when the child follows its parent (a document under a knowledge - * base) the client drops it once that parent is mapped/copied, else it stays (credential label / - * table column). + * - `reference`: an unmapped remappable resource (`kind`). BLOCKS the sync until the user maps it + * OR selects it for copy (matched to a mapping entry by `${kind}:${sourceId}`); the entry drops + * off the blocker list once resolved. + * - `workflow`: a `workflow-selector`/`workflow_input` ref to a workflow not carried into the + * target. BLOCKS the sync; resolved outside the modal (deploy the referenced workflow in the + * source, or remove/fix the reference). + * - `dependent`: a create-target dependent selector a remapped parent clears. NOT a blocker (the + * reconfigure flow owns dependents). Carries the parent (`parentKind`/`parentSourceId`); when the + * child follows its parent (a document under a knowledge base) the client drops it once that + * parent is mapped/copied, else it stays (credential label / table column). */ export const forkClearedRefSchema = z.discriminatedUnion('cause', [ forkClearedRefBaseSchema.extend({ cause: z.literal('reference'), /** The unmapped remappable resource (never `workflow`). */ kind: forkRemapKindSchema, + /** + * True when the referenced resource no longer exists (deleted/archived) in the SOURCE + * workspace, so it cannot be offered for copy - the resolution is mapping the dead source id + * to a live target resource, or fixing the source workflow. Collected as `false` and + * annotated post-collection by the source-liveness check (`annotateForkClearedRefSourceLiveness`). + */ + sourceDeleted: z.boolean(), }), forkClearedRefBaseSchema.extend({ cause: z.literal('workflow'), @@ -365,6 +374,42 @@ export const forkClearedRefSchema = z.discriminatedUnion('cause', [ ]) export type ForkClearedRef = z.output +/** + * Why a would-clear reference blocks the sync, so clients can phrase the resolution: + * - `unmapped-copyable`: a live copyable-kind resource (table / KB / file / custom tool / skill) + * with no target mapping - resolve by mapping it or selecting it for copy. + * - `unmapped-mcp-server`: a live external MCP server with no target mapping - resolve by mapping + * (MCP servers are never copied; create one in the target first if none exists). + * - `source-deleted`: the referenced resource was deleted in the source - resolve by mapping the + * dead id to an existing live target resource, or by fixing/archiving the source workflow. + * - `workflow-missing`: a cross-workflow reference to a workflow not carried into the target - + * resolve by deploying the referenced workflow in the source, or removing the reference. + */ +export const forkSyncBlockerReasonSchema = z.enum([ + 'unmapped-copyable', + 'unmapped-mcp-server', + 'source-deleted', + 'workflow-missing', +]) +export type ForkSyncBlockerReason = z.output + +/** + * One reference that blocked a promote at the server gate (the authoritative in-tx re-check of + * the would-clear set). Mirrors the cleared-ref labels so the client can phrase each blocker; + * `kind` is `workflow` for cross-workflow references. `sourceLabel` may fall back to `sourceId` + * (the gate skips display-label loading); the modal's refreshed diff carries the labeled list. + */ +export const forkSyncBlockerSchema = z.object({ + workflowName: z.string(), + blockLabel: z.string(), + fieldLabel: z.string(), + kind: z.union([forkRemapKindSchema, z.literal('workflow')]), + sourceId: z.string(), + sourceLabel: z.string(), + reason: forkSyncBlockerReasonSchema, +}) +export type ForkSyncBlocker = z.output + export const getForkDiffQuerySchema = z.object({ otherWorkspaceId: workspaceIdSchema, direction: forkDirectionSchema, @@ -395,11 +440,13 @@ export const getForkDiffContract = defineRouteContract({ /** Every workflow each mapped resource is used in, for the always-on reconfigure listing. */ resourceUsages: z.array(forkResourceUsageSchema), /** - * Referenced resources with no target mapping that the sync can copy into the target - * (fork-style), so the user can copy instead of mapping each one by hand. Default-selected - * in the modal; documents under a selected knowledge base are copied automatically. - * `parentId`/`parentLabel` carry the folder grouping for file entries (id + name); they - * are null for non-file kinds and for files at the workspace root. + * Copyable resources with no target mapping that the sync can copy into the target + * (fork-style). `referenced: true` entries are referenced by the synced workflows and + * default-selected in the modal (deselecting one clears its references); `referenced: false` + * entries exist in the source but are used by no synced workflow and default-unselected + * (skipping one breaks nothing). Documents under a selected knowledge base are copied + * automatically. `parentId`/`parentLabel` carry the folder grouping for file entries + * (id + name); they are null for non-file kinds and for files at the workspace root. */ copyableUnmapped: z.array( z.object({ @@ -408,6 +455,8 @@ export const getForkDiffContract = defineRouteContract({ label: z.string(), parentId: z.string().nullable(), parentLabel: z.string().nullable(), + /** Whether any synced workflow references this resource (drives the copy default). */ + referenced: z.boolean(), }) ), /** @@ -453,9 +502,10 @@ export const forkDependentValueEntrySchema = z.object({ export type ForkDependentValueEntry = z.input /** - * Source resource ids (by kind) the user chose to copy into the target before the sync gate, - * for referenced-but-unmapped resources. Each kind's documents under a copied knowledge base - * are discovered + copied automatically (the user selects only the parent resources). + * Source resource ids (by kind) the user chose to copy into the target before the sync gate - + * unmapped resources, whether referenced by the synced workflows or not. Each kind's documents + * under a copied knowledge base are discovered + copied automatically (the user selects only + * the parent resources). */ export const promoteCopyResourcesSchema = z.object({ knowledgeBases: forkResourceIdList, @@ -495,6 +545,12 @@ export const promoteForkContract = defineRouteContract({ redeployed: z.number().int(), deployFailed: z.number().int(), unmappedRequired: z.array(forkUnmappedReferenceSchema), + /** + * References the sync would have cleared, so it was blocked without writing (the + * authoritative in-tx gate; non-empty only when `promoteRunId` is empty). Normally the + * client blocks first - this fires only when the state changed between preview and Sync. + */ + blockers: z.array(forkSyncBlockerSchema), /** Workflows whose required dependent fields the target must re-pick post-sync. */ needsConfiguration: z.array(forkNeedsConfigurationSchema), /** Workflows whose optional dependent fields a swap cleared (surfaced, not gated). */ diff --git a/apps/sim/lib/workflows/persistence/remap-internal-ids.test.ts b/apps/sim/lib/workflows/persistence/remap-internal-ids.test.ts index d6261bc01c5..7cdc22f39b2 100644 --- a/apps/sim/lib/workflows/persistence/remap-internal-ids.test.ts +++ b/apps/sim/lib/workflows/persistence/remap-internal-ids.test.ts @@ -121,8 +121,10 @@ describe('remapWorkflowReferencesInSubBlocks', () => { // The `inputMapping` belongs to the ACTIVE canonical mode's workflow only. resolveCanonicalMode // picks the active mode (block.data.canonicalModes override, else the value heuristic); the wipe - // fires iff the ACTIVE mode's workflowId was removed by the remap. clearUnmapped: true throughout. - it('keeps inputMapping: active basic valid + dormant advanced stale (no override)', () => { + // fires iff the ACTIVE mode's workflow was removed by the remap. Only the SELECTOR is ever + // remapped/cleared - the manual member passes through verbatim - so an active-advanced (manual) + // mode never wipes inputMapping. clearUnmapped: true throughout. + it('keeps inputMapping: active basic valid + dormant advanced manual preserved (no override)', () => { const subBlocks: SubBlockRecord = { workflowId: { id: 'workflowId', type: 'workflow-selector', value: 'wf-src' }, manualWorkflowId: { id: 'manualWorkflowId', type: 'short-input', value: 'wf-unknown' }, @@ -130,11 +132,12 @@ describe('remapWorkflowReferencesInSubBlocks', () => { } const result = remapWorkflowReferencesInSubBlocks(subBlocks, map, { clearUnmapped: true }) expect(result.workflowId.value).toBe('wf-dst') - expect(result.manualWorkflowId.value).toBe('') + // Manual member is user-owned: preserved verbatim (never cleared), even while dormant. + expect(result.manualWorkflowId.value).toBe('wf-unknown') expect(result.inputMapping.value).toBe('{"a":"b"}') }) - it('wipes inputMapping: active advanced stale (canonicalModes override) + dormant basic valid', () => { + it('keeps inputMapping: active advanced manual preserved (canonicalModes override) + dormant basic remapped', () => { const subBlocks: SubBlockRecord = { workflowId: { id: 'workflowId', type: 'workflow-selector', value: 'wf-src' }, manualWorkflowId: { id: 'manualWorkflowId', type: 'short-input', value: 'wf-unknown' }, @@ -144,12 +147,14 @@ describe('remapWorkflowReferencesInSubBlocks', () => { clearUnmapped: true, canonicalModes: { workflowId: 'advanced' }, }) + // Active advanced manual is preserved, so its inputMapping survives; the dormant basic selector + // still remaps. expect(result.workflowId.value).toBe('wf-dst') - expect(result.manualWorkflowId.value).toBe('') - expect(result.inputMapping.value).toBe('') + expect(result.manualWorkflowId.value).toBe('wf-unknown') + expect(result.inputMapping.value).toBe('{"a":"b"}') }) - it('wipes inputMapping: active basic stale (heuristic) + dormant advanced valid', () => { + it('wipes inputMapping: active basic selector cleared (heuristic) + dormant advanced manual preserved', () => { const subBlocks: SubBlockRecord = { workflowId: { id: 'workflowId', type: 'workflow-selector', value: 'wf-unknown' }, manualWorkflowId: { id: 'manualWorkflowId', type: 'short-input', value: 'wf-src' }, @@ -157,22 +162,24 @@ describe('remapWorkflowReferencesInSubBlocks', () => { } const result = remapWorkflowReferencesInSubBlocks(subBlocks, map, { clearUnmapped: true }) expect(result.workflowId.value).toBe('') - expect(result.manualWorkflowId.value).toBe('wf-dst') + // Manual preserved verbatim (not remapped to wf-dst); the active basic selector clearing is what + // wipes inputMapping. + expect(result.manualWorkflowId.value).toBe('wf-src') expect(result.inputMapping.value).toBe('') }) - it('wipes inputMapping: active advanced stale + basic empty (heuristic)', () => { + it('keeps inputMapping: active advanced manual preserved + basic empty (heuristic)', () => { const subBlocks: SubBlockRecord = { workflowId: { id: 'workflowId', type: 'workflow-selector', value: '' }, manualWorkflowId: { id: 'manualWorkflowId', type: 'short-input', value: 'wf-unknown' }, inputMapping: { id: 'inputMapping', type: 'input-mapping', value: '{"a":"b"}' }, } const result = remapWorkflowReferencesInSubBlocks(subBlocks, map, { clearUnmapped: true }) - expect(result.manualWorkflowId.value).toBe('') - expect(result.inputMapping.value).toBe('') + expect(result.manualWorkflowId.value).toBe('wf-unknown') + expect(result.inputMapping.value).toBe('{"a":"b"}') }) - it('keeps inputMapping: both modes valid', () => { + it('keeps inputMapping: both modes valid (selector remapped, manual preserved)', () => { const subBlocks: SubBlockRecord = { workflowId: { id: 'workflowId', type: 'workflow-selector', value: 'wf-src' }, manualWorkflowId: { id: 'manualWorkflowId', type: 'short-input', value: 'sub-src' }, @@ -180,27 +187,27 @@ describe('remapWorkflowReferencesInSubBlocks', () => { } const result = remapWorkflowReferencesInSubBlocks(subBlocks, map, { clearUnmapped: true }) expect(result.workflowId.value).toBe('wf-dst') - expect(result.manualWorkflowId.value).toBe('sub-dst') + expect(result.manualWorkflowId.value).toBe('sub-src') expect(result.inputMapping.value).toBe('{"a":"b"}') }) - it('remaps the advanced-mode manualWorkflowId override', () => { + it('does not remap the advanced manualWorkflowId (manual is user-owned)', () => { const subBlocks: SubBlockRecord = { manualWorkflowId: { id: 'manualWorkflowId', type: 'short-input', value: 'wf-src' }, } const result = remapWorkflowReferencesInSubBlocks(subBlocks, map) - expect(result.manualWorkflowId.value).toBe('wf-dst') + expect(result.manualWorkflowId.value).toBe('wf-src') }) - it('remaps a comma-separated manualWorkflowIds list', () => { + it('does not remap the manual comma-separated manualWorkflowIds list (manual is user-owned)', () => { const subBlocks: SubBlockRecord = { manualWorkflowIds: { id: 'manualWorkflowIds', type: 'short-input', value: 'wf-src, sub-src' }, } const result = remapWorkflowReferencesInSubBlocks(subBlocks, map) - expect(result.manualWorkflowIds.value).toBe('wf-dst,sub-dst') + expect(result.manualWorkflowIds.value).toBe('wf-src, sub-src') }) - it('drops unmapped ids from a manualWorkflowIds list when clearUnmapped is set', () => { + it('preserves the manual manualWorkflowIds list verbatim even under clearUnmapped', () => { const subBlocks: SubBlockRecord = { manualWorkflowIds: { id: 'manualWorkflowIds', @@ -209,7 +216,47 @@ describe('remapWorkflowReferencesInSubBlocks', () => { }, } const result = remapWorkflowReferencesInSubBlocks(subBlocks, map, { clearUnmapped: true }) - expect(result.manualWorkflowIds.value).toBe('wf-dst') + expect(result.manualWorkflowIds.value).toBe('wf-src,wf-unknown') + }) + + // The advanced manual field is user-owned: ANY free-form value - env ref, literal id, tag, or + // arbitrary text - is preserved verbatim under clearUnmapped (active advanced), and its sibling + // inputMapping is never wiped. One passthrough covers every free-form edge case at once. + it.each([ + ['env ref', '{{MY_WORKFLOW_ID}}'], + ['literal source-workspace id', 'wf-unknown'], + ['block-output tag', ''], + ['arbitrary text', 'not an id at all'], + ])('preserves a manual %s value and its inputMapping (active advanced)', (_label, value) => { + const subBlocks: SubBlockRecord = { + workflowId: { id: 'workflowId', type: 'workflow-selector', value: '' }, + manualWorkflowId: { id: 'manualWorkflowId', type: 'short-input', value }, + inputMapping: { id: 'inputMapping', type: 'input-mapping', value: '{"a":"b"}' }, + } + const result = remapWorkflowReferencesInSubBlocks(subBlocks, map, { clearUnmapped: true }) + expect(result.manualWorkflowId.value).toBe(value) + expect(result.inputMapping.value).toBe('{"a":"b"}') + }) + + // The one behavioral change vs. selector handling: a literal source-workspace id typed into the + // MANUAL field that WOULD map to a copied target is left AS-IS (not remapped), because manual is + // user-owned - while the SELECTOR with the same id still remaps to the copied target. + it('leaves a mapped literal id in the manual field as-is while the selector remaps it', () => { + const manualSubBlocks: SubBlockRecord = { + manualWorkflowId: { id: 'manualWorkflowId', type: 'short-input', value: 'wf-src' }, + } + expect( + remapWorkflowReferencesInSubBlocks(manualSubBlocks, map, { clearUnmapped: true }) + .manualWorkflowId.value + ).toBe('wf-src') + + const selectorSubBlocks: SubBlockRecord = { + workflowId: { id: 'workflowId', type: 'workflow-selector', value: 'wf-src' }, + } + expect( + remapWorkflowReferencesInSubBlocks(selectorSubBlocks, map, { clearUnmapped: true }).workflowId + .value + ).toBe('wf-dst') }) it('remaps a multi-select workflowSelector array', () => { @@ -220,11 +267,64 @@ describe('remapWorkflowReferencesInSubBlocks', () => { expect(result.workflowSelector.value).toEqual(['wf-dst', 'sub-dst']) }) - // create-fork scopes its workflow id map to the workflows ACTUALLY copied (deployed state - // loaded). With BOTH the parent (`wf-src`) and child (`sub-src`) workflows copied, every - // reference variety must remap to the child id (NOT clear), even under fork-create's - // clearUnmapped policy - the explicit "both deployed and copied" guard. - it('remaps every reference variety when both referenced workflows are copied (clearUnmapped)', () => { + it('clears unmapped ids from the structured workflowSelector list under clearUnmapped', () => { + const subBlocks: SubBlockRecord = { + workflowSelector: { + id: 'workflowSelector', + type: 'dropdown', + value: ['wf-src', 'wf-unknown'], + }, + } + const result = remapWorkflowReferencesInSubBlocks(subBlocks, map, { clearUnmapped: true }) + expect(result.workflowSelector.value).toEqual(['wf-dst']) + }) + + // The sim workspace-event trigger's workflow filter: a multi-select `dropdown` with baseKey + // `workflowIds` whose options are workspace workflow ids - a structured (selector-sourced) + // list, remapped exactly like `workflowSelector`. + it('remaps the workspace-event trigger workflowIds dropdown list', () => { + const subBlocks: SubBlockRecord = { + workflowIds: { id: 'workflowIds', type: 'dropdown', value: ['wf-src', 'sub-src'] }, + } + const result = remapWorkflowReferencesInSubBlocks(subBlocks, map) + expect(result.workflowIds.value).toEqual(['wf-dst', 'sub-dst']) + }) + + it('drops unmapped ids from the workflowIds dropdown under clearUnmapped', () => { + const subBlocks: SubBlockRecord = { + workflowIds: { id: 'workflowIds', type: 'dropdown', value: ['wf-src', 'wf-unknown'] }, + } + const result = remapWorkflowReferencesInSubBlocks(subBlocks, map, { clearUnmapped: true }) + expect(result.workflowIds.value).toEqual(['wf-dst']) + }) + + // The TYPE gate: the legacy logs block's `workflowIds` is a free-form short-input (manual, + // user-owned), so it must pass through verbatim even though its baseKey matches. + it('leaves a short-input workflowIds (legacy logs block, user-owned) untouched under clearUnmapped', () => { + const subBlocks: SubBlockRecord = { + workflowIds: { id: 'workflowIds', type: 'short-input', value: 'wf-src,wf-unknown' }, + } + const result = remapWorkflowReferencesInSubBlocks(subBlocks, map, { clearUnmapped: true }) + expect(result.workflowIds.value).toBe('wf-src,wf-unknown') + }) + + // The baseKey gate: dropdowns whose baseKey is neither `workflowSelector` nor `workflowIds` + // (event pickers, status filters, ...) hold non-workflow values and are never rewritten. + it('leaves other dropdowns untouched (only workflow-list baseKeys are remapped)', () => { + const subBlocks: SubBlockRecord = { + eventType: { id: 'eventType', type: 'dropdown', value: 'wf-src' }, + level: { id: 'level', type: 'dropdown', value: ['wf-src'] }, + } + const result = remapWorkflowReferencesInSubBlocks(subBlocks, map, { clearUnmapped: true }) + expect(result.eventType.value).toBe('wf-src') + expect(result.level.value).toEqual(['wf-src']) + }) + + // create-fork scopes its workflow id map to the workflows ACTUALLY copied (deployed state loaded). + // With BOTH `wf-src` and `sub-src` copied, the SELECTOR varieties remap to the child ids; the + // free-form MANUAL varieties (`manualWorkflowId`, `manualWorkflowIds`) are user-owned and pass + // through verbatim, even under fork-create's clearUnmapped policy. + it('remaps selector varieties and preserves manual varieties when both workflows are copied (clearUnmapped)', () => { const subBlocks: SubBlockRecord = { selector: { id: 'selector', type: 'workflow-selector', value: 'wf-src' }, inputMapping: { id: 'inputMapping', type: 'input-mapping', value: '{"a":"b"}' }, @@ -243,17 +343,19 @@ describe('remapWorkflowReferencesInSubBlocks', () => { const result = remapWorkflowReferencesInSubBlocks(subBlocks, map, { clearUnmapped: true }) expect(result.selector.value).toBe('wf-dst') expect(result.inputMapping.value).toBe('{"a":"b"}') - expect(result.manualWorkflowId.value).toBe('sub-dst') - expect(result.manualWorkflowIds.value).toBe('wf-dst,sub-dst') + // Manual varieties pass through verbatim (not remapped to the child ids). + expect(result.manualWorkflowId.value).toBe('sub-src') + expect(result.manualWorkflowIds.value).toBe('wf-src, sub-src') expect(result.workflowSelector.value).toEqual(['wf-dst', 'sub-dst']) const tools = result.tools.value as Array<{ type: string; params?: { workflowId?: string } }> expect(tools[0].params?.workflowId).toBe('sub-dst') expect(tools[1]).toEqual({ type: 'custom-tool', customToolId: 'ct-1' }) }) - // A deployed source workflow whose state failed to load is excluded from the scoped fork map, - // so a copied workflow's reference to it clears (never dangles at a never-created child id). - it('clears references to a deployed-but-uncopied workflow absent from the scoped map', () => { + // A deployed source workflow whose state failed to load is excluded from the scoped fork map, so a + // copied workflow's SELECTOR reference to it clears (never dangles at a never-created child id). The + // free-form manual list is user-owned and preserved verbatim. + it('clears selector references to a deployed-but-uncopied workflow (manual list preserved)', () => { const subBlocks: SubBlockRecord = { selector: { id: 'selector', type: 'workflow-selector', value: 'wf-uncopied' }, inputMapping: { id: 'inputMapping', type: 'input-mapping', value: '{"a":"b"}' }, @@ -271,7 +373,7 @@ describe('remapWorkflowReferencesInSubBlocks', () => { const result = remapWorkflowReferencesInSubBlocks(subBlocks, map, { clearUnmapped: true }) expect(result.selector.value).toBe('') expect(result.inputMapping.value).toBe('') - expect(result.manualWorkflowIds.value).toBe('wf-dst') + expect(result.manualWorkflowIds.value).toBe('wf-src,wf-uncopied') expect(result.tools.value as unknown[]).toHaveLength(0) }) }) diff --git a/apps/sim/lib/workflows/persistence/remap-internal-ids.ts b/apps/sim/lib/workflows/persistence/remap-internal-ids.ts index d21d12c6e1e..5a42bcd4fce 100644 --- a/apps/sim/lib/workflows/persistence/remap-internal-ids.ts +++ b/apps/sim/lib/workflows/persistence/remap-internal-ids.ts @@ -126,17 +126,24 @@ export function remapVariableIdsInSubBlocks( } /** - * Rewrite cross-workflow references through a workflow id map: single - * `workflow-selector` / `manualWorkflowId` values, multi-workflow lists - * (`workflowSelector` multi-select + comma-separated `manualWorkflowIds`, as used - * by the logs block), and `workflow_input` sub-workflow tools nested in a - * `tool-input` array (an agent calling another workflow as a tool). + * Rewrite cross-workflow references through a workflow id map. Only SELECTOR-sourced (structured) + * references are remapped/cleared: the basic `workflow-selector` value, the multi-select + * `workflowSelector` list (logs block), the workspace-event trigger's multi-select `workflowIds` + * dropdown (its options are workspace workflow ids), and `workflow_input` sub-workflow tools + * nested in a `tool-input` array (an agent picking another workflow as a tool - its + * `params.workflowId` comes from the workflow picker, never free-form input). * - * `clearUnmapped` controls the cross-workspace case: fork/promote pass `true` so a - * reference to a workflow that wasn't copied is cleared/dropped rather than left - * pointing at the source workspace (a silent cross-workspace execution). Same- - * workspace duplication leaves it `false` to preserve references to untouched - * sibling workflows. + * The advanced, free-form MANUAL fields (`manualWorkflowId`, comma-separated `manualWorkflowIds`) + * are user-owned and pass through VERBATIM - mirroring `manualCredential` in the fork remap - so a + * hand-typed value (an env ref `{{VAR}}`, a `` tag, a literal id, or arbitrary text) + * is never rewritten or cleared. The `workflowIds` handling is gated on subblock TYPE `dropdown` + * for the same reason: the legacy logs block's `workflowIds` is a free-form `short-input` + * (user-owned, verbatim), and only the workspace-event trigger uses a `workflowIds` dropdown. + * + * `clearUnmapped` controls the cross-workspace case for those selector references: fork/promote pass + * `true` so a selector pointing at a workflow that wasn't copied is cleared/dropped rather than left + * pointing at the source workspace (a silent cross-workspace execution). Same-workspace duplication + * leaves it `false` to preserve references to untouched sibling workflows. */ export function remapWorkflowReferencesInSubBlocks( subBlocks: SubBlockRecord, @@ -152,7 +159,8 @@ export function remapWorkflowReferencesInSubBlocks( } // The `workflowId` canonical pair: basic `workflow-selector` + advanced `manualWorkflowId`. Capture // each key (by type/baseKey, regardless of value) and its ORIGINAL value so the inputMapping wipe - // below can decide on the ACTIVE mode's disposition via `resolveCanonicalMode`. + // below can decide on the ACTIVE mode's disposition via `resolveCanonicalMode`. Only the basic + // selector is ever remapped; the advanced manual member is captured for mode resolution only. let basicId: string | undefined let basicValue = '' let advancedId: string | undefined @@ -168,15 +176,23 @@ export function remapWorkflowReferencesInSubBlocks( advancedId = key advancedValue = typeof subBlock.value === 'string' ? subBlock.value : '' } + // Remap only the SELECTOR member; the manual `manualWorkflowId` passes through verbatim. if ( - (subBlock.type === 'workflow-selector' || baseKey === 'manualWorkflowId') && + subBlock.type === 'workflow-selector' && typeof subBlock.value === 'string' && subBlock.value ) { updated[key] = { ...subBlock, value: remapScalar(subBlock.value) } continue } - if (baseKey === 'manualWorkflowIds' || baseKey === 'workflowSelector') { + // Remap only the STRUCTURED multi-workflow lists: the logs block's `workflowSelector` and + // the workspace-event trigger's `workflowIds` dropdown. The latter is gated on TYPE + // `dropdown` so the legacy logs block's `workflowIds` short-input (manual, user-owned) + // passes through verbatim, as does the manual comma-separated `manualWorkflowIds`. + if ( + baseKey === 'workflowSelector' || + (subBlock.type === 'dropdown' && baseKey === 'workflowIds') + ) { const remapped = remapWorkflowIdList(subBlock.value, workflowIdMap, clearUnmapped) if (remapped !== subBlock.value) { updated[key] = { ...subBlock, value: remapped } diff --git a/apps/sim/lib/workspaces/fork/copy/cleanup-failed.ts b/apps/sim/lib/workspaces/fork/copy/cleanup-failed.ts index 5f557f68f7a..936a777a82d 100644 --- a/apps/sim/lib/workspaces/fork/copy/cleanup-failed.ts +++ b/apps/sim/lib/workspaces/fork/copy/cleanup-failed.ts @@ -75,6 +75,14 @@ function clearFailedSubBlockReferences( * when a reference-clear phase threw - placeholders were then NOT dropped - and `cleared` is 0 in * that case, so the report never claims references it did not actually clear. On success `cleared` * is the count of failed resources whose references were cleared. + * + * Storage accounting: this cleanup never decrements storage usage because it never removes + * anything that was counted. Copied file blobs are the only counted copies (incremented in + * `executeForkFileBlobCopies` only after the blob lands), and a failed file's blob never + * landed - its metadata row is intentionally left re-uploadable, and nothing was charged. The + * dropped table/KB/document placeholders are DB rows the upload path never counts, and any KB + * blobs copied before their KB failed are left in storage (rows only are dropped here) but + * uncounted - mirroring the KB upload path, which never counts KB blobs. */ export async function clearFailedForkResourceReferences(params: { childWorkspaceId: string diff --git a/apps/sim/lib/workspaces/fork/copy/copy-files.test.ts b/apps/sim/lib/workspaces/fork/copy/copy-files.test.ts new file mode 100644 index 00000000000..e0b5c3c0e58 --- /dev/null +++ b/apps/sim/lib/workspaces/fork/copy/copy-files.test.ts @@ -0,0 +1,158 @@ +/** + * @vitest-environment node + */ +import { storageServiceMock, storageServiceMockFns } from '@sim/testing' +import { omit } from '@sim/utils/object' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockIncrementStorageUsage } = vi.hoisted(() => ({ + mockIncrementStorageUsage: vi.fn(), +})) + +vi.mock('@/lib/uploads/core/storage-service', () => storageServiceMock) +vi.mock('@/lib/billing/storage', () => ({ + incrementStorageUsage: mockIncrementStorageUsage, +})) +vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ + generateWorkspaceFileKey: vi.fn( + (workspaceId: string, fileName: string) => `workspace/${workspaceId}/generated-${fileName}` + ), +})) + +import type { DbOrTx } from '@/lib/db/types' +import { + type BlobCopyTask, + executeForkFileBlobCopies, + planForkFileCopies, +} from '@/lib/workspaces/fork/copy/copy-files' + +function makeTask(overrides: Partial = {}): BlobCopyTask { + return { + sourceKey: 'workspace/src-ws/source-a.txt', + targetKey: 'workspace/child-ws/target-a.txt', + context: 'workspace', + fileName: 'a.txt', + contentType: 'text/plain', + size: 100, + userId: 'user-1', + workspaceId: 'child-ws', + ...overrides, + } +} + +describe('executeForkFileBlobCopies storage accounting', () => { + beforeEach(() => { + vi.clearAllMocks() + storageServiceMockFns.mockHeadObject.mockResolvedValue(null) + storageServiceMockFns.mockDownloadFile.mockResolvedValue(Buffer.from('blob-bytes')) + storageServiceMockFns.mockUploadFile.mockResolvedValue({ key: 'workspace/child-ws/target' }) + mockIncrementStorageUsage.mockResolvedValue(undefined) + }) + + it('charges the initiating user exactly once per landed blob, by the metadata row size', async () => { + const tasks = [ + makeTask({ targetKey: 'workspace/child-ws/t1', size: 100 }), + makeTask({ targetKey: 'workspace/child-ws/t2', size: 200, fileName: 'b.txt' }), + ] + + const result = await executeForkFileBlobCopies(tasks, 'test') + + expect(result).toEqual({ copied: 2, failed: 0, failedTargetKeys: [] }) + expect(mockIncrementStorageUsage).toHaveBeenCalledTimes(2) + expect(mockIncrementStorageUsage).toHaveBeenNthCalledWith(1, 'user-1', 100, 'child-ws') + expect(mockIncrementStorageUsage).toHaveBeenNthCalledWith(2, 'user-1', 200, 'child-ws') + }) + + it('skips an already-existing target blob without re-copying or re-charging (replayed run)', async () => { + storageServiceMockFns.mockHeadObject.mockResolvedValue({ size: 100 }) + + const result = await executeForkFileBlobCopies([makeTask()], 'test') + + expect(result).toEqual({ copied: 1, failed: 0, failedTargetKeys: [] }) + expect(storageServiceMockFns.mockDownloadFile).not.toHaveBeenCalled() + expect(storageServiceMockFns.mockUploadFile).not.toHaveBeenCalled() + expect(mockIncrementStorageUsage).not.toHaveBeenCalled() + }) + + it('never charges a failed copy (the blob did not land)', async () => { + storageServiceMockFns.mockDownloadFile.mockRejectedValue(new Error('source gone')) + + const result = await executeForkFileBlobCopies([makeTask()], 'test') + + expect(result).toEqual({ + copied: 0, + failed: 1, + failedTargetKeys: ['workspace/child-ws/target-a.txt'], + }) + expect(mockIncrementStorageUsage).not.toHaveBeenCalled() + }) + + it('treats a tracking failure as best-effort - the copy still counts as landed', async () => { + mockIncrementStorageUsage.mockRejectedValue(new Error('billing hiccup')) + + const result = await executeForkFileBlobCopies([makeTask()], 'test') + + expect(result).toEqual({ copied: 1, failed: 0, failedTargetKeys: [] }) + expect(storageServiceMockFns.mockUploadFile).toHaveBeenCalledTimes(1) + }) + + it('skips the charge for a legacy payload enqueued before size existed', async () => { + // Simulates a Trigger.dev payload serialized by a pre-`size` deploy (rolling upgrade). + const legacyTask = omit(makeTask(), ['size']) as BlobCopyTask + + const result = await executeForkFileBlobCopies([legacyTask], 'test') + + expect(result.copied).toBe(1) + expect(mockIncrementStorageUsage).not.toHaveBeenCalled() + }) +}) + +describe('planForkFileCopies', () => { + it('carries the source metadata size onto each blob task and the child row', async () => { + const sourceMeta = { + id: 'wf_src1', + key: 'workspace/src-ws/1-abc-a.txt', + userId: 'uploader-1', + workspaceId: 'src-ws', + folderId: 'folder-1', + context: 'workspace', + chatId: null, + originalName: 'a.txt', + displayName: null, + contentType: 'text/plain', + size: 4321, + deletedAt: null, + uploadedAt: new Date('2026-01-01'), + updatedAt: new Date('2026-01-01'), + } + const inserted: Array> = [] + const tx = { + select: vi.fn(() => ({ from: () => ({ where: () => Promise.resolve([sourceMeta]) }) })), + insert: vi.fn(() => ({ + values: (row: Record) => { + inserted.push(row) + return Promise.resolve() + }, + })), + } as unknown as DbOrTx + + const result = await planForkFileCopies({ + tx, + sourceWorkspaceId: 'src-ws', + childWorkspaceId: 'child-ws', + userId: 'user-1', + fileIds: ['wf_src1'], + now: new Date('2026-02-01'), + }) + + expect(result.blobTasks).toHaveLength(1) + expect(result.blobTasks[0]).toMatchObject({ + sourceKey: 'workspace/src-ws/1-abc-a.txt', + targetKey: 'workspace/child-ws/generated-a.txt', + size: 4321, + userId: 'user-1', + workspaceId: 'child-ws', + }) + expect(inserted[0]).toMatchObject({ size: 4321, workspaceId: 'child-ws' }) + }) +}) diff --git a/apps/sim/lib/workspaces/fork/copy/copy-files.ts b/apps/sim/lib/workspaces/fork/copy/copy-files.ts index e7e8f5080eb..828aa16513a 100644 --- a/apps/sim/lib/workspaces/fork/copy/copy-files.ts +++ b/apps/sim/lib/workspaces/fork/copy/copy-files.ts @@ -3,9 +3,10 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { and, eq, inArray, isNull, or } from 'drizzle-orm' +import { incrementStorageUsage } from '@/lib/billing/storage' import type { DbOrTx } from '@/lib/db/types' import { generateWorkspaceFileKey } from '@/lib/uploads/contexts/workspace/workspace-file-manager' -import { downloadFile, uploadFile } from '@/lib/uploads/core/storage-service' +import { downloadFile, headObject, uploadFile } from '@/lib/uploads/core/storage-service' import type { StorageContext } from '@/lib/uploads/shared/types' import { MAX_FILE_SIZE } from '@/lib/uploads/utils/validation' import { @@ -30,6 +31,13 @@ export interface BlobCopyTask { context: StorageContext fileName: string contentType: string + /** + * Byte size from the source metadata row - the child `workspace_files` row was inserted + * with this same size, so the storage-usage increment after a successful blob copy + * charges exactly the bytes the row advertises (matching the upload path, where the + * incremented bytes always equal the row's `size`). + */ + size: number userId: string workspaceId: string } @@ -127,6 +135,7 @@ export async function planForkFileCopies(params: { context: meta.context as StorageContext, fileName: meta.originalName, contentType: meta.contentType, + size: meta.size, userId, workspaceId: childWorkspaceId, }) @@ -145,6 +154,17 @@ export async function planForkFileCopies(params: { * blob's child storage key is returned in `failedTargetKeys` so the caller can clear the * `file-upload` references pointing at the now-missing object (the metadata row is left in * place, so the user can still re-upload the blob). + * + * Storage accounting: each blob that actually lands increments the initiating user's + * storage usage by the metadata row's size - the copied bytes are charged exactly as if + * the file had been uploaded to the target workspace. The increment cannot double-count: + * the content-copy job is at-most-once by config (`maxAttempts: 1`), each task increments + * only after its own successful upload, and the target-existence skip below means a + * manually replayed run neither re-copies nor re-charges a blob a prior attempt landed. + * Like the upload path, a tracking failure is logged and never fails the copy - and is + * never retried, so a landed blob whose increment failed stays uncounted (a manual replay + * skips it without charging). Accepted trade-off, matching the platform's upload paths: + * storage may undercount, but a user is never charged twice or for bytes that didn't land. */ export async function executeForkFileBlobCopies( blobTasks: BlobCopyTask[], @@ -155,6 +175,18 @@ export async function executeForkFileBlobCopies( const failedTargetKeys: string[] = [] for (const task of blobTasks) { try { + // Replay guard: target keys are freshly generated per fork/sync, so an existing + // object can only mean an earlier attempt already landed this exact copy. Skip + // without incrementing - a replay must never double-charge, so if the prior + // attempt's best-effort increment failed those bytes stay uncounted (the same + // accepted undercount as a tracking failure on the upload path). `headObject` + // returns null on local storage, where the copy is simply repeated (same bytes + // to the same key). + const existing = await headObject(task.targetKey, task.context) + if (existing) { + copied += 1 + continue + } const buffer = await downloadFile({ key: task.sourceKey, context: task.context, @@ -187,6 +219,17 @@ export async function executeForkFileBlobCopies( }, }) copied += 1 + // The typeof guard covers payloads enqueued before `size` existed (rolling deploy). + if (typeof task.size === 'number' && task.size > 0) { + try { + await incrementStorageUsage(task.userId, task.size, task.workspaceId) + } catch (storageError) { + logger.error(`[${requestId}] Failed to update storage tracking for copied file blob`, { + targetKey: task.targetKey, + error: getErrorMessage(storageError), + }) + } + } } catch (error) { failedTargetKeys.push(task.targetKey) logger.warn(`[${requestId}] Failed to copy file blob during fork`, { diff --git a/apps/sim/lib/workspaces/fork/copy/copy-resources.test.ts b/apps/sim/lib/workspaces/fork/copy/copy-resources.test.ts index a1ff7fb971d..e33be42700f 100644 --- a/apps/sim/lib/workspaces/fork/copy/copy-resources.test.ts +++ b/apps/sim/lib/workspaces/fork/copy/copy-resources.test.ts @@ -10,8 +10,15 @@ import { } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' +const { mockIncrementStorageUsage } = vi.hoisted(() => ({ + mockIncrementStorageUsage: vi.fn(), +})) + vi.mock('@sim/db', () => dbChainMock) vi.mock('@/lib/uploads/core/storage-service', () => storageServiceMock) +vi.mock('@/lib/billing/storage', () => ({ + incrementStorageUsage: mockIncrementStorageUsage, +})) import type { DbOrTx } from '@/lib/db/types' import { @@ -115,6 +122,24 @@ describe('copyForkResourceContent', () => { }) }) + it('#1 never touches storage accounting for copied KB document blobs (mirrors the KB upload path)', async () => { + // The normal KB upload path never increments `storage_used_bytes` (and embeddings are + // uncounted DB rows), so a fork-copied KB blob must not be charged either - copied KB + // bytes are only headroom-checked pre-fork, exactly like the multipart-initiate check. + dbChainMockFns.limit.mockResolvedValueOnce([sourceDoc]) + + const result = await copyForkResourceContent({ + contentPlan: basePlan({ + knowledgeBases: [{ sourceId: 'src-kb', childId: 'child-kb', documentIdMap: {} }], + }), + requestId: 'test', + }) + + expect(result.copied).toBe(1) + expect(storageServiceMockFns.mockUploadFile).toHaveBeenCalledTimes(1) + expect(mockIncrementStorageUsage).not.toHaveBeenCalled() + }) + it('#4 re-reads a copied skill body post-commit and rewrites it via db.update (never from payload)', async () => { // The body is no longer carried in the plan - the content phase keyset-re-reads the child row. dbChainMockFns.limit.mockResolvedValueOnce([ @@ -355,6 +380,101 @@ describe('copyForkResourceContainers skill copy', () => { }) }) +describe('copyForkResourceContainers knowledge-base tag definitions', () => { + /** Sequential tx mock: each select resolves the next queued row set; inserts are captured per call. */ + function makeKbTx(selects: Array>>) { + let call = 0 + const inserts: Array>> = [] + const tx = { + select: () => ({ + from: () => ({ where: () => Promise.resolve(selects[call++] ?? []) }), + }), + insert: () => ({ + values: (rows: Array>) => { + inserts.push(rows) + return Promise.resolve() + }, + }), + } + return { tx: tx as unknown as DbOrTx, inserts } + } + + const kbSelection = { + customTools: [], + skills: [], + workflowMcpServers: [], + tables: [], + knowledgeBases: ['kb-1'], + } + + const sourceBase = { id: 'kb-1', name: 'Docs KB', workspaceId: 'src-ws', deletedAt: null } + + it('copies the source KB tag definitions to the child KB with fresh ids (other columns verbatim)', async () => { + const { tx, inserts } = makeKbTx([ + [sourceBase], + [ + { + id: 'tag-1', + knowledgeBaseId: 'kb-1', + tagSlot: 'tag1', + displayName: 'Category', + fieldType: 'text', + }, + { + id: 'tag-2', + knowledgeBaseId: 'kb-1', + tagSlot: 'boolean1', + displayName: 'Reviewed', + fieldType: 'boolean', + }, + ], + ]) + + const result = await copyForkResourceContainers({ + tx, + sourceWorkspaceId: 'src-ws', + childWorkspaceId: 'child-ws', + userId: 'user-1', + now: new Date(), + selection: kbSelection, + workflowIdMap: new Map(), + }) + + const childKbId = result.idMap.get('knowledge_base')?.get('kb-1') + expect(childKbId).toBeTruthy() + // insert #0 is the KB row; insert #1 is the tag-definition batch. + expect(inserts).toHaveLength(2) + const tagRows = inserts[1] + expect(tagRows).toHaveLength(2) + for (const row of tagRows) { + expect(row.knowledgeBaseId).toBe(childKbId) + expect(row.id).not.toBe('tag-1') + expect(row.id).not.toBe('tag-2') + } + expect(tagRows.map((row) => [row.tagSlot, row.displayName, row.fieldType])).toEqual([ + ['tag1', 'Category', 'text'], + ['boolean1', 'Reviewed', 'boolean'], + ]) + }) + + it('no-ops the tag-definition copy for a KB with zero definitions', async () => { + const { tx, inserts } = makeKbTx([[sourceBase], []]) + + await copyForkResourceContainers({ + tx, + sourceWorkspaceId: 'src-ws', + childWorkspaceId: 'child-ws', + userId: 'user-1', + now: new Date(), + selection: kbSelection, + workflowIdMap: new Map(), + }) + + // Only the KB row itself is inserted - no empty tag-definition insert. + expect(inserts).toHaveLength(1) + }) +}) + describe('planForkMappedKbDocumentCopies', () => { const sourceRow = (id: string, knowledgeBaseId: string) => ({ id, diff --git a/apps/sim/lib/workspaces/fork/copy/copy-resources.ts b/apps/sim/lib/workspaces/fork/copy/copy-resources.ts index 5f78426b2b8..a6b3779c4ce 100644 --- a/apps/sim/lib/workspaces/fork/copy/copy-resources.ts +++ b/apps/sim/lib/workspaces/fork/copy/copy-resources.ts @@ -4,6 +4,7 @@ import { document, embedding, knowledgeBase, + knowledgeBaseTagDefinitions, skill, userTableDefinitions, userTableRows, @@ -24,6 +25,7 @@ import type { ForkMappingUpsert, ForkResourceType, } from '@/lib/workspaces/fork/mapping/mapping-store' +import type { ForkBlockIdResolver } from '@/lib/workspaces/fork/remap/block-identity' import { type ForkContentRefMaps, rewriteForkContentRefs, @@ -83,6 +85,13 @@ export interface CopyResourcesParams { * plan resolver); omitted by fork-create, which preserves env names verbatim (no rewrite). */ resolveEnvName?: (key: string) => string | null | undefined + /** + * Resolve a source block id to its target block id for copied tables' workflow-group + * `outputs[].blockId`. Promote passes the SAME persisted-pair resolver its workflow writes + * use (on push the parent keeps its ORIGINAL block ids, never the derive); fork-create + * omits it, defaulting to the deterministic derive (a fresh child has no pairs). + */ + resolveBlockId?: ForkBlockIdResolver } export interface ForkContentPlanEntry { @@ -199,8 +208,9 @@ type SkillSkeletonInsert = Omit & { conten /** * Copy the selected resources' **container rows** into the child workspace inside * the fork transaction: custom tools, skills, and MCP server configs (each a - * single row), plus table definitions and knowledge-base rows (without their bulk - * rows / documents / embeddings). This keeps the fork transaction bounded to + * single row), plus table definitions and knowledge-base rows with their tag + * definitions (bounded per KB) but without their bulk rows / documents / + * embeddings. This keeps the fork transaction bounded to * O(selected resources) single-row writes. The heavy content (table rows, KB * documents + embeddings) is returned as a {@link ForkContentPlan} for * {@link copyForkResourceContent} to copy best-effort after commit. Secrets are @@ -359,7 +369,8 @@ export async function copyForkResourceContainers( const childTableId = generateId() const remappedSchema = remapForkTableWorkflowGroups( definition.schema as TableSchema, - workflowIdMap + workflowIdMap, + params.resolveBlockId ) inserts.push({ ...definition, @@ -414,6 +425,34 @@ export async function copyForkResourceContainers( } if (inserts.length > 0) await tx.insert(knowledgeBase).values(inserts) + // Copy each source KB's tag definitions to its child so tagged documents keep a working tag + // schema: the copied documents carry tag VALUES in their slot columns, and both tag-filter + // search and documentTags writes resolve display names through these definition rows (a copy + // without them 400s / throws on every defined tag). Fresh ids, child KB id, all other columns + // verbatim - nothing persists a tag-definition id (workflow state, documents, and fork + // mappings all reference tags by display name / slot), so no id map is recorded. + if (kbEntryBySourceId.size > 0) { + const tagDefinitions = await tx + .select() + .from(knowledgeBaseTagDefinitions) + .where( + inArray(knowledgeBaseTagDefinitions.knowledgeBaseId, Array.from(kbEntryBySourceId.keys())) + ) + const tagDefinitionInserts: (typeof knowledgeBaseTagDefinitions.$inferInsert)[] = [] + for (const definition of tagDefinitions) { + const childKbId = kbEntryBySourceId.get(definition.knowledgeBaseId)?.childId + if (!childKbId) continue + tagDefinitionInserts.push({ + ...definition, + id: generateId(), + knowledgeBaseId: childKbId, + }) + } + if (tagDefinitionInserts.length > 0) { + await tx.insert(knowledgeBaseTagDefinitions).values(tagDefinitionInserts) + } + } + // Pre-create placeholder document rows for the documents the copied workflows // reference, at child ids generated inside the transaction, so each // `document-selector` reference can be remapped to a valid copied document rather diff --git a/apps/sim/lib/workspaces/fork/copy/copy-workflows.test.ts b/apps/sim/lib/workspaces/fork/copy/copy-workflows.test.ts index e141d65e7b4..74b7ade28f9 100644 --- a/apps/sim/lib/workspaces/fork/copy/copy-workflows.test.ts +++ b/apps/sim/lib/workspaces/fork/copy/copy-workflows.test.ts @@ -1,8 +1,22 @@ /** * @vitest-environment node */ -import { describe, expect, it } from 'vitest' -import { buildWorkflowNameRegistry } from '@/lib/workspaces/fork/copy/copy-workflows' +import { describe, expect, it, vi } from 'vitest' +import type { DbOrTx } from '@/lib/db/types' + +const { mockSaveWorkflowToNormalizedTables } = vi.hoisted(() => ({ + mockSaveWorkflowToNormalizedTables: vi.fn(), +})) + +vi.mock('@/lib/workflows/persistence/utils', () => ({ + saveWorkflowToNormalizedTables: mockSaveWorkflowToNormalizedTables, +})) + +import { + buildWorkflowNameRegistry, + copyWorkflowStateIntoTarget, + resolveForkFolderMapping, +} from '@/lib/workspaces/fork/copy/copy-workflows' describe('buildWorkflowNameRegistry', () => { it('reports a name as taken by another workflow in the same folder', () => { @@ -60,3 +74,222 @@ describe('buildWorkflowNameRegistry', () => { expect(reg.isTaken('f1', 'Dup', 'w1')).toBe(false) }) }) + +interface FolderRow { + id: string + name: string + userId: string + workspaceId: string + parentId: string | null + color: string | null + isExpanded: boolean + locked: boolean + sortOrder: number + createdAt: Date + updatedAt: Date + archivedAt: Date | null +} + +function folderRow(id: string, name: string, parentId: string | null = null): FolderRow { + return { + id, + name, + userId: 'source-user', + workspaceId: 'ws-source', + parentId, + color: '#6B7280', + isExpanded: true, + locked: false, + sortOrder: 0, + createdAt: new Date('2026-01-01'), + updatedAt: new Date('2026-01-01'), + archivedAt: null, + } +} + +/** + * Transaction stub for {@link resolveForkFolderMapping}: the first awaited select resolves + * the source folders, the second the target folders, and inserted rows are captured. + */ +function buildFolderTx(sourceFolders: FolderRow[], targetFolders: FolderRow[] = []) { + const insertedRows: FolderRow[] = [] + const selects = [sourceFolders, targetFolders] + let selectIndex = 0 + const tx = { + select: () => ({ + from: () => ({ + where: () => Promise.resolve(selects[selectIndex++] ?? []), + }), + }), + insert: () => ({ + values: (rows: FolderRow[]) => { + insertedRows.push(...rows) + return Promise.resolve() + }, + }), + } as unknown as DbOrTx + return { tx, insertedRows } +} + +function resolveMapping(params: { + tx: DbOrTx + contentFolderIds: ReadonlyArray +}): Promise> { + return resolveForkFolderMapping({ + tx: params.tx, + sourceWorkspaceId: 'ws-source', + targetWorkspaceId: 'ws-target', + userId: 'target-user', + now: new Date('2026-07-01'), + contentFolderIds: params.contentFolderIds, + }) +} + +describe('resolveForkFolderMapping', () => { + it('keeps the full ancestor chain of a nested folder holding a copied workflow', async () => { + const { tx, insertedRows } = buildFolderTx([ + folderRow('A', 'Alpha'), + folderRow('B', 'Beta', 'A'), + folderRow('C', 'Gamma', 'B'), + ]) + + const map = await resolveMapping({ tx, contentFolderIds: ['C'] }) + + expect(map.size).toBe(3) + expect(insertedRows).toHaveLength(3) + const byName = new Map(insertedRows.map((row) => [row.name, row])) + expect(byName.get('Alpha')?.parentId).toBeNull() + expect(byName.get('Beta')?.parentId).toBe(map.get('A')) + expect(byName.get('Gamma')?.parentId).toBe(map.get('B')) + for (const row of insertedRows) { + expect(row.workspaceId).toBe('ws-target') + expect(row.userId).toBe('target-user') + expect(row.locked).toBe(false) + expect(['A', 'B', 'C']).not.toContain(row.id) + } + }) + + it('prunes an empty sibling subtree while keeping the occupied folder', async () => { + const { tx, insertedRows } = buildFolderTx([ + folderRow('A', 'Occupied'), + folderRow('D', 'Empty parent'), + folderRow('E', 'Empty child', 'D'), + ]) + + const map = await resolveMapping({ tx, contentFolderIds: ['A'] }) + + expect(insertedRows).toHaveLength(1) + expect(insertedRows[0].name).toBe('Occupied') + expect(map.has('A')).toBe(true) + expect(map.has('D')).toBe(false) + expect(map.has('E')).toBe(false) + }) + + it('prunes a root-level empty folder when the copied workflows live at root', async () => { + const { tx, insertedRows } = buildFolderTx([folderRow('F', 'Never used')]) + + const map = await resolveMapping({ tx, contentFolderIds: [null, null] }) + + expect(insertedRows).toHaveLength(0) + expect(map.size).toBe(0) + }) + + it('creates no folders when nothing is copied into any folder', async () => { + const { tx, insertedRows } = buildFolderTx([ + folderRow('A', 'Alpha'), + folderRow('B', 'Beta', 'A'), + ]) + + const map = await resolveMapping({ tx, contentFolderIds: [] }) + + expect(insertedRows).toHaveLength(0) + expect(map.size).toBe(0) + }) + + it('reuses an existing target folder for a kept folder instead of duplicating it', async () => { + const existing = { ...folderRow('T1', 'Shared'), workspaceId: 'ws-target' } + const { tx, insertedRows } = buildFolderTx([folderRow('G', 'Shared')], [existing]) + + const map = await resolveMapping({ tx, contentFolderIds: ['G'] }) + + expect(insertedRows).toHaveLength(0) + expect(map.get('G')).toBe('T1') + }) + + it('maps a pruned folder onto a matching existing target folder without creating it', async () => { + const existing = { ...folderRow('T1', 'Prior sync'), workspaceId: 'ws-target' } + const { tx, insertedRows } = buildFolderTx([folderRow('P', 'Prior sync')], [existing]) + + const map = await resolveMapping({ tx, contentFolderIds: [] }) + + expect(insertedRows).toHaveLength(0) + expect(map.get('P')).toBe('T1') + }) + + it('never root-aliases a pruned nested folder onto a same-named root target folder', async () => { + // Source X is nested under unmatched P; the target's root-level "X" is unrelated. + const existing = { ...folderRow('T-root-x', 'X'), workspaceId: 'ws-target' } + const { tx, insertedRows } = buildFolderTx( + [folderRow('P', 'Parent'), folderRow('X', 'X', 'P')], + [existing] + ) + + const map = await resolveMapping({ tx, contentFolderIds: [] }) + + expect(insertedRows).toHaveLength(0) + expect(map.size).toBe(0) + }) + + it('creates a kept child under a reused existing parent folder', async () => { + const existingParent = { ...folderRow('T-parent', 'Parent'), workspaceId: 'ws-target' } + const { tx, insertedRows } = buildFolderTx( + [folderRow('P', 'Parent'), folderRow('C', 'Child', 'P')], + [existingParent] + ) + + const map = await resolveMapping({ tx, contentFolderIds: ['C'] }) + + expect(map.get('P')).toBe('T-parent') + expect(insertedRows).toHaveLength(1) + expect(insertedRows[0].name).toBe('Child') + expect(insertedRows[0].parentId).toBe('T-parent') + }) +}) + +describe('copyWorkflowStateIntoTarget folder fallback', () => { + it('places a copied workflow at the target root when its source folder has no mapping', async () => { + mockSaveWorkflowToNormalizedTables.mockResolvedValue({ success: true }) + const insertedWorkflows: Array> = [] + const tx = { + insert: () => ({ + values: (row: Record) => { + insertedWorkflows.push(row) + return Promise.resolve() + }, + }), + } as unknown as DbOrTx + + const result = await copyWorkflowStateIntoTarget({ + tx, + targetWorkflowId: 'wf-child', + targetWorkspaceId: 'ws-target', + userId: 'target-user', + mode: 'create', + now: new Date('2026-07-01'), + sourceState: { blocks: {}, edges: [], loops: {}, parallels: {}, variables: {} }, + sourceMeta: { + name: 'Orphaned placement', + description: null, + folderId: 'folder-with-no-mapping', + sortOrder: 0, + }, + workflowIdMap: new Map(), + folderIdMap: new Map(), + nameRegistry: buildWorkflowNameRegistry([]), + }) + + expect(insertedWorkflows).toHaveLength(1) + expect(insertedWorkflows[0].folderId).toBeNull() + expect(result.name).toBe('Orphaned placement') + }) +}) diff --git a/apps/sim/lib/workspaces/fork/copy/copy-workflows.ts b/apps/sim/lib/workspaces/fork/copy/copy-workflows.ts index ecb386d0d09..f1d7be3ecbb 100644 --- a/apps/sim/lib/workspaces/fork/copy/copy-workflows.ts +++ b/apps/sim/lib/workspaces/fork/copy/copy-workflows.ts @@ -45,13 +45,25 @@ interface ResolveForkFolderMappingParams { targetWorkspaceId: string userId: string now: Date + /** + * Source folder ids that will directly hold copied content (workflows); null entries + * (root-placed content) are ignored. A source folder is copied into the target only when + * its subtree contains at least one of these, so a fork/sync never creates folders that + * would end up empty. Copied workspace FILES never influence this set: they live in the + * separate `workspace_file_folders` entity and are flattened to root by the copy. + */ + contentFolderIds: ReadonlyArray } /** - * Mirror the source workspace's folder tree into the target workspace, creating - * folders as needed and reusing target folders that already match by name within - * the same (mapped) parent. Returns a map from source folder id to target folder - * id so copied workflows can be placed in the corresponding folder. + * Mirror into the target workspace the part of the source folder tree that will actually + * receive copied content: the folders in `contentFolderIds` plus their ancestor chains (so + * nesting stays intact). Target folders that already match by name within the same (mapped) + * parent are reused instead of duplicated. Folders whose subtree holds no copied content are + * pruned - never created - though a pruned folder still maps onto an existing target folder + * when one matches, so previously-synced content refs keep resolving. Returns a map from + * source folder id to target folder id; a copied workflow whose folder is absent from the + * map is placed at the target's root (see {@link copyWorkflowStateIntoTarget}). */ export async function resolveForkFolderMapping({ tx, @@ -59,6 +71,7 @@ export async function resolveForkFolderMapping({ targetWorkspaceId, userId, now, + contentFolderIds, }: ResolveForkFolderMappingParams): Promise> { const map = new Map() @@ -71,6 +84,20 @@ export async function resolveForkFolderMapping({ if (sourceFolders.length === 0) return map + const byId = new Map(sourceFolders.map((folder) => [folder.id, folder])) + + // Kept = folders that directly hold copied content plus every ancestor; everything else + // would be empty in the target and is pruned. A dangling (archived) parent ends the walk, + // matching the re-root fallback below. + const kept = new Set() + for (const folderId of contentFolderIds) { + let current = folderId ? byId.get(folderId) : undefined + while (current && !kept.has(current.id)) { + kept.add(current.id) + current = current.parentId ? byId.get(current.parentId) : undefined + } + } + const targetFolders = await tx .select() .from(workflowFolder) @@ -83,7 +110,6 @@ export async function resolveForkFolderMapping({ targetByKey.set(`${folder.parentId ?? ''}::${folder.name}`, folder.id) } - const byId = new Map(sourceFolders.map((folder) => [folder.id, folder])) const ordered: typeof sourceFolders = [] const seen = new Set() const visit = (folder: (typeof sourceFolders)[number]) => { @@ -97,13 +123,20 @@ export async function resolveForkFolderMapping({ const newFolders: (typeof sourceFolders)[number][] = [] for (const folder of ordered) { + const isKept = kept.has(folder.id) const mappedParentId = folder.parentId ? (map.get(folder.parentId) ?? null) : null const key = `${mappedParentId ?? ''}::${folder.name}` const existing = targetByKey.get(key) if (existing) { - map.set(folder.id, existing) + // A pruned folder may still MAP onto an existing target folder, but only when its + // parent chain actually resolved: an unmapped pruned parent aliases the key to root + // level, which could match an unrelated same-named root folder. + if (isKept || !folder.parentId || map.has(folder.parentId)) { + map.set(folder.id, existing) + } continue } + if (!isKept) continue const newFolderId = generateId() map.set(folder.id, newFolderId) targetByKey.set(key, newFolderId) diff --git a/apps/sim/lib/workspaces/fork/copy/storage-quota.test.ts b/apps/sim/lib/workspaces/fork/copy/storage-quota.test.ts new file mode 100644 index 00000000000..04f0ef050f5 --- /dev/null +++ b/apps/sim/lib/workspaces/fork/copy/storage-quota.test.ts @@ -0,0 +1,140 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockCheckStorageQuota } = vi.hoisted(() => ({ + mockCheckStorageQuota: vi.fn(), +})) + +vi.mock('@/lib/billing/storage', () => ({ + checkStorageQuota: mockCheckStorageQuota, +})) + +/** + * Minimal stand-in for the domain error so this unit test never loads the authz module's + * billing/feature-flag import chain. Shape-compatible with the real `ForkError`. + */ +vi.mock('@/lib/workspaces/fork/lineage/authz', () => ({ + ForkError: class ForkError extends Error { + statusCode: number + constructor(message: string, statusCode = 400) { + super(message) + this.name = 'ForkError' + this.statusCode = statusCode + } + }, +})) + +import type { DbOrTx } from '@/lib/db/types' +import { + assertForkStorageHeadroom, + sumForkCopyBytes, +} from '@/lib/workspaces/fork/copy/storage-quota' +import { ForkError } from '@/lib/workspaces/fork/lineage/authz' + +/** + * Fake executor resolving one aggregate row per query, in call order. Supports both sum + * shapes: `select().from().where()` (files) and `select().from().innerJoin().where()` (KB + * documents joined to their live KB row). + */ +function makeExecutor(totals: Array) { + let call = 0 + const next = () => Promise.resolve([{ total: totals[call++] ?? 0 }]) + const select = vi.fn(() => ({ + from: () => ({ + where: next, + innerJoin: () => ({ where: next }), + }), + })) + return { executor: { select } as unknown as DbOrTx, select } +} + +describe('sumForkCopyBytes', () => { + it('adds the workspace-file and KB-document byte sums', async () => { + const { executor, select } = makeExecutor([300, 700]) + + const bytes = await sumForkCopyBytes(executor, 'src-ws', { + fileIds: ['wf-1'], + knowledgeBaseIds: ['kb-1'], + }) + + expect(bytes).toBe(1000) + expect(select).toHaveBeenCalledTimes(2) + }) + + it('coerces driver string aggregates (bigint sums) to numbers', async () => { + const { executor } = makeExecutor(['1024']) + + const bytes = await sumForkCopyBytes(executor, 'src-ws', { fileKeys: ['workspace/src/k1'] }) + + expect(bytes).toBe(1024) + }) + + it('runs no query for an empty selection', async () => { + const { executor, select } = makeExecutor([]) + + const bytes = await sumForkCopyBytes(executor, 'src-ws', { + fileIds: [], + fileKeys: [], + knowledgeBaseIds: [], + }) + + expect(bytes).toBe(0) + expect(select).not.toHaveBeenCalled() + }) + + it('skips the file query when only KBs are selected (and vice versa)', async () => { + const { executor, select } = makeExecutor([555]) + + const bytes = await sumForkCopyBytes(executor, 'src-ws', { knowledgeBaseIds: ['kb-1'] }) + + expect(bytes).toBe(555) + expect(select).toHaveBeenCalledTimes(1) + }) +}) + +describe('assertForkStorageHeadroom', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('never consults the quota helper for zero bytes', async () => { + await assertForkStorageHeadroom({ userId: 'user-1', bytes: 0 }) + expect(mockCheckStorageQuota).not.toHaveBeenCalled() + }) + + it('resolves when the scope has headroom', async () => { + mockCheckStorageQuota.mockResolvedValue({ allowed: true, currentUsage: 10, limit: 100 }) + + await expect( + assertForkStorageHeadroom({ userId: 'user-1', bytes: 50 }) + ).resolves.toBeUndefined() + expect(mockCheckStorageQuota).toHaveBeenCalledWith('user-1', 50) + }) + + it("throws a 413 ForkError carrying the upload path's quota message when over quota", async () => { + mockCheckStorageQuota.mockResolvedValue({ + allowed: false, + currentUsage: 99, + limit: 100, + error: 'Storage limit exceeded. Used: 10.50GB, Limit: 10GB', + }) + + const rejection = expect(assertForkStorageHeadroom({ userId: 'user-1', bytes: 50 })).rejects + await rejection.toBeInstanceOf(ForkError) + await rejection.toMatchObject({ + statusCode: 413, + message: + 'Not enough storage to copy the selected resources. Storage limit exceeded. Used: 10.50GB, Limit: 10GB', + }) + }) + + it('falls back to a generic storage message when the quota helper omits one', async () => { + mockCheckStorageQuota.mockResolvedValue({ allowed: false, currentUsage: 0, limit: 0 }) + + await expect(assertForkStorageHeadroom({ userId: 'user-1', bytes: 1 })).rejects.toThrow( + 'Not enough storage to copy the selected resources. Storage limit exceeded' + ) + }) +}) diff --git a/apps/sim/lib/workspaces/fork/copy/storage-quota.ts b/apps/sim/lib/workspaces/fork/copy/storage-quota.ts new file mode 100644 index 00000000000..91a250f1b5d --- /dev/null +++ b/apps/sim/lib/workspaces/fork/copy/storage-quota.ts @@ -0,0 +1,126 @@ +import { document, knowledgeBase, workspaceFiles } from '@sim/db/schema' +import { and, eq, inArray, isNotNull, isNull, or, sql } from 'drizzle-orm' +import { checkStorageQuota } from '@/lib/billing/storage' +import type { DbOrTx } from '@/lib/db/types' +import { ForkError } from '@/lib/workspaces/fork/lineage/authz' + +/** Resource ids whose blob bytes a fork/sync copy would duplicate into the target. */ +export interface ForkCopyBytesSelection { + /** Workspace files selected by `workspace_files.id` (the fork modal's picker shape). */ + fileIds?: string[] + /** Workspace files selected by storage key (the sync copy selection shape). */ + fileKeys?: string[] + /** Knowledge bases whose live documents' stored blobs would be re-keyed into the target. */ + knowledgeBaseIds?: string[] +} + +/** + * Byte total of the workspace-file blobs a copy selection would duplicate. Applies the + * same row filters as `planForkFileCopies` (source workspace, durable `workspace` + * context, non-deleted, id/key selectors OR'd), so the sum covers exactly the rows the + * copy would plan. + */ +async function sumWorkspaceFileBytes( + executor: DbOrTx, + sourceWorkspaceId: string, + fileIds: string[], + fileKeys: string[] +): Promise { + if (fileIds.length === 0 && fileKeys.length === 0) return 0 + const selectors = [ + fileIds.length > 0 ? inArray(workspaceFiles.id, fileIds) : undefined, + fileKeys.length > 0 ? inArray(workspaceFiles.key, fileKeys) : undefined, + ].filter((clause): clause is NonNullable => clause !== undefined) + const rows = await executor + .select({ total: sql`coalesce(sum(${workspaceFiles.size}), 0)` }) + .from(workspaceFiles) + .where( + and( + selectors.length === 1 ? selectors[0] : or(...selectors), + eq(workspaceFiles.workspaceId, sourceWorkspaceId), + eq(workspaceFiles.context, 'workspace'), + isNull(workspaceFiles.deletedAt) + ) + ) + // `sum()` comes back as a string (bigint) from the driver; coerce explicitly. + return Number(rows[0]?.total ?? 0) +} + +/** + * Byte total of the KB document blobs the selected knowledge bases would re-key into the + * target. Scoped to live KBs in the source workspace (mirroring the container copy) and + * to LIVE documents with an internal blob: external/`data:` documents have a null + * `storageKey` (no blob is duplicated), and embeddings are DB rows the upload path never + * counts, so neither contributes bytes here. + */ +async function sumKbDocumentBytes( + executor: DbOrTx, + sourceWorkspaceId: string, + knowledgeBaseIds: string[] +): Promise { + if (knowledgeBaseIds.length === 0) return 0 + const rows = await executor + .select({ total: sql`coalesce(sum(${document.fileSize}), 0)` }) + .from(document) + .innerJoin(knowledgeBase, eq(document.knowledgeBaseId, knowledgeBase.id)) + .where( + and( + inArray(knowledgeBase.id, knowledgeBaseIds), + eq(knowledgeBase.workspaceId, sourceWorkspaceId), + isNull(knowledgeBase.deletedAt), + isNull(document.deletedAt), + isNull(document.archivedAt), + isNotNull(document.storageKey) + ) + ) + return Number(rows[0]?.total ?? 0) +} + +/** + * Byte total a fork/sync copy selection would duplicate into the target: selected + * workspace-file blobs plus the selected knowledge bases' stored document blobs. Sizes + * come from the metadata rows (`workspace_files.size`, `document.file_size`) - no blob + * reads. Both sums scope to the source workspace with the same filters the copy itself + * applies, so an id that is not actually copyable can only over-count (block), never + * under-count. + */ +export async function sumForkCopyBytes( + executor: DbOrTx, + sourceWorkspaceId: string, + selection: ForkCopyBytesSelection +): Promise { + const fileBytes = await sumWorkspaceFileBytes( + executor, + sourceWorkspaceId, + selection.fileIds ?? [], + selection.fileKeys ?? [] + ) + const kbBytes = await sumKbDocumentBytes( + executor, + sourceWorkspaceId, + selection.knowledgeBaseIds ?? [] + ) + return fileBytes + kbBytes +} + +/** + * Assert the initiating user's storage scope has headroom for `bytes` of copied blobs, + * using the exact quota helper the upload path uses (`checkStorageQuota`, which resolves + * the org-pooled vs personal scope from the user's subscription and always allows when + * billing is disabled). Over quota throws a {@link ForkError} (413, matching the upload + * routes' storage-limit status) carrying the upload path's quota error message, so the + * fork/sync modals surface the same user-facing text an over-quota upload would. + */ +export async function assertForkStorageHeadroom(params: { + userId: string + bytes: number +}): Promise { + const { userId, bytes } = params + if (bytes <= 0) return + const quota = await checkStorageQuota(userId, bytes) + if (quota.allowed) return + throw new ForkError( + `Not enough storage to copy the selected resources. ${quota.error ?? 'Storage limit exceeded'}`, + 413 + ) +} diff --git a/apps/sim/lib/workspaces/fork/create-fork.test.ts b/apps/sim/lib/workspaces/fork/create-fork.test.ts new file mode 100644 index 00000000000..c2f1ee680f8 --- /dev/null +++ b/apps/sim/lib/workspaces/fork/create-fork.test.ts @@ -0,0 +1,187 @@ +/** + * @vitest-environment node + */ +import { dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockSumForkCopyBytes, + mockAssertForkStorageHeadroom, + mockLoadSourceDeployedStates, + mockPlanForkFileCopies, + mockCopyForkResourceContainers, + mockStartBackgroundWork, + mockFinishBackgroundWork, + mockScheduleForkContentCopy, +} = vi.hoisted(() => ({ + mockSumForkCopyBytes: vi.fn(), + mockAssertForkStorageHeadroom: vi.fn(), + mockLoadSourceDeployedStates: vi.fn(), + mockPlanForkFileCopies: vi.fn(), + mockCopyForkResourceContainers: vi.fn(), + mockStartBackgroundWork: vi.fn(), + mockFinishBackgroundWork: vi.fn(), + mockScheduleForkContentCopy: vi.fn(), +})) + +vi.mock('@sim/db', () => dbChainMock) +vi.mock('@/lib/workflows/defaults', () => ({ + buildDefaultWorkflowArtifacts: vi.fn(() => ({ workflowState: {} })), +})) +vi.mock('@/lib/workflows/persistence/utils', () => ({ + saveWorkflowToNormalizedTables: vi.fn(), +})) +vi.mock('@/lib/workspaces/fork/background-work/store', () => ({ + startBackgroundWork: mockStartBackgroundWork, + finishBackgroundWork: mockFinishBackgroundWork, +})) +vi.mock('@/lib/workspaces/fork/copy/content-copy-runner', () => ({ + hasForkContentToCopy: vi.fn(() => false), + scheduleForkContentCopy: mockScheduleForkContentCopy, + serializeContentRefMaps: vi.fn(() => ({})), +})) +vi.mock('@/lib/workspaces/fork/copy/copy-files', () => ({ + planForkFileCopies: mockPlanForkFileCopies, +})) +vi.mock('@/lib/workspaces/fork/copy/copy-resources', () => ({ + copyForkResourceContainers: mockCopyForkResourceContainers, +})) +vi.mock('@/lib/workspaces/fork/copy/storage-quota', () => ({ + sumForkCopyBytes: mockSumForkCopyBytes, + assertForkStorageHeadroom: mockAssertForkStorageHeadroom, +})) +vi.mock('@/lib/workspaces/fork/copy/copy-workflows', () => ({ + copyWorkflowStateIntoTarget: vi.fn(), + loadWorkflowNameRegistry: vi.fn(async () => new Map()), + resolveForkFolderMapping: vi.fn(async () => new Map()), +})) +vi.mock('@/lib/workspaces/fork/copy/deploy-bridge', () => ({ + loadSourceDeployedStates: mockLoadSourceDeployedStates, +})) +vi.mock('@/lib/workspaces/fork/lineage/lineage', () => ({ + setForkLockTimeout: vi.fn(), +})) +vi.mock('@/lib/workspaces/fork/mapping/block-map-store', () => ({ + reconcileForkBlockPairs: vi.fn(), + toForkBlockPairs: vi.fn(() => []), +})) +vi.mock('@/lib/workspaces/fork/mapping/mapping-store', () => ({ + seedEdgeMappings: vi.fn(), +})) +vi.mock('@/lib/workspaces/fork/remap/fork-bootstrap', () => ({ + createForkBootstrapTransform: vi.fn(() => (subBlocks: unknown) => subBlocks), +})) +vi.mock('@/lib/workspaces/fork/remap/reference-scan', () => ({ + collectReferencedDocumentIds: vi.fn(() => new Set()), +})) +vi.mock('@/lib/workspaces/policy', () => ({ + WORKSPACE_MODE: { + PERSONAL: 'personal', + ORGANIZATION: 'organization', + GRANDFATHERED_SHARED: 'grandfathered_shared', + }, +})) + +import { createFork } from '@/lib/workspaces/fork/create-fork' + +const SOURCE = { id: 'src-ws', name: 'Parent' } as never +const POLICY = { + organizationId: null, + workspaceMode: 'personal', + billedAccountUserId: null, +} as never + +function forkParams(selection?: { + files?: string[] + knowledgeBases?: string[] +}): Parameters[0] { + return { + source: SOURCE, + policy: POLICY, + userId: 'user-1', + name: 'My Fork', + selection: { + files: selection?.files ?? [], + tables: [], + knowledgeBases: selection?.knowledgeBases ?? [], + customTools: [], + skills: [], + workflowMcpServers: [], + }, + requestId: 'test', + } +} + +describe('createFork storage headroom gate', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mockSumForkCopyBytes.mockResolvedValue(0) + mockAssertForkStorageHeadroom.mockResolvedValue(undefined) + mockLoadSourceDeployedStates.mockResolvedValue({ + deployedWorkflows: [], + sourceStates: new Map(), + }) + mockPlanForkFileCopies.mockResolvedValue({ + keyMap: new Map(), + idMap: new Map(), + blobTasks: [], + }) + mockCopyForkResourceContainers.mockResolvedValue({ + idMap: new Map(), + mappingEntries: [], + contentPlan: { + sourceWorkspaceId: 'src-ws', + childWorkspaceId: 'child-ws', + userId: 'user-1', + tables: [], + knowledgeBases: [], + skills: [], + documents: [], + }, + names: { + tables: [], + knowledgeBases: [], + customTools: [], + skills: [], + workflowMcpServers: [], + }, + }) + mockStartBackgroundWork.mockResolvedValue('status-1') + mockFinishBackgroundWork.mockResolvedValue(undefined) + }) + + it('fails an over-quota fork BEFORE any read or write, with the storage error', async () => { + mockSumForkCopyBytes.mockResolvedValue(999_999) + mockAssertForkStorageHeadroom.mockRejectedValue( + new Error( + 'Not enough storage to copy the selected resources. Storage limit exceeded. Used: 10.50GB, Limit: 10GB' + ) + ) + + await expect( + createFork(forkParams({ files: ['wf-1'], knowledgeBases: ['kb-1'] })) + ).rejects.toThrow('Not enough storage to copy the selected resources') + + expect(mockAssertForkStorageHeadroom).toHaveBeenCalledWith({ userId: 'user-1', bytes: 999_999 }) + // Nothing was read, created, or recorded: the fork failed before all of it. + expect(mockLoadSourceDeployedStates).not.toHaveBeenCalled() + expect(dbChainMockFns.transaction).not.toHaveBeenCalled() + expect(mockStartBackgroundWork).not.toHaveBeenCalled() + }) + + it('proceeds under quota, summing exactly the selected files + knowledge bases', async () => { + mockSumForkCopyBytes.mockResolvedValue(500) + + const result = await createFork(forkParams({ files: ['wf-1'], knowledgeBases: ['kb-1'] })) + + expect(result.workspace.name).toBe('My Fork') + expect(result.workflowsCopied).toBe(0) + expect(mockSumForkCopyBytes).toHaveBeenCalledWith(expect.anything(), 'src-ws', { + fileIds: ['wf-1'], + knowledgeBaseIds: ['kb-1'], + }) + expect(mockAssertForkStorageHeadroom).toHaveBeenCalledWith({ userId: 'user-1', bytes: 500 }) + expect(dbChainMockFns.transaction).toHaveBeenCalledTimes(1) + }) +}) diff --git a/apps/sim/lib/workspaces/fork/create-fork.ts b/apps/sim/lib/workspaces/fork/create-fork.ts index 0e4fce8e5de..37230f4ddd9 100644 --- a/apps/sim/lib/workspaces/fork/create-fork.ts +++ b/apps/sim/lib/workspaces/fork/create-fork.ts @@ -29,6 +29,10 @@ import { resolveForkFolderMapping, } from '@/lib/workspaces/fork/copy/copy-workflows' import { loadSourceDeployedStates } from '@/lib/workspaces/fork/copy/deploy-bridge' +import { + assertForkStorageHeadroom, + sumForkCopyBytes, +} from '@/lib/workspaces/fork/copy/storage-quota' import { buildForkWorkflowIdMap } from '@/lib/workspaces/fork/copy/workflow-id-map' import { setForkLockTimeout } from '@/lib/workspaces/fork/lineage/lineage' import { @@ -111,6 +115,16 @@ export async function createFork(params: CreateForkParams): Promise child folder id map: remaps folder references in the copied workflows below and // feeds the post-commit content-ref rewrite (`sim:folder/` mentions in skill/file bodies). + // Scoped to the folders that will actually receive a copied workflow (plus ancestors): a + // fork copies only DEPLOYED workflows, so folders holding none would be created empty in + // the child and are pruned instead. Copied files don't extend this set - they use the + // separate workspace-file-folder entity and land at the child's root. const folderIdMap = await resolveForkFolderMapping({ tx, sourceWorkspaceId: source.id, targetWorkspaceId: childWorkspaceId, userId, now, + contentFolderIds: deployedWorkflows + .filter((wf) => workflowIdMap.has(wf.id)) + .map((wf) => wf.folderId), }) const resourceResult = await copyForkResourceContainers({ diff --git a/apps/sim/lib/workspaces/fork/mapping/mapping-service.ts b/apps/sim/lib/workspaces/fork/mapping/mapping-service.ts index 7a9f4ad3115..a2f05cea3f4 100644 --- a/apps/sim/lib/workspaces/fork/mapping/mapping-service.ts +++ b/apps/sim/lib/workspaces/fork/mapping/mapping-service.ts @@ -205,7 +205,12 @@ export async function getForkMappingView( sourceLabel: p.sourceLabel, targetId, suggested, - required: p.reference.required, + // Every entry here is a reference a synced workflow actually carries, and a sync is + // blocked while ANY reference would clear - so every entry is required. Copyable kinds + // (table / KB / file / custom tool / skill) also satisfy the gate by being selected for + // copy; map-only kinds (credential / env-var / MCP server) and source-deleted resources + // (no copy candidate) must be mapped. + required: true, candidates, // The full (unfiltered) target list for this kind hit the cap, so the picker is // showing a partial list - the UI tells the user to refine. diff --git a/apps/sim/lib/workspaces/fork/mapping/resources.test.ts b/apps/sim/lib/workspaces/fork/mapping/resources.test.ts index b67ce351f22..a29094c217e 100644 --- a/apps/sim/lib/workspaces/fork/mapping/resources.test.ts +++ b/apps/sim/lib/workspaces/fork/mapping/resources.test.ts @@ -5,6 +5,7 @@ import { dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing' import { beforeEach, describe, expect, it } from 'vitest' import type { DbOrTx } from '@/lib/db/types' import { + listForkCopyableSourceResources, listForkResourceCandidates, loadForkCopyableResourceLabels, } from '@/lib/workspaces/fork/mapping/resources' @@ -49,6 +50,75 @@ describe('listForkResourceCandidates', () => { }) }) +describe('listForkCopyableSourceResources', () => { + beforeEach(() => { + resetDbChainMock() + }) + + it('lists every sync-copyable kind, files keyed by storage key with folder grouping', async () => { + // The grouped queries resolve in Promise.all array order, each ending in `.limit()`: + // files (with folder), tables, knowledge bases, custom tools, skills. + dbChainMockFns.limit + .mockResolvedValueOnce([ + { + id: 'file-row-1', + key: 'workspace/SRC/a.png', + label: 'a.png', + folderId: 'fld-1', + folderName: 'Images', + }, + { + id: 'file-row-2', + key: 'workspace/SRC/root.txt', + label: 'root.txt', + folderId: null, + folderName: null, + }, + ]) + .mockResolvedValueOnce([{ id: 'tbl-1', label: 'Table One' }]) + .mockResolvedValueOnce([{ id: 'kb-1', label: 'KB One' }]) + .mockResolvedValueOnce([{ id: 'ct-1', label: 'Tool One' }]) + .mockResolvedValueOnce([{ id: 'sk-1', label: 'Skill One' }]) + + const result = await listForkCopyableSourceResources(executor, 'ws-src') + + expect(result).toEqual([ + // Files are addressed by STORAGE KEY (matching `file-upload` references + the promote copy + // selection), never by `workspace_files.id`, and carry their folder grouping. + { + kind: 'file', + sourceId: 'workspace/SRC/a.png', + label: 'a.png', + parentId: 'fld-1', + parentLabel: 'Images', + }, + { + kind: 'file', + sourceId: 'workspace/SRC/root.txt', + label: 'root.txt', + parentId: null, + parentLabel: null, + }, + { kind: 'table', sourceId: 'tbl-1', label: 'Table One', parentId: null, parentLabel: null }, + { + kind: 'knowledge-base', + sourceId: 'kb-1', + label: 'KB One', + parentId: null, + parentLabel: null, + }, + { + kind: 'custom-tool', + sourceId: 'ct-1', + label: 'Tool One', + parentId: null, + parentLabel: null, + }, + { kind: 'skill', sourceId: 'sk-1', label: 'Skill One', parentId: null, parentLabel: null }, + ]) + }) +}) + describe('loadForkCopyableResourceLabels', () => { beforeEach(() => { resetDbChainMock() diff --git a/apps/sim/lib/workspaces/fork/mapping/resources.ts b/apps/sim/lib/workspaces/fork/mapping/resources.ts index 23b0bf1cd38..1a9702e2da9 100644 --- a/apps/sim/lib/workspaces/fork/mapping/resources.ts +++ b/apps/sim/lib/workspaces/fork/mapping/resources.ts @@ -17,7 +17,7 @@ import { and, count, eq, exists, inArray, isNull, sql } from 'drizzle-orm' import type { ForkCopyableKind } from '@/lib/api/contracts/workspace-fork' import type { DbOrTx } from '@/lib/db/types' import type { ForkResourceType } from '@/lib/workspaces/fork/mapping/mapping-store' -import type { ForkRemapKind } from '@/lib/workspaces/fork/remap/remap-references' +import type { ForkMcpServerMeta, ForkRemapKind } from '@/lib/workspaces/fork/remap/remap-references' export interface ForkResourceCandidate { id: string @@ -329,6 +329,32 @@ export async function filterExistingForkTargets( return result } +/** + * Identity metadata (`name`/`url`) for the given MCP server ids in a workspace, looked up by + * exact id (no candidate cap, same deleted filter as the candidates). Promote uses it for the + * MAPPED TARGET servers so remapped tool-input entries rewrite their embedded server metadata + * from the target row (see {@link ForkMcpServerMeta}) - one bounded `inArray` read per sync, + * never per-entry. An id absent from the map no longer exists; its entries are left as-is. + */ +export async function getMcpServerMetaByIds( + executor: DbOrTx, + workspaceId: string, + ids: string[] +): Promise> { + if (ids.length === 0) return new Map() + const rows = await executor + .select({ id: mcpServers.id, name: mcpServers.name, url: mcpServers.url }) + .from(mcpServers) + .where( + and( + eq(mcpServers.workspaceId, workspaceId), + isNull(mcpServers.deletedAt), + inArray(mcpServers.id, ids) + ) + ) + return new Map(rows.map((row) => [row.id, { name: row.name, url: row.url ?? null }])) +} + /** * Provider id for each given credential id in a workspace, looked up by exact id (no * candidate cap). Presence in the returned map means the credential exists in the @@ -451,6 +477,66 @@ export interface ForkCopyableLabel { parentLabel: string | null } +/** + * One copyable resource in the sync SOURCE workspace, keyed the way the promote copy addresses + * it: files by STORAGE KEY (matching `file-upload` references + `planForkFileCopies`), every + * other kind by row id. `parentId`/`parentLabel` carry a file's folder grouping (null for + * non-file kinds and root files). + */ +export interface ForkCopyableSourceResource { + kind: ForkCopyableKind + sourceId: string + label: string + parentId: string | null + parentLabel: string | null +} + +/** + * Every copyable-kind resource in the sync source workspace (same archived/deleted filters and + * per-kind {@link CANDIDATE_LIMIT} cap as the copy picker), as sync-copy candidate entries. The + * promote plan filters these down to the UNREFERENCED-and-unmapped set it offers for copy + * alongside the referenced candidates. Covers exactly the sync-copyable kinds + * (`forkCopyableKindSchema`): workflow-publishing MCP servers are fork-copy-only (their copies + * are not recorded in the fork resource map, so a sync copy could never be idempotent) and + * external MCP servers / credentials / env vars are never copied. + */ +export async function listForkCopyableSourceResources( + executor: DbOrTx, + sourceWorkspaceId: string +): Promise { + const [files, tables, kbs, tools, skills] = await Promise.all([ + fileCandidatesWithFolderQuery(executor, sourceWorkspaceId), + tableCandidatesQuery(executor, sourceWorkspaceId), + knowledgeBaseCandidatesQuery(executor, sourceWorkspaceId), + customToolCandidatesQuery(executor, sourceWorkspaceId), + skillCandidatesQuery(executor, sourceWorkspaceId), + ]) + const flat = ( + kind: ForkCopyableKind, + rows: Array<{ id: string; label: string }> + ): ForkCopyableSourceResource[] => + rows.map((row) => ({ + kind, + sourceId: row.id, + label: row.label, + parentId: null, + parentLabel: null, + })) + return [ + ...files.map((row) => ({ + kind: 'file' as const, + sourceId: row.key, + label: row.label, + parentId: row.folderId, + parentLabel: row.folderName, + })), + ...flat('table', tables), + ...flat('knowledge-base', kbs), + ...flat('custom-tool', tools), + ...flat('skill', skills), + ] +} + /** * Labels (by exact id) for the copyable resource kinds referenced-but-unmapped at promote time, * scoped to the source workspace and the same archived/deleted filters as the copy picker. A diff --git a/apps/sim/lib/workspaces/fork/promote/cleared-refs.test.ts b/apps/sim/lib/workspaces/fork/promote/cleared-refs.test.ts index 69f3adb8d01..278f421b111 100644 --- a/apps/sim/lib/workspaces/fork/promote/cleared-refs.test.ts +++ b/apps/sim/lib/workspaces/fork/promote/cleared-refs.test.ts @@ -1,7 +1,7 @@ /** * @vitest-environment node */ -import { describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import type { SubBlockConfig } from '@/blocks/types' // The reference indexer resolves a tool's params via the tool registry; stub it so loading the @@ -19,7 +19,30 @@ vi.mock('@/tools/params', () => ({ formatParameterLabel: (label: string) => label, })) -import { collectForkClearedRefCandidates } from '@/lib/workspaces/fork/promote/cleared-refs' +// The liveness annotation + gate label loading go through the mapping resource helpers; mocked so +// each case controls which source ids read as still-alive and which labels resolve. +const { mockFilterExisting, mockLoadCopyableLabels } = vi.hoisted(() => ({ + mockFilterExisting: vi.fn(), + mockLoadCopyableLabels: vi.fn(), +})) +vi.mock('@/lib/workspaces/fork/mapping/resources', () => ({ + filterExistingForkTargets: mockFilterExisting, + loadForkCopyableResourceLabels: mockLoadCopyableLabels, + getWorkspaceEnvKeys: vi.fn(), + listForkCopyableSourceResources: vi.fn(), + listForkResourceCandidates: vi.fn(), + getCredentialProvidersByIds: vi.fn(), + classifyCredentialResourceType: vi.fn(), + CANDIDATE_LIMIT: 1000, +})) + +import type { DbOrTx } from '@/lib/db/types' +import { + annotateForkClearedRefSourceLiveness, + collectForkClearedRefCandidates, + collectForkSyncBlockers, +} from '@/lib/workspaces/fork/promote/cleared-refs' +import { buildPromoteWorkflowIdMap } from '@/lib/workspaces/fork/promote/promote-plan' import { buildForkBlockIdResolver, deriveForkBlockId, @@ -114,6 +137,8 @@ describe('collectForkClearedRefCandidates', () => { sourceId: 'kb-src', sourceLabel: 'Docs KB', cause: 'reference', + // Collected as false; source liveness is annotated afterwards (DB check). + sourceDeleted: false, }, ]) }) @@ -271,7 +296,7 @@ describe('collectForkClearedRefCandidates', () => { expect(result).toEqual([]) }) - it('collapses the workflowId pair to the active member: a dormant basic selector is not a false cleared-ref', () => { + it('collapses the workflowId pair to the active member: manual active emits nothing, basic selector still clears', () => { vi.mocked(getBlock).mockReturnValue( blockWith([ { @@ -290,7 +315,16 @@ describe('collectForkClearedRefCandidates', () => { }, ]) ) - // Advanced mode active; the dormant basic selector holds a stale, uncopied id. + const item = { + sourceWorkflowId: 'wf-src', + targetWorkflowId: 'wf-tgt', + mode: 'replace' as const, + sourceMeta: { name: 'Caller' }, + } + + // Advanced (manual) mode active; the dormant basic selector holds a stale, uncopied id. The + // manual member is user-owned (never cleared) and the dormant basic selector is collapsed away, + // so NO workflow cleared-ref rows even when nothing is carried into the target. const advancedState = { blocks: { 'block-1': { @@ -309,34 +343,274 @@ describe('collectForkClearedRefCandidates', () => { parallels: {}, variables: {}, } as unknown as WorkflowState + const manualActive = collectForkClearedRefCandidates( + params({ + items: [item], + sourceStates: new Map([['wf-src', advancedState]]), + sourceWorkflowNames: new Map([['wf-active', 'Active Workflow']]), + }) + ) + expect(manualActive.filter((ref) => ref.cause === 'workflow')).toEqual([]) + + // Active BASIC selector path unbroken: an uncopied selector value still produces a workflow row. + const basicState = { + blocks: { + 'block-1': { + id: 'block-1', + type: 'workflow', + name: 'Caller', + data: { canonicalModes: { workflowId: 'basic' } }, + subBlocks: { + workflowId: { type: 'workflow-selector', value: 'wf-basic' }, + manualWorkflowId: { type: 'short-input', value: 'wf-active' }, + }, + }, + }, + edges: [], + loops: {}, + parallels: {}, + variables: {}, + } as unknown as WorkflowState + const basicActive = collectForkClearedRefCandidates( + params({ + items: [item], + sourceStates: new Map([['wf-src', basicState]]), + sourceWorkflowNames: new Map([['wf-basic', 'Basic Workflow']]), + }) + ) + const workflowRows = basicActive.filter((ref) => ref.cause === 'workflow') + expect(workflowRows).toHaveLength(1) + expect(workflowRows[0].sourceId).toBe('wf-basic') + }) + + it('collapses the workflowIds pair to the active member: a stale dormant workflowSelector array emits nothing, active basic still clears', () => { + vi.mocked(getBlock).mockReturnValue( + blockWith([ + { + id: 'workflowSelector', + title: 'Workflows', + type: 'dropdown', + canonicalParamId: 'workflowIds', + mode: 'basic', + }, + { + id: 'manualWorkflowIds', + title: 'Workflow IDs', + type: 'short-input', + canonicalParamId: 'workflowIds', + mode: 'advanced', + }, + ]) + ) const item = { sourceWorkflowId: 'wf-src', targetWorkflowId: 'wf-tgt', mode: 'replace' as const, - sourceMeta: { name: 'Caller' }, + sourceMeta: { name: 'Logs' }, } + const stateWithModes = (canonicalModes: Record): WorkflowState => + ({ + blocks: { + 'block-1': { + id: 'block-1', + type: 'logs', + name: 'Logs', + data: { canonicalModes }, + subBlocks: { + // Switching to advanced does NOT clear the dormant basic selector, so a stale + // non-empty array persists here. + workflowSelector: { type: 'dropdown', value: ['wf-stale-1', 'wf-stale-2'] }, + manualWorkflowIds: { type: 'short-input', value: 'wf-manual' }, + }, + }, + }, + edges: [], + loops: {}, + parallels: {}, + variables: {}, + }) as unknown as WorkflowState - // Active advanced workflow carried into the target: the dormant basic must NOT produce a row. - const carried = collectForkClearedRefCandidates( + // Advanced mode active: the dormant selector's stale, unmapped ids must NOT surface as + // workflow cleared-refs (they would be unresolvable sync blockers - the modal can't map them). + const advancedActive = collectForkClearedRefCandidates( params({ items: [item], - sourceStates: new Map([['wf-src', advancedState]]), - workflowIdMap: new Map([['wf-active', 'wf-active-child']]), + sourceStates: new Map([['wf-src', stateWithModes({ workflowIds: 'advanced' })]]), + workflowIdMap: new Map(), }) ) - expect(carried.filter((ref) => ref.cause === 'workflow')).toEqual([]) + expect(advancedActive.filter((ref) => ref.cause === 'workflow')).toEqual([]) - // The ACTIVE member still produces a row when it is not carried (active path unbroken). - const cleared = collectForkClearedRefCandidates( + // Active BASIC selector path unbroken: the same unmapped ids still emit one row each. + const basicActive = collectForkClearedRefCandidates( params({ items: [item], - sourceStates: new Map([['wf-src', advancedState]]), - sourceWorkflowNames: new Map([['wf-active', 'Active Workflow']]), + sourceStates: new Map([['wf-src', stateWithModes({ workflowIds: 'basic' })]]), + workflowIdMap: new Map(), }) ) - const workflowRows = cleared.filter((ref) => ref.cause === 'workflow') - expect(workflowRows).toHaveLength(1) - expect(workflowRows[0].sourceId).toBe('wf-active') + const workflowRows = basicActive.filter((ref) => ref.cause === 'workflow') + expect(workflowRows.map((ref) => ref.sourceId)).toEqual(['wf-stale-1', 'wf-stale-2']) + }) + + // The sim workspace-event trigger's workflow filter: a multi-select `dropdown` with baseKey + // `workflowIds` (options are workspace workflow ids). Uncarried ids are dropped by the remap, + // so they must surface as workflow-cause cleared refs / sync blockers. + it('emits workflow refs for the workspace-event trigger workflowIds dropdown (uncarried ids)', () => { + vi.mocked(getBlock).mockReturnValue( + blockWith([{ id: 'workflowIds', title: 'Workflows', type: 'dropdown', multiSelect: true }]) + ) + const result = collectForkClearedRefCandidates( + params({ + items: [ + { + sourceWorkflowId: 'wf-src', + targetWorkflowId: 'wf-tgt', + mode: 'replace', + sourceMeta: { name: 'Alerts' }, + }, + ], + sourceStates: new Map([ + [ + 'wf-src', + stateWith('sim_workspace_event', 'Workspace Events', { + workflowIds: { type: 'dropdown', value: ['wf-watched', 'wf-carried'] }, + }), + ], + ]), + workflowIdMap: new Map([['wf-carried', 'wf-carried-tgt']]), + sourceWorkflowNames: new Map([['wf-watched', 'Watched Workflow']]), + }) + ) + expect(result).toEqual([ + { + targetWorkflowId: 'wf-tgt', + workflowName: 'Alerts', + blockId: targetBlockId, + blockLabel: 'Workspace Events', + fieldLabel: 'Workflows', + kind: 'workflow', + sourceId: 'wf-watched', + sourceLabel: 'Watched Workflow', + cause: 'workflow', + }, + ]) + }) + + it('emits nothing for the trigger workflowIds when every watched workflow is carried', () => { + vi.mocked(getBlock).mockReturnValue( + blockWith([{ id: 'workflowIds', title: 'Workflows', type: 'dropdown', multiSelect: true }]) + ) + const result = collectForkClearedRefCandidates( + params({ + items: [ + { + sourceWorkflowId: 'wf-src', + targetWorkflowId: 'wf-tgt', + mode: 'replace', + sourceMeta: { name: 'Alerts' }, + }, + ], + sourceStates: new Map([ + [ + 'wf-src', + stateWith('sim_workspace_event', 'Workspace Events', { + workflowIds: { type: 'dropdown', value: ['wf-a', 'wf-b'] }, + }), + ], + ]), + workflowIdMap: new Map([ + ['wf-a', 'wf-a-tgt'], + ['wf-b', 'wf-b-tgt'], + ]), + }) + ) + expect(result).toEqual([]) + }) + + // The TYPE gate: the legacy logs block's `workflowIds` is a free-form short-input (manual, + // user-owned, never remapped/cleared), so it must not emit workflow cleared-refs. + it('does not treat the legacy logs short-input workflowIds as a workflow reference', () => { + vi.mocked(getBlock).mockReturnValue( + blockWith([{ id: 'workflowIds', title: 'Workflow IDs', type: 'short-input' }]) + ) + const result = collectForkClearedRefCandidates( + params({ + items: [ + { + sourceWorkflowId: 'wf-src', + targetWorkflowId: 'wf-tgt', + mode: 'replace', + sourceMeta: { name: 'Logs' }, + }, + ], + sourceStates: new Map([ + [ + 'wf-src', + stateWith('logs', 'Logs', { + workflowIds: { type: 'short-input', value: 'wf-a,wf-b' }, + }), + ], + ]), + workflowIdMap: new Map(), + }) + ) + expect(result.filter((ref) => ref.cause === 'workflow')).toEqual([]) + }) + + it('does not emit manual manualWorkflowIds values as workflow cleared-refs', () => { + vi.mocked(getBlock).mockReturnValue( + blockWith([ + { + id: 'workflowSelector', + title: 'Workflows', + type: 'dropdown', + canonicalParamId: 'workflowIds', + mode: 'basic', + }, + { + id: 'manualWorkflowIds', + title: 'Workflow IDs', + type: 'short-input', + canonicalParamId: 'workflowIds', + mode: 'advanced', + }, + ]) + ) + // Active advanced manual list holds uncopied ids: user-owned, so never a workflow cleared-ref. + const state = { + blocks: { + 'block-1': { + id: 'block-1', + type: 'logs', + name: 'Logs', + data: { canonicalModes: { workflowIds: 'advanced' } }, + subBlocks: { + workflowSelector: { type: 'dropdown', value: [] }, + manualWorkflowIds: { type: 'short-input', value: 'wf-a,wf-b' }, + }, + }, + }, + edges: [], + loops: {}, + parallels: {}, + variables: {}, + } as unknown as WorkflowState + const result = collectForkClearedRefCandidates( + params({ + items: [ + { + sourceWorkflowId: 'wf-src', + targetWorkflowId: 'wf-tgt', + mode: 'replace', + sourceMeta: { name: 'Logs' }, + }, + ], + sourceStates: new Map([['wf-src', state]]), + workflowIdMap: new Map(), + }) + ) + expect(result.filter((ref) => ref.cause === 'workflow')).toEqual([]) }) it('emits a configured create-target dependent a remapped parent will clear (cause dependent)', () => { @@ -526,3 +800,542 @@ describe('collectForkClearedRefCandidates', () => { expect(result).toEqual([]) }) }) + +const replaceItem = { + sourceWorkflowId: 'wf-src', + targetWorkflowId: 'wf-tgt', + mode: 'replace' as const, + sourceMeta: { name: 'Caller' }, +} + +/** Fake executor whose select chains resolve queued row sets in call order. */ +function makeExecutor(rowSets: unknown[][] = []) { + let call = 0 + const select = vi.fn(() => ({ + from: vi.fn(() => ({ + where: vi.fn(() => Promise.resolve(rowSets[call++] ?? [])), + })), + })) + return { executor: { select } as unknown as DbOrTx, select } +} + +describe('annotateForkClearedRefSourceLiveness', () => { + beforeEach(() => { + mockFilterExisting.mockReset() + mockFilterExisting.mockResolvedValue({}) + }) + + const referenceRef = (kind: 'table' | 'knowledge-base', sourceId: string) => ({ + targetWorkflowId: 'wf-tgt', + workflowName: 'Caller', + blockId: 'b1', + blockLabel: 'Block', + fieldLabel: 'Field', + kind, + sourceId, + sourceLabel: sourceId, + cause: 'reference' as const, + sourceDeleted: false, + }) + + it('flags deleted sources and leaves live ones (checked against the SOURCE workspace)', async () => { + mockFilterExisting.mockResolvedValue({ table: new Set(['tbl-live']) }) + const { executor } = makeExecutor() + const result = await annotateForkClearedRefSourceLiveness(executor, 'src-ws', [ + referenceRef('table', 'tbl-live'), + referenceRef('table', 'tbl-gone'), + ]) + expect(mockFilterExisting).toHaveBeenCalledWith(executor, 'src-ws', { + table: new Set(['tbl-live', 'tbl-gone']), + }) + expect(result.map((ref) => (ref.cause === 'reference' ? ref.sourceDeleted : null))).toEqual([ + false, + true, + ]) + }) + + it('no-ops with zero queries when there are no reference-cause entries', async () => { + const { executor } = makeExecutor() + const workflowRef = { + targetWorkflowId: 'wf-tgt', + workflowName: 'Caller', + blockId: 'b1', + blockLabel: 'Block', + fieldLabel: 'Workflow', + kind: 'workflow' as const, + sourceId: 'wf-other', + sourceLabel: 'Other', + cause: 'workflow' as const, + } + const result = await annotateForkClearedRefSourceLiveness(executor, 'src-ws', [workflowRef]) + expect(result).toEqual([workflowRef]) + expect(mockFilterExisting).not.toHaveBeenCalled() + }) +}) + +describe('collectForkSyncBlockers', () => { + beforeEach(() => { + mockFilterExisting.mockReset() + mockLoadCopyableLabels.mockReset() + mockFilterExisting.mockResolvedValue({}) + mockLoadCopyableLabels.mockResolvedValue(new Map()) + }) + + const baseParams = (overrides: Partial[0]>) => ({ + executor: makeExecutor().executor, + sourceWorkspaceId: 'src-ws', + items: [replaceItem], + sourceStates: new Map(), + resolver: (() => null) as ForkReferenceResolver, + workflowIdMap: new Map(), + resolveBlockId, + ...overrides, + }) + + it('blocks an unmapped referenced copyable (unmapped-copyable) with its loaded label', async () => { + vi.mocked(getBlock).mockReturnValue( + blockWith([{ id: 'tbl', title: 'Table', type: 'table-selector' }]) + ) + mockFilterExisting.mockResolvedValue({ table: new Set(['tbl-src']) }) + mockLoadCopyableLabels.mockResolvedValue( + new Map([['table:tbl-src', { label: 'Orders', parentId: null, parentLabel: null }]]) + ) + const blockers = await collectForkSyncBlockers( + baseParams({ + sourceStates: new Map([ + [ + 'wf-src', + stateWith('table', 'Table Block', { + tbl: { type: 'table-selector', value: 'tbl-src' }, + }), + ], + ]), + }) + ) + expect(blockers).toEqual([ + { + workflowName: 'Caller', + blockLabel: 'Table Block', + fieldLabel: 'Table', + kind: 'table', + sourceId: 'tbl-src', + sourceLabel: 'Orders', + reason: 'unmapped-copyable', + }, + ]) + }) + + it('passes with ZERO queries when the resolver maps/copy-resolves every reference', async () => { + vi.mocked(getBlock).mockReturnValue( + blockWith([{ id: 'tbl', title: 'Table', type: 'table-selector' }]) + ) + const { executor, select } = makeExecutor() + const blockers = await collectForkSyncBlockers( + baseParams({ + executor, + sourceStates: new Map([ + [ + 'wf-src', + stateWith('table', 'Table Block', { + tbl: { type: 'table-selector', value: 'tbl-src' }, + }), + ], + ]), + // The promote gate overlays the copy selection onto the plan resolver; a mapped OR + // copy-selected reference resolves non-null and never reaches the blocker list. + resolver: (kind, id) => (kind === 'table' && id === 'tbl-src' ? 'tbl-copy' : null), + }) + ) + expect(blockers).toEqual([]) + expect(mockFilterExisting).not.toHaveBeenCalled() + expect(select).not.toHaveBeenCalled() + }) + + it('blocks an unmapped external MCP server (unmapped-mcp-server), named via the source read', async () => { + vi.mocked(getBlock).mockReturnValue( + blockWith([{ id: 'server', title: 'Server', type: 'mcp-server-selector' }]) + ) + mockFilterExisting.mockResolvedValue({ 'mcp-server': new Set(['srv-1']) }) + const { executor } = makeExecutor([[{ id: 'srv-1', name: 'Internal Tools' }]]) + const blockers = await collectForkSyncBlockers( + baseParams({ + executor, + sourceStates: new Map([ + [ + 'wf-src', + stateWith('mcp', 'MCP Block', { + server: { type: 'mcp-server-selector', value: 'srv-1' }, + }), + ], + ]), + }) + ) + expect(blockers).toEqual([ + expect.objectContaining({ + kind: 'mcp-server', + sourceId: 'srv-1', + sourceLabel: 'Internal Tools', + reason: 'unmapped-mcp-server', + }), + ]) + }) + + it('blocks a source-deleted reference (source-deleted) - no exemption, resolvable by mapping', async () => { + vi.mocked(getBlock).mockReturnValue( + blockWith([{ id: 'kb', title: 'Knowledge Base', type: 'knowledge-base-selector' }]) + ) + // The liveness check reports the source row gone; the copy loader (live rows only) misses, + // so the label falls back to the id. + mockFilterExisting.mockResolvedValue({ 'knowledge-base': new Set() }) + const blockers = await collectForkSyncBlockers( + baseParams({ + sourceStates: new Map([ + [ + 'wf-src', + stateWith('knowledge', 'KB Block', { + kb: { type: 'knowledge-base-selector', value: 'kb-gone' }, + }), + ], + ]), + }) + ) + expect(blockers).toEqual([ + expect.objectContaining({ + kind: 'knowledge-base', + sourceId: 'kb-gone', + sourceLabel: 'kb-gone', + reason: 'source-deleted', + }), + ]) + // Mapping the dead id to a live target resolves it (the resolver never checks source + // liveness - a mapping row whose source row is gone still resolves). + const resolved = await collectForkSyncBlockers( + baseParams({ + sourceStates: new Map([ + [ + 'wf-src', + stateWith('knowledge', 'KB Block', { + kb: { type: 'knowledge-base-selector', value: 'kb-gone' }, + }), + ], + ]), + resolver: (kind, id) => (kind === 'knowledge-base' && id === 'kb-gone' ? 'kb-tgt' : null), + }) + ) + expect(resolved).toEqual([]) + }) + + it('blocks a workflow reference that would clear (workflow-missing), named via the source read', async () => { + vi.mocked(getBlock).mockReturnValue( + blockWith([{ id: 'target', title: 'Workflow', type: 'workflow-selector' }]) + ) + const { executor } = makeExecutor([[{ id: 'wf-child', name: 'Child Flow' }]]) + // The child was deleted in the source: not an item, not in the identity map -> the map + // built by buildPromoteWorkflowIdMap misses and the reference would clear. + const workflowIdMap = buildPromoteWorkflowIdMap({ + identityMap: new Map([['wf-child', 'wf-child-tgt']]), + existingSourceIds: new Set(), + targetActiveIds: new Set(['wf-child-tgt']), + items: [{ sourceWorkflowId: 'wf-src', targetWorkflowId: 'wf-tgt' }], + }) + const blockers = await collectForkSyncBlockers( + baseParams({ + executor, + sourceStates: new Map([ + [ + 'wf-src', + stateWith('workflow_caller', 'Run Subflow', { + target: { type: 'workflow-selector', value: 'wf-child' }, + }), + ], + ]), + workflowIdMap, + }) + ) + expect(blockers).toEqual([ + expect.objectContaining({ + kind: 'workflow', + sourceId: 'wf-child', + sourceLabel: 'Child Flow', + reason: 'workflow-missing', + }), + ]) + }) + + it('does NOT block a previously-synced, source-undeployed child (its mapping still resolves)', async () => { + vi.mocked(getBlock).mockReturnValue( + blockWith([{ id: 'target', title: 'Workflow', type: 'workflow-selector' }]) + ) + const { executor, select } = makeExecutor() + // The child exists in the source (merely undeployed, so not an item this push) and its + // mapped target is still active: the identity seed repoints the reference, nothing clears. + const workflowIdMap = buildPromoteWorkflowIdMap({ + identityMap: new Map([['wf-child', 'wf-child-tgt']]), + existingSourceIds: new Set(['wf-child']), + targetActiveIds: new Set(['wf-child-tgt']), + items: [{ sourceWorkflowId: 'wf-src', targetWorkflowId: 'wf-tgt' }], + }) + const blockers = await collectForkSyncBlockers( + baseParams({ + executor, + sourceStates: new Map([ + [ + 'wf-src', + stateWith('workflow_caller', 'Run Subflow', { + target: { type: 'workflow-selector', value: 'wf-child' }, + }), + ], + ]), + workflowIdMap, + }) + ) + expect(blockers).toEqual([]) + expect(select).not.toHaveBeenCalled() + }) + + it('returns identical blockers via the reused-plan path and a fresh scan, incl. an irrelevant copy selection', async () => { + vi.mocked(getBlock).mockReturnValue( + blockWith([{ id: 'tbl', title: 'Table', type: 'table-selector' }]) + ) + mockFilterExisting.mockResolvedValue({ table: new Set(['tbl-src']) }) + mockLoadCopyableLabels.mockResolvedValue( + new Map([['table:tbl-src', { label: 'Orders', parentId: null, parentLabel: null }]]) + ) + const sourceStates = new Map([ + [ + 'wf-src', + stateWith('table', 'Table Block', { + tbl: { type: 'table-selector', value: 'tbl-src' }, + }), + ], + ]) + + const freshScan = await collectForkSyncBlockers(baseParams({ sourceStates })) + const reusedPlan = await collectForkSyncBlockers( + baseParams({ + sourceStates, + planUnmapped: [{ kind: 'table', sourceId: 'tbl-src' }], + }) + ) + // An irrelevant copy selection: the overlay resolver resolves a candidate no synced block + // references. The plan lists it as unmapped, the overlay resolves it, and the blockers are + // unchanged either way. + const overlayResolver: ForkReferenceResolver = (kind, id) => + kind === 'custom-tool' && id === 'ct-unreferenced' ? 'ct-copy' : null + const withIrrelevantCopy = await collectForkSyncBlockers( + baseParams({ + sourceStates, + resolver: overlayResolver, + planUnmapped: [ + { kind: 'table', sourceId: 'tbl-src' }, + { kind: 'custom-tool', sourceId: 'ct-unreferenced' }, + ], + }) + ) + + expect(freshScan).toEqual([ + expect.objectContaining({ kind: 'table', sourceId: 'tbl-src', reason: 'unmapped-copyable' }), + ]) + expect(reusedPlan).toEqual(freshScan) + expect(withIrrelevantCopy).toEqual(freshScan) + }) + + it('skips the per-block reference re-scan when the plan reports nothing unmapped', async () => { + // Deliberately inconsistent inputs: the state carries an unmapped table ref a fresh scan + // WOULD flag, but the supplied plan data says nothing is unmapped. The empty result proves + // the reused-plan shortcut skipped the re-scan entirely (in production the plan is computed + // over the same states inside the same tx, so the inputs can never actually diverge). + vi.mocked(getBlock).mockReturnValue( + blockWith([{ id: 'tbl', title: 'Table', type: 'table-selector' }]) + ) + const { executor, select } = makeExecutor() + const blockers = await collectForkSyncBlockers( + baseParams({ + executor, + sourceStates: new Map([ + [ + 'wf-src', + stateWith('table', 'Table Block', { + tbl: { type: 'table-selector', value: 'tbl-src' }, + }), + ], + ]), + planUnmapped: [], + }) + ) + expect(blockers).toEqual([]) + expect(mockFilterExisting).not.toHaveBeenCalled() + expect(select).not.toHaveBeenCalled() + }) + + it('short-circuits with zero scans/queries when the copy overlay resolves every plan-unmapped ref', async () => { + vi.mocked(getBlock).mockReturnValue( + blockWith([{ id: 'tbl', title: 'Table', type: 'table-selector' }]) + ) + const { executor, select } = makeExecutor() + const blockers = await collectForkSyncBlockers( + baseParams({ + executor, + sourceStates: new Map([ + [ + 'wf-src', + stateWith('table', 'Table Block', { + tbl: { type: 'table-selector', value: 'tbl-src' }, + }), + ], + ]), + // The plan saw the ref unmapped; the gate resolver (plan resolver + copy-selection + // overlay) resolves it, so no blocking candidate can exist. + resolver: (kind, id) => (kind === 'table' && id === 'tbl-src' ? 'tbl-copy' : null), + planUnmapped: [{ kind: 'table', sourceId: 'tbl-src' }], + }) + ) + expect(blockers).toEqual([]) + expect(mockFilterExisting).not.toHaveBeenCalled() + expect(select).not.toHaveBeenCalled() + }) + + it('still blocks on a would-clear workflow reference through the reused-plan path', async () => { + // Workflow refs are not in the plan's reference scan, so the shortcut walks them separately: + // an uncarried ref must still trigger the full collection and emit workflow-missing. + vi.mocked(getBlock).mockReturnValue( + blockWith([{ id: 'target', title: 'Workflow', type: 'workflow-selector' }]) + ) + const { executor } = makeExecutor([[{ id: 'wf-child', name: 'Child Flow' }]]) + const blockers = await collectForkSyncBlockers( + baseParams({ + executor, + sourceStates: new Map([ + [ + 'wf-src', + stateWith('workflow_caller', 'Run Subflow', { + target: { type: 'workflow-selector', value: 'wf-child' }, + }), + ], + ]), + planUnmapped: [], + }) + ) + expect(blockers).toEqual([ + expect.objectContaining({ + kind: 'workflow', + sourceId: 'wf-child', + reason: 'workflow-missing', + }), + ]) + }) + + it('blocks on an uncarried workspace-event trigger workflowIds entry through the reused-plan path', async () => { + // Trigger workflow filters are not in the plan's reference scan, so the shortcut's light + // workflow-ref walk must detect them and trigger the full collection. + vi.mocked(getBlock).mockReturnValue( + blockWith([{ id: 'workflowIds', title: 'Workflows', type: 'dropdown', multiSelect: true }]) + ) + const { executor } = makeExecutor([[{ id: 'wf-watched', name: 'Watched Workflow' }]]) + const blockers = await collectForkSyncBlockers( + baseParams({ + executor, + sourceStates: new Map([ + [ + 'wf-src', + stateWith('sim_workspace_event', 'Workspace Events', { + workflowIds: { type: 'dropdown', value: ['wf-watched'] }, + }), + ], + ]), + planUnmapped: [], + }) + ) + expect(blockers).toEqual([ + expect.objectContaining({ + kind: 'workflow', + sourceId: 'wf-watched', + sourceLabel: 'Watched Workflow', + reason: 'workflow-missing', + }), + ]) + }) + + it('never blocks on a dormant workflowSelector array (advanced manual mode active)', async () => { + vi.mocked(getBlock).mockReturnValue( + blockWith([ + { + id: 'workflowSelector', + title: 'Workflows', + type: 'dropdown', + canonicalParamId: 'workflowIds', + mode: 'basic', + }, + { + id: 'manualWorkflowIds', + title: 'Workflow IDs', + type: 'short-input', + canonicalParamId: 'workflowIds', + mode: 'advanced', + }, + ]) + ) + const { executor, select } = makeExecutor() + const state = { + blocks: { + 'block-1': { + id: 'block-1', + type: 'logs', + name: 'Logs', + data: { canonicalModes: { workflowIds: 'advanced' } }, + subBlocks: { + workflowSelector: { type: 'dropdown', value: ['wf-stale'] }, + manualWorkflowIds: { type: 'short-input', value: 'wf-manual' }, + }, + }, + }, + edges: [], + loops: {}, + parallels: {}, + variables: {}, + } as unknown as WorkflowState + const blockers = await collectForkSyncBlockers( + baseParams({ + executor, + sourceStates: new Map([['wf-src', state]]), + planUnmapped: [], + }) + ) + expect(blockers).toEqual([]) + expect(select).not.toHaveBeenCalled() + }) + + it('never blocks on dependent-cause entries (create-target dependents stay informational)', async () => { + vi.mocked(getBlock).mockReturnValue( + blockWith([ + { id: 'credential', title: 'Credential', type: 'oauth-input' }, + { + id: 'folder', + title: 'Label', + type: 'folder-selector', + dependsOn: ['credential'], + selectorKey: 'gmail.labels', + }, + ]) + ) + const { executor, select } = makeExecutor() + const blockers = await collectForkSyncBlockers( + baseParams({ + executor, + items: [{ ...replaceItem, mode: 'create' as const }], + sourceStates: new Map([ + [ + 'wf-src', + stateWith('gmail', 'Gmail', { + credential: { value: 'cred-src' }, + folder: { value: 'INBOX' }, + }), + ], + ]), + }) + ) + expect(blockers).toEqual([]) + expect(select).not.toHaveBeenCalled() + expect(mockFilterExisting).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/workspaces/fork/promote/cleared-refs.ts b/apps/sim/lib/workspaces/fork/promote/cleared-refs.ts index e6922731275..cb31fef852b 100644 --- a/apps/sim/lib/workspaces/fork/promote/cleared-refs.ts +++ b/apps/sim/lib/workspaces/fork/promote/cleared-refs.ts @@ -1,4 +1,11 @@ -import type { ForkClearedRef } from '@/lib/api/contracts/workspace-fork' +import { mcpServers, workflow } from '@sim/db/schema' +import { and, eq, inArray } from 'drizzle-orm' +import type { + ForkClearedRef, + ForkCopyableKind, + ForkSyncBlocker, +} from '@/lib/api/contracts/workspace-fork' +import type { DbOrTx } from '@/lib/db/types' import { coerceObjectArray, isRecord, @@ -12,8 +19,18 @@ import { resolveCanonicalMode, } from '@/lib/workflows/subblocks/visibility' import { collectForkDependentReconfigs } from '@/lib/workspaces/fork/mapping/dependent-reconfigs' +import { + filterExistingForkTargets, + loadForkCopyableResourceLabels, +} from '@/lib/workspaces/fork/mapping/resources' +import { isForkCopyableKind } from '@/lib/workspaces/fork/promote/promote-plan' +import { + selectForkSyncBlockingRefs, + toForkSyncBlockers, +} from '@/lib/workspaces/fork/promote/sync-blockers' import type { ForkBlockIdResolver } from '@/lib/workspaces/fork/remap/block-identity' import { + type ForkReference, type ForkReferenceResolver, type ForkRemapKind, REQUIRED_KINDS, @@ -24,10 +41,12 @@ import type { WorkflowState } from '@/stores/workflows/workflow/types' /** * Remappable kinds excluded from the `reference` cleared-ref list. REQUIRED kinds (credential, - * env-var) are BLOCKERS - they gate Sync and are resolved by mapping, never silently cleared - so - * they must not read as "will be cleared" (a credential is also preserved by name once mapped, an - * env-var always). `knowledge-document` follows its parent KB - a document under an unmapped KB is - * implied by the KB's own cleared-ref entry, and under a mapped/copied KB it is auto-copied. + * env-var) gate Sync through the kind-level required gate with their own messaging, so they must + * not double-report here (a credential is also preserved by name once mapped, an env-var always). + * `knowledge-document` follows its parent KB - a document under an unmapped KB is implied by the + * KB's own cleared-ref entry, and under a mapped/copied KB it is auto-copied. Every other kind's + * entry IS a sync blocker (cause `reference`/`workflow`): a sync proceeds only when zero + * references would clear. */ const CLEARED_REF_EXCLUDED_KINDS = new Set([...REQUIRED_KINDS, 'knowledge-document']) @@ -59,10 +78,14 @@ function baseSubBlockId(key: string): string { } /** - * Cross-workflow references (`workflow-selector`, advanced `manualWorkflowId(s)`, multi-select - * `workflowSelector`, nested `workflow_input` tools) in a block's subBlocks. Mirrors the detection - * in {@link remapWorkflowReferencesInSubBlocks} so the cleared-ref list flags exactly the refs that - * remap would clear. Returns one entry per referenced workflow id with its owning subblock key. + * Cross-workflow references (`workflow-selector`, multi-select `workflowSelector`, the + * workspace-event trigger's multi-select `workflowIds` dropdown, nested `workflow_input` tools) + * in a block's subBlocks. Mirrors the detection in + * {@link remapWorkflowReferencesInSubBlocks} so the cleared-ref list flags exactly the refs that + * remap would clear - the free-form manual fields (`manualWorkflowId`, `manualWorkflowIds`) are + * user-owned and never remapped/cleared, so they are intentionally excluded (the `workflowIds` + * branch is gated on TYPE `dropdown` because the legacy logs block's `workflowIds` is a manual + * `short-input`). Returns one entry per referenced workflow id with its owning subblock key. */ function collectForkWorkflowReferences( subBlocks: SubBlockRecord, @@ -70,29 +93,42 @@ function collectForkWorkflowReferences( canonicalModes: CanonicalModeOverrides | undefined ): Array<{ workflowId: string; subBlockKey: string }> { const out: Array<{ workflowId: string; subBlockKey: string }> = [] - // Collapse the `workflowId` canonical pair (basic `workflow-selector` + advanced `manualWorkflowId`) - // to its ACTIVE member: only the active mode is serialized, so a dormant stale member is not a ref - // that would be cleared (mirrors remap-internal-ids.ts). Undefined mode -> emit both (legacy/no-pair). - const workflowGroup = config - ? buildCanonicalIndex(config.subBlocks).groupsById.workflowId - : undefined - const workflowMode = - workflowGroup && isCanonicalPair(workflowGroup) - ? resolveCanonicalMode(workflowGroup, buildSubBlockValues(subBlocks), canonicalModes) - : undefined + // Collapse each canonical pair to its ACTIVE member: only the selector members are + // remapped/cleared (the advanced `manualWorkflowId`/`manualWorkflowIds` are user-owned and + // preserved verbatim), so a DORMANT member's stale value is not a ref that would be cleared - + // it must not become an unresolvable sync blocker. Mirrors `isDormantCanonicalMember` in + // remap-references.ts: the lookup is per subblock key, so the scalar `workflowId` pair, the + // deployments block's scalar `workflowSelector` pair, and the logs block's multi-select + // `workflowSelector` (`workflowIds` group) all resolve through their OWN group. A missing + // config or a non-pair member is never skipped (legacy/no-pair states keep emitting). + const canonicalIndex = config ? buildCanonicalIndex(config.subBlocks) : undefined + const values = canonicalIndex ? buildSubBlockValues(subBlocks) : {} + const isDormantCanonicalMember = (key: string): boolean => { + if (!canonicalIndex) return false + const baseKey = baseSubBlockId(key) + const canonicalId = canonicalIndex.canonicalIdBySubBlockId[baseKey] + const group = canonicalId ? canonicalIndex.groupsById[canonicalId] : undefined + if (!group || !isCanonicalPair(group)) return false + const activeMode = resolveCanonicalMode(group, values, canonicalModes) + return (activeMode === 'advanced') !== group.advancedIds.includes(baseKey) + } for (const [key, subBlock] of Object.entries(subBlocks)) { if (!subBlock || typeof subBlock !== 'object') continue const baseKey = baseSubBlockId(key) if ( - (subBlock.type === 'workflow-selector' || baseKey === 'manualWorkflowId') && + subBlock.type === 'workflow-selector' && typeof subBlock.value === 'string' && subBlock.value ) { - // Skip the dormant member of the pair (the active mode owns the reference). - const isAdvancedMember = baseKey === 'manualWorkflowId' - if (workflowMode && (workflowMode === 'advanced') !== isAdvancedMember) continue + // Only the SELECTOR is remapped/cleared; the manual member is user-owned and preserved + // verbatim, so skip the dormant selector when advanced/manual mode is active. + if (isDormantCanonicalMember(key)) continue out.push({ workflowId: subBlock.value, subBlockKey: key }) - } else if (baseKey === 'manualWorkflowIds' || baseKey === 'workflowSelector') { + } else if ( + baseKey === 'workflowSelector' || + (subBlock.type === 'dropdown' && baseKey === 'workflowIds') + ) { + if (isDormantCanonicalMember(key)) continue const ids = Array.isArray(subBlock.value) ? subBlock.value : typeof subBlock.value === 'string' @@ -158,10 +194,16 @@ export function collectForkClearedRefCandidates( config?.subBlocks.find((cfg) => cfg.id === baseSubBlockId(subBlockKey))?.title ?? subBlockKey - // Cause `reference`: unmapped remappable resource refs (per block/field). + // Cause `reference`: unmapped remappable resource refs (per block/field). `blockType` + + // `canonicalModes` gate detection to the ACTIVE canonical member, matching the plan's + // reference scan - a dormant member's stale value is not a real reference, so it must not + // become a blocker with no mapping entry to resolve it. `sourceDeleted` starts false; the + // caller annotates it via {@link annotateForkClearedRefSourceLiveness} (DB check). const scan = remapForkSubBlocks(subBlocks, resolver, 'promote', { blockId: targetBlockId, blockName: blockLabel, + blockType: block.type, + canonicalModes: block.data?.canonicalModes, }) for (const ref of scan.unmapped) { if (CLEARED_REF_EXCLUDED_KINDS.has(ref.kind)) continue @@ -175,6 +217,7 @@ export function collectForkClearedRefCandidates( sourceId: ref.sourceId, sourceLabel: labelFor(ref.kind, ref.sourceId), cause: 'reference', + sourceDeleted: false, }) } @@ -231,3 +274,169 @@ export function collectForkClearedRefCandidates( return out } + +/** + * Fill each `reference`-cause entry's `sourceDeleted` flag by checking whether its resource still + * exists (not deleted/archived) in the SOURCE workspace. Reuses {@link filterExistingForkTargets} + * - a per-kind, exact-id (cap-free) liveness check with the canonical archived/deleted filters - + * pointed at the source workspace instead of a target. One batched round per kind present; a + * no-op (zero queries) when no reference-cause entries exist. Files check by storage key, matching + * how `file` references are recorded. + */ +export async function annotateForkClearedRefSourceLiveness( + executor: DbOrTx, + sourceWorkspaceId: string, + clearedRefs: ForkClearedRef[] +): Promise { + const idsByKind: Partial>> = {} + for (const ref of clearedRefs) { + if (ref.cause !== 'reference') continue + ;(idsByKind[ref.kind] ??= new Set()).add(ref.sourceId) + } + if (Object.keys(idsByKind).length === 0) return clearedRefs + const liveByKind = await filterExistingForkTargets(executor, sourceWorkspaceId, idsByKind) + return clearedRefs.map((ref) => + ref.cause === 'reference' + ? { ...ref, sourceDeleted: !(liveByKind[ref.kind]?.has(ref.sourceId) ?? false) } + : ref + ) +} + +/** Upper bound on the blockers a gate failure reports, so the error body stays sane. */ +const FORK_SYNC_BLOCKER_LIMIT = 100 + +/** + * Cheap existence check for blocking gate candidates, reusing the plan's already-computed scan + * output instead of re-running the full per-block reference scan: + * - `reference` cause: the collector detects references with the same per-block scan + * ({@link remapForkSubBlocks}) over the same source states the plan already ran, so a + * candidate exists iff some plan-unmapped reference of a non-excluded kind still resolves to + * null through the gate resolver. The gate resolver only ADDS resolutions on top of the plan + * resolver (promote's copy-selection overlay), so filtering the plan's unmapped set through it + * yields exactly the gate's unmapped set. The plan's cascade-only additions (env-var / + * credential) are excluded kinds and never contribute. + * - `workflow` cause: cross-workflow refs are not part of the plan's scan, so walk the blocks + * with the (much lighter) workflow-reference detection only, against the same workflowIdMap + * predicate the collector applies. + * `dependent`-cause candidates never block (see {@link forkSyncBlockerReasonFor}), so they are + * not checked. + */ +function hasForkSyncBlockerCandidates( + planUnmapped: ReadonlyArray>, + params: Pick< + CollectForkClearedRefsParams, + 'items' | 'sourceStates' | 'resolver' | 'workflowIdMap' + > +): boolean { + const { items, sourceStates, resolver, workflowIdMap } = params + const hasReferenceCandidate = planUnmapped.some( + (reference) => + !CLEARED_REF_EXCLUDED_KINDS.has(reference.kind) && + resolver(reference.kind, reference.sourceId) == null + ) + if (hasReferenceCandidate) return true + for (const item of items) { + const state = sourceStates.get(item.sourceWorkflowId) + if (!state) continue + for (const block of Object.values(state.blocks)) { + // double-cast-allowed: a WorkflowState block's SubBlockState entries are structurally + // SubBlockRecord entries but lack the open index signature SubBlockRecord declares + const subBlocks = (block.subBlocks ?? {}) as unknown as SubBlockRecord + const workflowRefs = collectForkWorkflowReferences( + subBlocks, + getBlock(block.type), + block.data?.canonicalModes + ) + if (workflowRefs.some((ref) => !workflowIdMap.has(ref.workflowId))) return true + } + } + return false +} + +/** + * The authoritative would-clear gate input for a promote: collect the cleared-ref candidates for + * the sync (against the caller's resolver, which must already account for the copy selection), + * keep the blocking causes (`reference` / `workflow` - dependents stay with the reconfigure + * flow), annotate source liveness, and return them as wire {@link ForkSyncBlocker}s with + * best-effort labels. The happy path (nothing would clear) costs ZERO queries - the collection is + * pure over the pre-read source states - and, when `planUnmapped` is supplied, ZERO re-scans of + * the blocks the plan already scanned; liveness + label reads (and the full candidate collection, + * for identical per-block/field blocker rows) run only when something blocks. Truncated to + * {@link FORK_SYNC_BLOCKER_LIMIT} entries. + */ +export async function collectForkSyncBlockers( + params: Omit & { + executor: DbOrTx + sourceWorkspaceId: string + /** + * The plan's unmapped references (`unmappedRequired` + `unmappedOptional`), when the caller + * computed the plan over the SAME `items`/`sourceStates` inside the same transaction AND the + * gate `resolver` only augments the plan's resolver (never un-resolves a plan-mapped ref) - + * promote's copy-selection overlay satisfies both. Enables the happy-path shortcut via + * {@link hasForkSyncBlockerCandidates}: the full per-block reference scan the plan already + * ran is skipped when no blocking candidate can exist, and re-run (for byte-identical blocker + * rows) when one does. Omit to always collect from scratch. + */ + planUnmapped?: ReadonlyArray> + } +): Promise { + const { executor, sourceWorkspaceId, planUnmapped, ...collectParams } = params + if (planUnmapped && !hasForkSyncBlockerCandidates(planUnmapped, collectParams)) return [] + const candidates = collectForkClearedRefCandidates({ + ...collectParams, + sourceLabels: new Map(), + sourceWorkflowNames: new Map(), + }) + if (!candidates.some((ref) => ref.cause === 'reference' || ref.cause === 'workflow')) return [] + + const annotated = await annotateForkClearedRefSourceLiveness( + executor, + sourceWorkspaceId, + candidates + ) + const blocking = selectForkSyncBlockingRefs(annotated).slice(0, FORK_SYNC_BLOCKER_LIMIT) + if (blocking.length === 0) return [] + + // Best-effort display labels (failure path only). Copyable kinds go through the shared label + // loader (live rows only - a deleted source keeps its id label); MCP servers are read without + // the deleted filter so a source-deleted server still names itself; workflow names label the + // `workflow`-cause entries. + const copyableIdsByKind: Partial> = {} + const mcpIds: string[] = [] + const workflowIds: string[] = [] + for (const { ref } of blocking) { + if (ref.cause === 'workflow') workflowIds.push(ref.sourceId) + else if (ref.kind === 'mcp-server') mcpIds.push(ref.sourceId) + else if (isForkCopyableKind(ref.kind)) (copyableIdsByKind[ref.kind] ??= []).push(ref.sourceId) + } + const [copyableLabels, mcpRows, workflowRows] = await Promise.all([ + loadForkCopyableResourceLabels(executor, sourceWorkspaceId, copyableIdsByKind), + mcpIds.length === 0 + ? Promise.resolve([] as Array<{ id: string; name: string }>) + : executor + .select({ id: mcpServers.id, name: mcpServers.name }) + .from(mcpServers) + .where( + and(eq(mcpServers.workspaceId, sourceWorkspaceId), inArray(mcpServers.id, mcpIds)) + ), + workflowIds.length === 0 + ? Promise.resolve([] as Array<{ id: string; name: string }>) + : executor + .select({ id: workflow.id, name: workflow.name }) + .from(workflow) + .where( + and(eq(workflow.workspaceId, sourceWorkspaceId), inArray(workflow.id, workflowIds)) + ), + ]) + const mcpNames = new Map(mcpRows.map((row) => [row.id, row.name])) + const workflowNames = new Map(workflowRows.map((row) => [row.id, row.name])) + const labelFor = (ref: ForkClearedRef): string => { + if (ref.cause === 'workflow') return workflowNames.get(ref.sourceId) ?? ref.sourceLabel + if (ref.kind === 'mcp-server') return mcpNames.get(ref.sourceId) ?? ref.sourceLabel + return copyableLabels.get(`${ref.kind}:${ref.sourceId}`)?.label ?? ref.sourceLabel + } + + return toForkSyncBlockers( + blocking.map(({ ref, reason }) => ({ ref: { ...ref, sourceLabel: labelFor(ref) }, reason })) + ) +} diff --git a/apps/sim/lib/workspaces/fork/promote/copy-unmapped.test.ts b/apps/sim/lib/workspaces/fork/promote/copy-unmapped.test.ts index 91e0e74341c..4737665694f 100644 --- a/apps/sim/lib/workspaces/fork/promote/copy-unmapped.test.ts +++ b/apps/sim/lib/workspaces/fork/promote/copy-unmapped.test.ts @@ -53,16 +53,55 @@ import { isForkCopyableKind } from '@/lib/workspaces/fork/promote/promote-plan' import type { ForkRemapKind } from '@/lib/workspaces/fork/remap/remap-references' const candidates: ForkCopyableUnmapped[] = [ - { kind: 'knowledge-base', sourceId: 'kb-1', label: 'KB One', parentId: null, parentLabel: null }, - { kind: 'table', sourceId: 'tbl-1', label: 'Table One', parentId: null, parentLabel: null }, - { kind: 'custom-tool', sourceId: 'ct-1', label: 'Tool One', parentId: null, parentLabel: null }, - { kind: 'skill', sourceId: 'sk-1', label: 'Skill One', parentId: null, parentLabel: null }, + { + kind: 'knowledge-base', + sourceId: 'kb-1', + label: 'KB One', + parentId: null, + parentLabel: null, + referenced: true, + }, + { + kind: 'table', + sourceId: 'tbl-1', + label: 'Table One', + parentId: null, + parentLabel: null, + referenced: true, + }, + { + kind: 'custom-tool', + sourceId: 'ct-1', + label: 'Tool One', + parentId: null, + parentLabel: null, + referenced: true, + }, + { + kind: 'skill', + sourceId: 'sk-1', + label: 'Skill One', + parentId: null, + parentLabel: null, + referenced: true, + }, { kind: 'file', sourceId: 'workspace/SRC/a.png', label: 'a.png', parentId: 'fld-1', parentLabel: 'Images', + referenced: true, + }, + // An UNREFERENCED candidate (new in the source, used by no synced workflow): selectable for + // copy exactly like a referenced one - the server treats the two identically. + { + kind: 'table', + sourceId: 'tbl-unref', + label: 'Scratch table', + parentId: null, + parentLabel: null, + referenced: false, }, ] @@ -76,7 +115,6 @@ describe('buildPromoteCopySelection', () => { expect(selection.tables).toEqual(['tbl-1']) expect(selection.customTools).toEqual(['ct-1']) expect(selection.skills).toEqual(['sk-1']) - expect(selection.workflowMcpServers).toEqual([]) expect(willResolve.has('knowledge-base:kb-1')).toBe(true) expect(willResolve.has('skill:sk-1')).toBe(true) }) @@ -106,12 +144,31 @@ describe('buildPromoteCopySelection', () => { expect(willResolve.size).toBe(0) }) + it('accepts an UNREFERENCED candidate exactly like a referenced one', () => { + // The client keeps unreferenced candidates default-unselected, but once the user opts in the + // server validates + copies them through the same path. Its willResolve key matches no + // unmapped reference (nothing references it), so the pre-copy gate is unaffected. + const { selection, willResolve } = buildPromoteCopySelection( + { tables: ['tbl-unref'] }, + candidates + ) + expect(selection.tables).toEqual(['tbl-unref']) + expect(willResolve.has('table:tbl-unref')).toBe(true) + }) + it('copy-vs-map: maps win - a mapped resource is absent from the candidates, so a copy request for it is dropped', () => { // Reconciliation precedence at the server boundary: a resource the user mapped resolves to a // target, so the plan never lists it in `copyableUnmapped`. Even if a (stale) client still // requests it for copy, only the genuinely-unmapped candidates survive - the map wins. const onlyTableUnmapped: ForkCopyableUnmapped[] = [ - { kind: 'table', sourceId: 'tbl-1', label: 'Table One', parentId: null, parentLabel: null }, + { + kind: 'table', + sourceId: 'tbl-1', + label: 'Table One', + parentId: null, + parentLabel: null, + referenced: true, + }, ] const { selection, willResolve } = buildPromoteCopySelection( // kb-1 + the file were mapped (so absent from candidates); only the table remains copyable. @@ -137,7 +194,6 @@ describe('hasPromoteCopySelection', () => { hasPromoteCopySelection({ customTools: [], skills: [], - workflowMcpServers: [], tables: [], knowledgeBases: ['kb-1'], files: [], @@ -147,7 +203,6 @@ describe('hasPromoteCopySelection', () => { hasPromoteCopySelection({ customTools: [], skills: [], - workflowMcpServers: [], tables: [], knowledgeBases: [], files: [], @@ -157,7 +212,6 @@ describe('hasPromoteCopySelection', () => { hasPromoteCopySelection({ customTools: [], skills: [], - workflowMcpServers: [], tables: [], knowledgeBases: [], files: ['workspace/SRC/file.png'], @@ -229,6 +283,9 @@ describe('copyPromoteUnmappedResources - files + folder content-refs', () => { const tx = {} as DbOrTx // Only edge.childWorkspaceId is read by the copy path. const edge = { childWorkspaceId: 'edge-child' } as unknown as ForkEdge + // The promote-built persisted-pair resolver; the copy must forward it verbatim so copied + // tables' workflow-group outputs land on the same block ids the workflow writes assign. + const resolveBlockId = (workflowId: string, blockId: string) => `${workflowId}:${blockId}` beforeEach(() => { vi.clearAllMocks() @@ -287,7 +344,6 @@ describe('copyPromoteUnmappedResources - files + folder content-refs', () => { selection: { customTools: [], skills: [], - workflowMcpServers: [], tables: [], knowledgeBases: [], files: ['workspace/SRC/a.png'], @@ -295,6 +351,7 @@ describe('copyPromoteUnmappedResources - files + folder content-refs', () => { workflowIdMap: new Map(), folderIdMap: new Map([['fld-src', 'fld-dst']]), resolver: () => null, + resolveBlockId, referencedDocumentIds: [], }) @@ -323,6 +380,64 @@ describe('copyPromoteUnmappedResources - files + folder content-refs', () => { expect(result.contentRefMaps.fileIds).toEqual({ 'file-src': 'file-dst' }) }) + it('persists container mapping entries for copied resources (idempotency for unreferenced copies)', async () => { + // An UNREFERENCED table selected for copy flows through the same container pipeline; its + // mapping row is what makes the next sync resolve the copy instead of re-offering it. + mockCopyForkResourceContainers.mockResolvedValue({ + idMap: new Map([['table', new Map([['tbl-unref', 'tbl-copy']])]]), + mappingEntries: [ + { resourceType: 'table', parentResourceId: 'tbl-unref', childResourceId: 'tbl-copy' }, + ], + contentPlan: { + sourceWorkspaceId: 'src-ws', + childWorkspaceId: 'target-ws', + userId: 'user-1', + tables: [{ sourceId: 'tbl-unref', childId: 'tbl-copy' }], + knowledgeBases: [], + skills: [], + documents: [], + }, + names: { + tables: ['Scratch table'], + knowledgeBases: [], + customTools: [], + skills: [], + workflowMcpServers: [], + }, + }) + mockPlanForkFileCopies.mockResolvedValue({ + keyMap: new Map(), + idMap: new Map(), + blobTasks: [], + }) + + await copyPromoteUnmappedResources({ + tx, + edge, + sourceWorkspaceId: 'src-ws', + targetWorkspaceId: 'target-ws', + direction: 'pull', + userId: 'user-1', + now: new Date(), + selection: { + customTools: [], + skills: [], + tables: ['tbl-unref'], + knowledgeBases: [], + files: [], + }, + workflowIdMap: new Map(), + folderIdMap: new Map(), + resolver: () => null, + resolveBlockId, + referencedDocumentIds: [], + }) + + expect(mockUpsertEdgeMappings).toHaveBeenCalledWith(tx, 'edge-child', 'user-1', [ + { resourceType: 'table', parentResourceId: 'tbl-unref', childResourceId: 'tbl-copy' }, + ]) + }) + it('threads the plan-provided referencedDocumentIds into both doc-copy paths (no in-tx re-scan)', async () => { await copyPromoteUnmappedResources({ tx, @@ -335,7 +450,6 @@ describe('copyPromoteUnmappedResources - files + folder content-refs', () => { selection: { customTools: [], skills: [], - workflowMcpServers: [], tables: [], knowledgeBases: ['kb-1'], files: [], @@ -343,13 +457,22 @@ describe('copyPromoteUnmappedResources - files + folder content-refs', () => { workflowIdMap: new Map(), folderIdMap: new Map(), resolver: () => null, + resolveBlockId, // The doc ids come straight from the promote plan's references; the copy must forward them, // not re-scan every source workflow state inside the locked tx. referencedDocumentIds: ['doc-1', 'doc-2'], }) expect(mockCopyForkResourceContainers).toHaveBeenCalledWith( - expect.objectContaining({ referencedDocumentIds: ['doc-1', 'doc-2'] }) + expect.objectContaining({ + referencedDocumentIds: ['doc-1', 'doc-2'], + // Workflow-publishing MCP servers are fork-create-only; a sync always passes the + // shared pipeline's slot empty (PromoteCopySelection has no such field). + selection: expect.objectContaining({ workflowMcpServers: [] }), + // The promote-built block-id resolver reaches the table remap unchanged, so copied + // tables' workflow-group outputs use the persisted-pair ids, not the derive. + resolveBlockId, + }) ) expect(mockPlanForkMappedKbDocumentCopies).toHaveBeenCalledWith( expect.objectContaining({ referencedDocumentIds: ['doc-1', 'doc-2'] }) diff --git a/apps/sim/lib/workspaces/fork/promote/copy-unmapped.ts b/apps/sim/lib/workspaces/fork/promote/copy-unmapped.ts index f0080b1d46c..cf1d33d56c8 100644 --- a/apps/sim/lib/workspaces/fork/promote/copy-unmapped.ts +++ b/apps/sim/lib/workspaces/fork/promote/copy-unmapped.ts @@ -21,16 +21,20 @@ import { resourceTypeToForkKind, upsertEdgeMappings, } from '@/lib/workspaces/fork/mapping/mapping-store' +import type { ForkBlockIdResolver } from '@/lib/workspaces/fork/remap/block-identity' import type { ForkReferenceResolver, ForkRemapKind, } from '@/lib/workspaces/fork/remap/remap-references' -/** The source ids selected for copy at promote, validated against the plan's copyable candidates. */ +/** + * The source ids selected for copy at promote, validated against the plan's copyable + * candidates. Exactly the sync-copyable kinds (`forkCopyableKindSchema`): workflow-publishing + * MCP servers are fork-create-only (never a promote copy candidate), so they have no slot here. + */ export interface PromoteCopySelection { customTools: string[] skills: string[] - workflowMcpServers: string[] tables: string[] knowledgeBases: string[] /** Workspace files to copy, identified by storage key (not `workspace_files.id`). */ @@ -54,10 +58,11 @@ export const FORK_COPYABLE_KIND_TO_SELECTION_KEY: Record< } /** - * Intersect the user's requested copy with the plan's actual copyable candidates, so a sync can - * only copy resources that are genuinely referenced-but-unmapped + still exist in the source (a - * crafted request can never copy an arbitrary resource). Returns the validated selection plus the - * set of `${kind}:${sourceId}` references the copy will resolve, for the pre-copy sync gate. + * Intersect the user's requested copy with the plan's actual copyable candidates (referenced or + * not, always unmapped + still existing in the source), so a crafted request can never copy an + * arbitrary resource. Returns the validated selection plus the set of `${kind}:${sourceId}` + * references the copy will resolve, for the pre-copy sync gate - an unreferenced candidate's key + * simply matches no reference there, which is harmless. */ export function buildPromoteCopySelection( requested: PromoteCopyResources | undefined, @@ -72,7 +77,6 @@ export function buildPromoteCopySelection( const selection: PromoteCopySelection = { customTools: [], skills: [], - workflowMcpServers: [], tables: [], knowledgeBases: [], files: [], @@ -140,8 +144,8 @@ export interface PromoteCopyResult { } /** - * Copy the referenced-but-unmapped resources a sync brings into the target (reusing the fork copy - * pipeline), then persist the source<->target id map in the direction the edge expects: a pull + * Copy the selected unmapped resources (referenced or not) a sync brings into the target (reusing + * the fork copy pipeline), then persist the source<->target id map in the direction the edge expects: a pull * fills the existing `(parent, child=null)` row (fill-null), a push replaces any prior * `(parent, child)` row keyed on the source child resource (delete-then-insert). This covers: * - the user-selected copyable containers (KB / table / custom-tool / skill) and workspace files, @@ -168,6 +172,12 @@ export async function copyPromoteUnmappedResources(params: { folderIdMap: Map /** Base resolver (persisted mappings + env identity), used to detect already-mapped KBs (U-docs). */ resolver: ForkReferenceResolver + /** + * The SAME block-id resolver the sync's workflow writes use (persisted pairs preferred over + * derive), so copied tables' workflow-group `outputs[].blockId` point at the blocks the sync + * actually writes - on push the parent keeps its ORIGINAL block ids, never the derive. + */ + resolveBlockId: ForkBlockIdResolver /** * Knowledge-document ids the synced workflows reference, already scanned once in the promote * plan and threaded in so the copy doesn't re-scan every source state inside the locked tx. @@ -188,6 +198,7 @@ export async function copyPromoteUnmappedResources(params: { workflowIdMap, folderIdMap, resolver, + resolveBlockId, referencedDocumentIds, } = params @@ -200,7 +211,9 @@ export async function copyPromoteUnmappedResources(params: { selection: { customTools: selection.customTools, skills: selection.skills, - workflowMcpServers: selection.workflowMcpServers, + // Workflow-publishing MCP servers are fork-create-only (never a sync-copy candidate); + // the shared copy pipeline still takes the slot, so pass it empty. + workflowMcpServers: [], tables: selection.tables, knowledgeBases: selection.knowledgeBases, }, @@ -209,6 +222,7 @@ export async function copyPromoteUnmappedResources(params: { // A sync can rename env vars, so a copied custom tool's `code` must have its `{{ENV}}` refs // rewritten through the same plan resolver that remaps subblock-value env refs. resolveEnvName: (key) => resolver('env-var', key), + resolveBlockId, }) // Copy the selected workspace files (keyed by storage key) - metadata inserts in the tx, blob diff --git a/apps/sim/lib/workspaces/fork/promote/promote-plan.test.ts b/apps/sim/lib/workspaces/fork/promote/promote-plan.test.ts index bf3d07b1791..f01e4d44a35 100644 --- a/apps/sim/lib/workspaces/fork/promote/promote-plan.test.ts +++ b/apps/sim/lib/workspaces/fork/promote/promote-plan.test.ts @@ -2,11 +2,15 @@ * @vitest-environment node */ import { describe, expect, it } from 'vitest' -import type { ForkCopyableLabel } from '@/lib/workspaces/fork/mapping/resources' +import type { + ForkCopyableLabel, + ForkCopyableSourceResource, +} from '@/lib/workspaces/fork/mapping/resources' import { assembleForkCopyableUnmapped, buildPromoteWorkflowIdMap, collectForkCopyableIdsByKind, + collectForkUnreferencedCopyables, } from '@/lib/workspaces/fork/promote/promote-plan' import type { ForkReference } from '@/lib/workspaces/fork/remap/remap-references' @@ -135,8 +139,16 @@ describe('assembleForkCopyableUnmapped', () => { label: 'Docs KB', parentId: null, parentLabel: null, + referenced: true, + }, + { + kind: 'file', + sourceId: 'fk-1', + label: 'a.png', + parentId: 'fld-1', + parentLabel: 'Folder', + referenced: true, }, - { kind: 'file', sourceId: 'fk-1', label: 'a.png', parentId: 'fld-1', parentLabel: 'Folder' }, ]) }) @@ -152,3 +164,93 @@ describe('assembleForkCopyableUnmapped', () => { expect(result).toEqual([]) }) }) + +describe('collectForkUnreferencedCopyables', () => { + const source = ( + kind: ForkCopyableSourceResource['kind'], + sourceId: string, + label = sourceId + ): ForkCopyableSourceResource => ({ kind, sourceId, label, parentId: null, parentLabel: null }) + + const referencedCandidate = (kind: ForkCopyableSourceResource['kind'], sourceId: string) => ({ + kind, + sourceId, + label: sourceId, + parentId: null, + parentLabel: null, + referenced: true, + }) + + it('emits an unmapped source resource no synced workflow references, flagged referenced: false', () => { + const result = collectForkUnreferencedCopyables( + [source('table', 'tbl-new', 'Scratch table')], + [], + () => null + ) + expect(result).toEqual([ + { + kind: 'table', + sourceId: 'tbl-new', + label: 'Scratch table', + parentId: null, + parentLabel: null, + referenced: false, + }, + ]) + }) + + it('dedupes against the referenced candidate set (a referenced resource is never double-listed)', () => { + const result = collectForkUnreferencedCopyables( + [source('knowledge-base', 'kb-1'), source('knowledge-base', 'kb-new')], + [referencedCandidate('knowledge-base', 'kb-1')], + () => null + ) + expect(result.map((candidate) => candidate.sourceId)).toEqual(['kb-new']) + }) + + it('excludes a resource with a persisted mapping (idempotency: a prior copy is never re-offered)', () => { + // A resource copied by a prior sync resolves through its workspace_fork_resource_map row. + const result = collectForkUnreferencedCopyables( + [source('skill', 'sk-copied'), source('skill', 'sk-new')], + [], + (kind, sourceId) => (kind === 'skill' && sourceId === 'sk-copied' ? 'sk-target' : null) + ) + expect(result.map((candidate) => candidate.sourceId)).toEqual(['sk-new']) + }) + + it('does not confuse the same id across kinds when deduping or resolving', () => { + const result = collectForkUnreferencedCopyables( + [source('table', 'shared-id'), source('skill', 'shared-id')], + [referencedCandidate('table', 'shared-id')], + () => null + ) + expect(result).toHaveLength(1) + expect(result[0]).toMatchObject({ kind: 'skill', sourceId: 'shared-id', referenced: false }) + }) + + it('carries a file candidate keyed by storage key with its folder grouping', () => { + const result = collectForkUnreferencedCopyables( + [ + { + kind: 'file', + sourceId: 'workspace/SRC/new.png', + label: 'new.png', + parentId: 'fld-1', + parentLabel: 'Images', + }, + ], + [], + () => null + ) + expect(result).toEqual([ + { + kind: 'file', + sourceId: 'workspace/SRC/new.png', + label: 'new.png', + parentId: 'fld-1', + parentLabel: 'Images', + referenced: false, + }, + ]) + }) +}) diff --git a/apps/sim/lib/workspaces/fork/promote/promote-plan.ts b/apps/sim/lib/workspaces/fork/promote/promote-plan.ts index e4d271b736a..32289c204fd 100644 --- a/apps/sim/lib/workspaces/fork/promote/promote-plan.ts +++ b/apps/sim/lib/workspaces/fork/promote/promote-plan.ts @@ -13,8 +13,10 @@ import { } from '@/lib/workspaces/fork/mapping/mapping-store' import { type ForkCopyableLabel, + type ForkCopyableSourceResource, filterExistingForkTargets, getWorkspaceEnvKeys, + listForkCopyableSourceResources, loadForkCopyableResourceLabels, } from '@/lib/workspaces/fork/mapping/resources' import { toScannerBlocks } from '@/lib/workspaces/fork/remap/reference-scan' @@ -61,10 +63,13 @@ export interface ForkPromotePlan { /** Review-only descriptions of inline secrets that cannot be id-mapped. */ inlineSecretSources: string[] /** - * Referenced-but-unmapped resources of copyable kinds that still exist in the source, so a - * sync can copy them into the target instead of requiring a manual mapping (U15). Documents - * are auto-copied with their parent KB and are not listed here. `parentId`/`parentLabel` carry - * a file's folder grouping (null for non-file kinds and root files), for the nested picker. + * Unmapped resources of copyable kinds that still exist in the source, so a sync can copy + * them into the target instead of requiring a manual mapping (U15). `referenced: true` + * entries are referenced by the synced workflows (default-selected in the modal - skipping + * one clears its references); `referenced: false` entries are used by no synced workflow + * (default-unselected - skipping one breaks nothing). Documents are auto-copied with their + * parent KB and are not listed here. `parentId`/`parentLabel` carry a file's folder grouping + * (null for non-file kinds and root files), for the nested picker. */ copyableUnmapped: Array<{ kind: ForkCopyableKind @@ -72,6 +77,7 @@ export interface ForkPromotePlan { label: string parentId: string | null parentLabel: string | null + referenced: boolean }> willUpdate: number willCreate: number @@ -136,10 +142,10 @@ export function collectForkCopyableIdsByKind( } /** - * Assemble {@link ForkPromotePlan.copyableUnmapped} from the unmapped references and the loaded - * source labels: each copyable reference whose label resolved becomes a copy candidate; one whose - * label is missing (the resource no longer exists in the source) is dropped. Pure - split from the - * DB label load so it is unit-testable. + * Assemble the REFERENCED slice of {@link ForkPromotePlan.copyableUnmapped} from the unmapped + * references and the loaded source labels: each copyable reference whose label resolved becomes a + * copy candidate; one whose label is missing (the resource no longer exists in the source) is + * dropped. Pure - split from the DB label load so it is unit-testable. */ export function assembleForkCopyableUnmapped( unmappedReferences: ForkReference[], @@ -156,12 +162,36 @@ export function assembleForkCopyableUnmapped( label: entry.label, parentId: entry.parentId, parentLabel: entry.parentLabel, + referenced: true, }, ] : [] }) } +/** + * Assemble the UNREFERENCED slice of {@link ForkPromotePlan.copyableUnmapped}: every copyable + * resource in the source workspace that no synced workflow references (not in the referenced + * candidate set) and that has no target mapping for this edge (the resolver returns null). A + * previously-copied resource resolves through its persisted `workspace_fork_resource_map` row, + * so a re-sync never re-offers it (idempotency). Pure - split from the DB source listing so it + * is unit-testable. + */ +export function collectForkUnreferencedCopyables( + sourceResources: ForkCopyableSourceResource[], + referencedCopyables: ForkPromotePlan['copyableUnmapped'], + resolver: ForkReferenceResolver +): ForkPromotePlan['copyableUnmapped'] { + const referencedKeys = new Set( + referencedCopyables.map((candidate) => `${candidate.kind}:${candidate.sourceId}`) + ) + return sourceResources.flatMap((resource) => { + if (referencedKeys.has(`${resource.kind}:${resource.sourceId}`)) return [] + if (resolver(resource.kind, resource.sourceId) != null) return [] + return [{ ...resource, referenced: false }] + }) +} + /** * Compute everything a promote needs without mutating. Only the source's * **deployed** workflows participate; each plan item carries the source's active @@ -330,7 +360,15 @@ export async function computeForkPromotePlan(params: { sourceWorkspaceId, collectForkCopyableIdsByKind(allUnmapped) ) - const copyableUnmapped = assembleForkCopyableUnmapped(allUnmapped, copyableLabels) + const referencedCopyables = assembleForkCopyableUnmapped(allUnmapped, copyableLabels) + // Also offer the source's UNREFERENCED copyable resources with no target mapping (e.g. newly + // created since the fork), default-unselected in the modal. Mapped ones (including everything + // a prior sync copied) resolve non-null and drop out, so a re-sync never re-offers a copy. + const sourceCopyables = await listForkCopyableSourceResources(executor, sourceWorkspaceId) + const copyableUnmapped = [ + ...referencedCopyables, + ...collectForkUnreferencedCopyables(sourceCopyables, referencedCopyables, resolver), + ] const willUpdate = items.filter((i) => i.mode === 'replace').length const willCreate = items.filter((i) => i.mode === 'create').length diff --git a/apps/sim/lib/workspaces/fork/promote/promote.test.ts b/apps/sim/lib/workspaces/fork/promote/promote.test.ts new file mode 100644 index 00000000000..e9b2a4f7d5b --- /dev/null +++ b/apps/sim/lib/workspaces/fork/promote/promote.test.ts @@ -0,0 +1,401 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { ForkSyncBlocker } from '@/lib/api/contracts/workspace-fork' + +const { + mockComputePlan, + mockBuildCopySelection, + mockHasCopySelection, + mockCopyUnmapped, + mockCollectBlockers, + mockLoadBlockMap, + mockBuildBlockIdResolver, + mockResolveFolderMapping, + mockUpsertPromoteRun, + mockLoadSourceDeployedStates, + mockGetUsersWithPermissions, + mockGetMcpServerMeta, + mockCreateTransform, + mockSumForkCopyBytes, + mockAssertForkStorageHeadroom, +} = vi.hoisted(() => ({ + mockComputePlan: vi.fn(), + mockBuildCopySelection: vi.fn(), + mockHasCopySelection: vi.fn(), + mockCopyUnmapped: vi.fn(), + mockCollectBlockers: vi.fn(), + mockLoadBlockMap: vi.fn(), + mockBuildBlockIdResolver: vi.fn(), + mockResolveFolderMapping: vi.fn(), + mockUpsertPromoteRun: vi.fn(), + mockLoadSourceDeployedStates: vi.fn(), + mockGetUsersWithPermissions: vi.fn(), + mockGetMcpServerMeta: vi.fn(), + mockCreateTransform: vi.fn(), + mockSumForkCopyBytes: vi.fn(), + mockAssertForkStorageHeadroom: vi.fn(), +})) + +vi.mock('@/lib/workflows/deployment-outbox', () => ({ + enqueueWorkflowUndeploySideEffects: vi.fn(), + processWorkflowDeploymentOutboxEvent: vi.fn(), +})) +vi.mock('@/lib/workflows/orchestration/deploy', () => ({ + performFullDeploy: vi.fn(async () => ({ success: true })), +})) +vi.mock('@/lib/workflows/persistence/utils', () => ({ + undeployWorkflow: vi.fn(async () => ({ success: true })), +})) +vi.mock('@/lib/workspaces/fork/background-work/store', () => ({ + startBackgroundWork: vi.fn(), +})) +vi.mock('@/lib/workspaces/fork/copy/content-copy-runner', () => ({ + hasForkContentToCopy: vi.fn(() => false), + scheduleForkContentCopy: vi.fn(), +})) +vi.mock('@/lib/workspaces/fork/copy/copy-workflows', () => ({ + copyWorkflowStateIntoTarget: vi.fn(), + loadTargetDraftSubBlocks: vi.fn(async () => new Map()), + loadWorkflowNameRegistry: vi.fn(async () => new Map()), + resolveForkFolderMapping: mockResolveFolderMapping, +})) +vi.mock('@/lib/workspaces/fork/copy/storage-quota', () => ({ + sumForkCopyBytes: mockSumForkCopyBytes, + assertForkStorageHeadroom: mockAssertForkStorageHeadroom, +})) +vi.mock('@/lib/workspaces/fork/copy/deploy-bridge', () => ({ + getActiveDeploymentVersionNumbers: vi.fn(async () => new Map()), + loadSourceDeployedStates: mockLoadSourceDeployedStates, +})) +vi.mock('@/lib/workspaces/fork/lineage/lineage', () => ({ + acquireForkEdgeLock: vi.fn(), + acquireForkTargetLock: vi.fn(), + setForkLockTimeout: vi.fn(), +})) +vi.mock('@/lib/workspaces/fork/mapping/block-map-store', () => ({ + loadForkBlockMap: mockLoadBlockMap, + reconcileForkBlockPairs: vi.fn(), + toForkBlockPairs: vi.fn(() => []), +})) +vi.mock('@/lib/workspaces/fork/mapping/dependent-value-store', () => ({ + loadForkDependentValues: vi.fn(async () => []), + reconcileForkDependentValues: vi.fn(), +})) +vi.mock('@/lib/workspaces/fork/mapping/mapping-store', () => ({ + deleteWorkflowIdentityByIds: vi.fn(), + upsertEdgeMappings: vi.fn(), +})) +vi.mock('@/lib/workspaces/fork/promote/cleared-refs', () => ({ + collectForkSyncBlockers: mockCollectBlockers, +})) +vi.mock('@/lib/workspaces/fork/promote/copy-unmapped', () => ({ + augmentForkResolver: vi.fn((base) => base), + buildPromoteCopySelection: mockBuildCopySelection, + copyPromoteUnmappedResources: mockCopyUnmapped, + hasPromoteCopySelection: mockHasCopySelection, +})) +vi.mock('@/lib/workspaces/fork/promote/promote-plan', () => ({ + computeForkPromotePlan: mockComputePlan, +})) +vi.mock('@/lib/workspaces/fork/promote/promote-run-store', () => ({ + upsertPromoteRun: mockUpsertPromoteRun, +})) +vi.mock('@/lib/workspaces/fork/mapping/resources', () => ({ + getMcpServerMetaByIds: mockGetMcpServerMeta, +})) +vi.mock('@/lib/workspaces/fork/remap/block-identity', () => ({ + buildForkBlockIdResolver: mockBuildBlockIdResolver, +})) +vi.mock('@/lib/workspaces/fork/remap/remap-references', () => ({ + createForkSubBlockTransform: mockCreateTransform, +})) +vi.mock('@/lib/workspaces/fork/socket', () => ({ + notifyForkWorkflowChanged: vi.fn(), +})) +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + getUsersWithPermissions: mockGetUsersWithPermissions, +})) + +import { db } from '@sim/db' +import { promoteFork } from '@/lib/workspaces/fork/promote/promote' +import type { ForkPromotePlan } from '@/lib/workspaces/fork/promote/promote-plan' + +const EDGE = { childWorkspaceId: 'child-ws', parentWorkspaceId: 'parent-ws' } + +const EMPTY_SELECTION = { + customTools: [], + skills: [], + tables: [], + knowledgeBases: [], + files: [], +} + +function makePlan(overrides: Partial = {}): ForkPromotePlan { + return { + childWorkspaceId: EDGE.childWorkspaceId, + sourceWorkspaceId: 'src-ws', + targetWorkspaceId: 'tgt-ws', + direction: 'push', + resolver: () => null, + items: [], + workflowIdMap: new Map(), + archivedTargetIds: [], + archivedTargets: [], + references: [], + unmappedRequired: [], + unmappedOptional: [], + mcpReauthServerIds: [], + inlineSecretSources: [], + copyableUnmapped: [], + willUpdate: 0, + willCreate: 0, + willArchive: 0, + ...overrides, + } +} + +const BLOCKER: ForkSyncBlocker = { + workflowName: 'Caller', + blockLabel: 'Table Block', + fieldLabel: 'Table', + kind: 'table', + sourceId: 'tbl-1', + sourceLabel: 'Orders', + reason: 'unmapped-copyable', +} + +function promoteParams() { + return { + edge: EDGE as never, + sourceWorkspaceId: 'src-ws', + targetWorkspaceId: 'tgt-ws', + direction: 'push' as const, + userId: 'user-1', + } +} + +describe('promoteFork gates', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(db.transaction).mockImplementation( + async (cb: (tx: unknown) => unknown) => cb({}) as never + ) + mockGetUsersWithPermissions.mockResolvedValue([]) + mockLoadSourceDeployedStates.mockResolvedValue({ + deployedWorkflows: [], + sourceStates: new Map(), + }) + mockComputePlan.mockResolvedValue(makePlan()) + mockBuildCopySelection.mockReturnValue({ + selection: EMPTY_SELECTION, + willResolve: new Set(), + }) + mockHasCopySelection.mockReturnValue(false) + mockCollectBlockers.mockResolvedValue([]) + mockLoadBlockMap.mockResolvedValue(new Map()) + mockBuildBlockIdResolver.mockReturnValue((_wf: string, blockId: string) => blockId) + mockResolveFolderMapping.mockResolvedValue(new Map()) + mockUpsertPromoteRun.mockResolvedValue('run-1') + mockGetMcpServerMeta.mockResolvedValue(new Map()) + mockCreateTransform.mockReturnValue((subBlocks: unknown) => subBlocks) + mockSumForkCopyBytes.mockResolvedValue(0) + mockAssertForkStorageHeadroom.mockResolvedValue(undefined) + }) + + it('blocks an over-quota copy selection before any lock, read, or write', async () => { + mockSumForkCopyBytes.mockResolvedValue(999_999) + mockAssertForkStorageHeadroom.mockRejectedValue( + new Error( + 'Not enough storage to copy the selected resources. Storage limit exceeded. Used: 10.50GB, Limit: 10GB' + ) + ) + + await expect( + promoteFork({ + ...promoteParams(), + copyResources: { files: ['workspace/src-ws/key-1'], knowledgeBases: ['kb-1'] }, + }) + ).rejects.toThrow('Not enough storage to copy the selected resources') + + expect(mockAssertForkStorageHeadroom).toHaveBeenCalledWith({ userId: 'user-1', bytes: 999_999 }) + // Fails fast: no source-state loads, no locked transaction, no writes of any kind. + expect(mockLoadSourceDeployedStates).not.toHaveBeenCalled() + expect(db.transaction).not.toHaveBeenCalled() + expect(mockUpsertPromoteRun).not.toHaveBeenCalled() + }) + + it('sums the requested copy selection bytes against the SOURCE workspace (files by key, KBs by id)', async () => { + await promoteFork({ + ...promoteParams(), + copyResources: { + files: ['workspace/src-ws/key-1'], + knowledgeBases: ['kb-1'], + tables: ['tbl-1'], + }, + }) + + expect(mockSumForkCopyBytes).toHaveBeenCalledTimes(1) + expect(mockSumForkCopyBytes).toHaveBeenCalledWith(expect.anything(), 'src-ws', { + fileKeys: ['workspace/src-ws/key-1'], + knowledgeBaseIds: ['kb-1'], + }) + }) + + it('blocks on unmapped required credentials/secrets BEFORE the cleared-refs gate runs', async () => { + mockComputePlan.mockResolvedValue( + makePlan({ + unmappedRequired: [ + { kind: 'credential', sourceId: 'c1', subBlockKey: 'credential', required: true }, + ], + }) + ) + + const result = await promoteFork(promoteParams()) + + expect(result.blocked).toBe('unmapped') + expect(result.unmappedRequired).toEqual([ + { kind: 'credential', sourceId: 'c1', required: true, blockName: undefined }, + ]) + expect(result.blockers).toEqual([]) + expect(mockCollectBlockers).not.toHaveBeenCalled() + expect(mockResolveFolderMapping).not.toHaveBeenCalled() + expect(mockUpsertPromoteRun).not.toHaveBeenCalled() + }) + + it('blocks with the structured blocker list when references would clear, writing NOTHING', async () => { + mockCollectBlockers.mockResolvedValue([BLOCKER]) + + const result = await promoteFork(promoteParams()) + + expect(result.blocked).toBe('cleared-refs') + expect(result.blockers).toEqual([BLOCKER]) + expect(result.promoteRunId).toBe('') + expect(result.updated).toBe(0) + expect(result.created).toBe(0) + expect(result.archived).toBe(0) + // Blocked before the first write: no folder creation, no resource copy, no undo point. + expect(mockResolveFolderMapping).not.toHaveBeenCalled() + expect(mockCopyUnmapped).not.toHaveBeenCalled() + expect(mockUpsertPromoteRun).not.toHaveBeenCalled() + }) + + it('evaluates the gate against the plan resolver overlaid with the copy selection', async () => { + const planResolver = vi.fn(() => 'plan-resolved') + mockComputePlan.mockResolvedValue(makePlan({ resolver: planResolver })) + mockBuildCopySelection.mockReturnValue({ + selection: EMPTY_SELECTION, + willResolve: new Set(['table:t1']), + }) + + await promoteFork(promoteParams()) + + expect(mockCollectBlockers).toHaveBeenCalledTimes(1) + const gateParams = mockCollectBlockers.mock.calls[0][0] + // A copy-selected reference resolves through the overlay (never hits the plan resolver); + // everything else falls through to the plan's persisted-mapping resolver. + expect(gateParams.resolver('table', 't1')).toBe('t1') + expect(planResolver).not.toHaveBeenCalled() + expect(gateParams.resolver('table', 't2')).toBe('plan-resolved') + expect(planResolver).toHaveBeenCalledWith('table', 't2') + }) + + it('threads the SAME block-id resolver into the gate and the resource copy as the workflow writes', async () => { + // Copied tables' workflow-group outputs must land on the block ids the sync actually writes + // (persisted pairs preferred over derive), so the copy receives the resolver built from the + // loaded block map - the identical instance the cleared-refs gate uses. + const resolver = (_workflowId: string, blockId: string) => `pair-${blockId}` + mockBuildBlockIdResolver.mockReturnValue(resolver) + mockHasCopySelection.mockReturnValue(true) + mockCopyUnmapped.mockResolvedValue({ + contentPlan: { + sourceWorkspaceId: 'src-ws', + childWorkspaceId: 'tgt-ws', + userId: 'user-1', + tables: [], + knowledgeBases: [], + skills: [], + documents: [], + }, + copyIdMapByKind: new Map(), + contentRefMaps: {}, + blobTasks: [], + }) + + await promoteFork(promoteParams()) + + expect(mockCopyUnmapped).toHaveBeenCalledTimes(1) + expect(mockCopyUnmapped.mock.calls[0][0].resolveBlockId).toBe(resolver) + expect(mockCollectBlockers.mock.calls[0][0].resolveBlockId).toBe(resolver) + }) + + it('proceeds when zero references would clear (empty blocker list)', async () => { + const plan = makePlan() + mockComputePlan.mockResolvedValue(plan) + + const result = await promoteFork(promoteParams()) + + expect(result.blocked).toBeNull() + expect(result.blockers).toEqual([]) + expect(result.promoteRunId).toBe('run-1') + expect(mockCollectBlockers).toHaveBeenCalledWith( + expect.objectContaining({ + sourceWorkspaceId: 'src-ws', + items: plan.items, + workflowIdMap: plan.workflowIdMap, + }) + ) + expect(mockUpsertPromoteRun).toHaveBeenCalledTimes(1) + }) + + it("threads the plan's unmapped references into the gate so it can reuse the plan's scan", async () => { + const unmappedOptional = [ + { kind: 'table' as const, sourceId: 'tbl-1', subBlockKey: 'tbl', required: false }, + ] + mockComputePlan.mockResolvedValue(makePlan({ unmappedOptional })) + + await promoteFork(promoteParams()) + + expect(mockCollectBlockers).toHaveBeenCalledWith( + expect.objectContaining({ planUnmapped: unmappedOptional }) + ) + }) + + it('batch-loads the mapped TARGET MCP server rows and threads them into the subblock transform', async () => { + // Two references resolving to the SAME target and one unmapped: the read must cover the + // distinct mapped target ids only (one bounded query, unmapped ids dropped). + const resolver = (kind: string, id: string) => { + if (kind !== 'mcp-server') return null + if (id === 'srv-a' || id === 'srv-b') return 'srv-tgt' + return null + } + mockComputePlan.mockResolvedValue( + makePlan({ + resolver, + references: [ + { kind: 'mcp-server', sourceId: 'srv-a', subBlockKey: 'tools', required: false }, + { kind: 'mcp-server', sourceId: 'srv-b', subBlockKey: 'server', required: false }, + { kind: 'mcp-server', sourceId: 'srv-unmapped', subBlockKey: 'tools', required: false }, + ], + }) + ) + mockGetMcpServerMeta.mockResolvedValue( + new Map([['srv-tgt', { name: 'Target Server', url: 'https://target.example/mcp' }]]) + ) + + await promoteFork(promoteParams()) + + expect(mockGetMcpServerMeta).toHaveBeenCalledTimes(1) + expect(mockGetMcpServerMeta).toHaveBeenCalledWith(expect.anything(), 'tgt-ws', ['srv-tgt']) + // The transform receives a lookup resolving the TARGET id to its row metadata, so remapped + // tool-input entries rewrite their embedded serverUrl/serverName from the target server. + expect(mockCreateTransform).toHaveBeenCalledTimes(1) + const [, transformOptions] = mockCreateTransform.mock.calls[0] + expect(transformOptions.resolveMcpServerMeta('srv-tgt')).toEqual({ + name: 'Target Server', + url: 'https://target.example/mcp', + }) + expect(transformOptions.resolveMcpServerMeta('srv-unknown')).toBeUndefined() + }) +}) diff --git a/apps/sim/lib/workspaces/fork/promote/promote.ts b/apps/sim/lib/workspaces/fork/promote/promote.ts index ad6e5d792c6..c475e8f7d25 100644 --- a/apps/sim/lib/workspaces/fork/promote/promote.ts +++ b/apps/sim/lib/workspaces/fork/promote/promote.ts @@ -4,7 +4,7 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { and, eq, inArray } from 'drizzle-orm' -import type { PromoteCopyResources } from '@/lib/api/contracts/workspace-fork' +import type { ForkSyncBlocker, PromoteCopyResources } from '@/lib/api/contracts/workspace-fork' import type { DbOrTx } from '@/lib/db/types' import { enqueueWorkflowUndeploySideEffects, @@ -31,6 +31,10 @@ import { getActiveDeploymentVersionNumbers, loadSourceDeployedStates, } from '@/lib/workspaces/fork/copy/deploy-bridge' +import { + assertForkStorageHeadroom, + sumForkCopyBytes, +} from '@/lib/workspaces/fork/copy/storage-quota' import { acquireForkEdgeLock, acquireForkTargetLock, @@ -53,6 +57,8 @@ import { type ForkMappingUpsert, upsertEdgeMappings, } from '@/lib/workspaces/fork/mapping/mapping-store' +import { getMcpServerMetaByIds } from '@/lib/workspaces/fork/mapping/resources' +import { collectForkSyncBlockers } from '@/lib/workspaces/fork/promote/cleared-refs' import { augmentForkResolver, buildPromoteCopySelection, @@ -71,6 +77,7 @@ import { buildForkBlockIdResolver } from '@/lib/workspaces/fork/remap/block-iden import { createForkSubBlockTransform, type ForkReference, + type ForkReferenceResolver, } from '@/lib/workspaces/fork/remap/remap-references' import { notifyForkWorkflowChanged } from '@/lib/workspaces/fork/socket' import { getUsersWithPermissions } from '@/lib/workspaces/permissions/utils' @@ -99,9 +106,10 @@ export interface PromoteForkParams { value: string }> /** - * Referenced-but-unmapped resources (by source id) the caller chose to copy into the target - * before the sync gate. Validated against the plan's copyable candidates, so an arbitrary id is - * ignored. Each copied resource's references then resolve to the new copy instead of blocking. + * Unmapped resources (by source id) the caller chose to copy into the target before the sync + * gate - referenced ones (their references then resolve to the new copy instead of blocking) + * and unreferenced ones (new in the source, brought along untouched). Validated against the + * plan's copyable candidates, so an arbitrary id is ignored. */ copyResources?: PromoteCopyResources requestId?: string @@ -120,7 +128,14 @@ export interface PromoteForkResult { */ deployFailed: number unmappedRequired: Array> - blocked: 'unmapped' | null + /** + * References the sync would have cleared in the target, so it was blocked without writing + * (`blocked: 'cleared-refs'`). The authoritative in-tx re-check of the diff's would-clear + * preview: normally the client blocks first, so a non-empty list means the state changed + * between preview and Sync. + */ + blockers: ForkSyncBlocker[] + blocked: 'unmapped' | 'cleared-refs' | null /** Names of the workflows the sync changed, by action, for the activity report. */ updatedNames: string[] createdNames: string[] @@ -256,10 +271,9 @@ async function propagateCredentialAccess( } } -interface PromoteTxBlocked { - blocked: 'unmapped' - unmappedRequired: PromoteForkResult['unmappedRequired'] -} +type PromoteTxBlocked = + | { blocked: 'unmapped'; unmappedRequired: PromoteForkResult['unmappedRequired'] } + | { blocked: 'cleared-refs'; blockers: ForkSyncBlocker[] } interface PromoteTxApplied { blocked: null @@ -329,7 +343,9 @@ function groupDependentOverrides( * propagated, and every promoted target is deployed. The plan is computed inside * the edge lock so concurrent promotes serialize. A sync always force-replaces the * target's deployed state (the modal confirms the overwrite up front); it blocks - * without mutating only when required references are unmapped. + * without mutating when required references (credentials / secrets) are unmapped OR + * when any reference would clear in a synced target workflow (the zero-cleared-refs + * gate - every reference must be mapped, selected for copy, or carried by the sync). */ export async function promoteFork(params: PromoteForkParams): Promise { const { edge, sourceWorkspaceId, targetWorkspaceId, direction, userId } = params @@ -352,6 +368,19 @@ export async function promoteFork(params: PromoteForkParams): Promise m.userId) // Read the source's deployed workflows + states BEFORE the transaction so these @@ -382,10 +411,10 @@ export async function promoteFork(params: PromoteForkParams): Promise target fully operational). Evaluated + // against the plan resolver overlaid with the validated copy selection (a selected copy + // resolves its references), BEFORE any write. Authoritative versus the diff's unlocked + // preview - state drift between preview and Sync re-blocks here (TOCTOU) - and it makes the + // in-tx remap's clear-unresolved behavior an unreachable defense-in-depth backstop. The + // plan's unmapped references are threaded through so the gate's happy path reuses the plan's + // scan (computed moments earlier over the same states, inside this same locked tx) instead of + // re-running the full per-block reference scan; the scan re-runs only when something blocks. + const gateResolver: ForkReferenceResolver = (kind, sourceId) => + willResolve.has(`${kind}:${sourceId}`) ? sourceId : plan.resolver(kind, sourceId) + const blockers = await collectForkSyncBlockers({ + executor: tx, + sourceWorkspaceId, + items: plan.items, + sourceStates, + resolver: gateResolver, + workflowIdMap: plan.workflowIdMap, + resolveBlockId, + planUnmapped: [...plan.unmappedRequired, ...plan.unmappedOptional], + }) + if (blockers.length > 0) { + return { blocked: 'cleared-refs', blockers } + } + // Resolve the source->target folder map BEFORE the copy so the folders already exist in the // target and the copy can rewrite `sim:folder/` references inside copied skill / markdown // bodies (the post-commit content rewrite reads this map). Idempotent: it reuses target - // folders that already match by name within the same mapped parent. + // folders that already match by name within the same mapped parent. Creation is scoped to + // the folders that will hold a synced workflow (plus ancestors) - a folder whose subtree + // syncs nothing is never created empty in the target, though it still maps onto a matching + // existing target folder so prior syncs' refs keep resolving. const folderIdMap = await resolveForkFolderMapping({ tx, sourceWorkspaceId, targetWorkspaceId, userId, now, + contentFolderIds: plan.items.map((item) => item.sourceMeta.folderId), }) let resolver = plan.resolver @@ -445,6 +512,9 @@ export async function promoteFork(params: PromoteForkParams): Promise reference.kind === 'mcp-server') + .map((reference) => resolver('mcp-server', reference.sourceId)) + .filter((targetId): targetId is string => targetId != null) + ), + ] + const mcpServerMetaById = await getMcpServerMetaByIds( + tx, + targetWorkspaceId, + mappedMcpServerTargetIds + ) + + const transform = createForkSubBlockTransform(resolver, { + resolveMcpServerMeta: (targetServerId) => mcpServerMetaById.get(targetServerId), + }) // Batch every prior-version read (replace + archive targets) into one query before any // write, so the locked apply phase doesn't do N round-trips. Reads are pre-write, so @@ -491,13 +583,8 @@ export async function promoteFork(params: PromoteForkParams): Promise +type DependentRef = Extract + +const base = { + targetWorkflowId: 'wf-tgt', + workflowName: 'Workflow', + blockId: 'block-1', + blockLabel: 'Block', + fieldLabel: 'Field', + sourceLabel: 'Source', +} + +const referenceRef = ( + kind: ReferenceRef['kind'], + sourceId: string, + sourceDeleted = false +): ReferenceRef => ({ ...base, cause: 'reference', kind, sourceId, sourceDeleted }) + +const workflowRef = (sourceId: string): ForkClearedRef => ({ + ...base, + cause: 'workflow', + kind: 'workflow', + sourceId, +}) + +const dependentRef = (parentKind: DependentRef['parentKind']): DependentRef => ({ + ...base, + cause: 'dependent', + kind: parentKind, + sourceId: 'parent-src', + parentKind, + parentSourceId: 'parent-src', +}) + +describe('forkSyncBlockerReasonFor', () => { + it('maps a live unmapped copyable-kind reference to unmapped-copyable (map or copy)', () => { + for (const kind of ['table', 'knowledge-base', 'file', 'custom-tool', 'skill'] as const) { + expect(forkSyncBlockerReasonFor(referenceRef(kind, 'src-1'))).toBe('unmapped-copyable') + } + }) + + it('maps a live unmapped MCP server to unmapped-mcp-server (map-only; no copy option)', () => { + expect(forkSyncBlockerReasonFor(referenceRef('mcp-server', 'srv-1'))).toBe( + 'unmapped-mcp-server' + ) + }) + + it('maps a source-deleted reference of ANY kind to source-deleted (no exemption)', () => { + expect(forkSyncBlockerReasonFor(referenceRef('table', 'tbl-gone', true))).toBe('source-deleted') + expect(forkSyncBlockerReasonFor(referenceRef('mcp-server', 'srv-gone', true))).toBe( + 'source-deleted' + ) + expect(forkSyncBlockerReasonFor(referenceRef('file', 'workspace/SRC/gone.png', true))).toBe( + 'source-deleted' + ) + }) + + it('maps a workflow-cause entry to workflow-missing', () => { + expect(forkSyncBlockerReasonFor(workflowRef('wf-other'))).toBe('workflow-missing') + }) + + it('never blocks a dependent-cause entry (the reconfigure flow owns dependents)', () => { + expect(forkSyncBlockerReasonFor(dependentRef('credential'))).toBeNull() + expect(forkSyncBlockerReasonFor(dependentRef('knowledge-base'))).toBeNull() + }) + + it('defensively ignores kinds the collector excludes (credential / env-var / document)', () => { + // These never reach the cleared list (excluded by the collector); if one leaked, the + // kind-level required gate owns credentials/env-vars, so this path must not double-block. + expect(forkSyncBlockerReasonFor(referenceRef('credential', 'c1'))).toBeNull() + expect(forkSyncBlockerReasonFor(referenceRef('env-var', 'KEY'))).toBeNull() + expect(forkSyncBlockerReasonFor(referenceRef('knowledge-document', 'doc-1'))).toBeNull() + }) +}) + +describe('selectForkSyncBlockingRefs / toForkSyncBlockers', () => { + it('keeps reference + workflow causes with their reasons and drops dependents', () => { + const refs: ForkClearedRef[] = [ + referenceRef('table', 'tbl-1'), + referenceRef('mcp-server', 'srv-1'), + referenceRef('skill', 'sk-gone', true), + workflowRef('wf-other'), + dependentRef('credential'), + ] + const blocking = selectForkSyncBlockingRefs(refs) + expect(blocking.map(({ ref, reason }) => [ref.sourceId, reason])).toEqual([ + ['tbl-1', 'unmapped-copyable'], + ['srv-1', 'unmapped-mcp-server'], + ['sk-gone', 'source-deleted'], + ['wf-other', 'workflow-missing'], + ]) + }) + + it('maps blocking entries to the wire blocker shape', () => { + const blocking = selectForkSyncBlockingRefs([referenceRef('table', 'tbl-1')]) + expect(toForkSyncBlockers(blocking)).toEqual([ + { + workflowName: 'Workflow', + blockLabel: 'Block', + fieldLabel: 'Field', + kind: 'table', + sourceId: 'tbl-1', + sourceLabel: 'Source', + reason: 'unmapped-copyable', + }, + ]) + }) +}) diff --git a/apps/sim/lib/workspaces/fork/promote/sync-blockers.ts b/apps/sim/lib/workspaces/fork/promote/sync-blockers.ts new file mode 100644 index 00000000000..b5d2bbed6c2 --- /dev/null +++ b/apps/sim/lib/workspaces/fork/promote/sync-blockers.ts @@ -0,0 +1,65 @@ +import { + type ForkClearedRef, + type ForkSyncBlocker, + type ForkSyncBlockerReason, + forkCopyableKindSchema, +} from '@/lib/api/contracts/workspace-fork' + +/** + * Pure sync-blocker taxonomy, shared by the server gate (promote) and the modal's blocker + * rendering. A sync is allowed only when ZERO references would clear in any synced target + * workflow; every would-clear entry of cause `reference` or `workflow` is a blocker with an + * actionable reason. `dependent`-cause entries are NOT blockers - the dependent/reconfigure + * flow owns them (its own required gating), and a credential-anchored dependent clears on any + * parent remap, so blocking on it would be unresolvable. + */ + +/** Copyable kinds derived from the wire contract, so the reason split can never drift. */ +const COPYABLE_BLOCKER_KINDS: ReadonlySet = new Set(forkCopyableKindSchema.options) + +/** + * The blocker reason for a would-clear entry, or null when the entry does not block + * (`dependent` cause, and - defensively - any kind the cleared-ref collector excludes): + * - `workflow` cause -> `workflow-missing` (deploy the referenced workflow in the source, or + * remove the reference). + * - `reference` + source deleted -> `source-deleted` (map the dead id to a live target + * resource, or fix/archive the source workflow). + * - `reference` + external MCP server -> `unmapped-mcp-server` (map it; MCP servers are never + * copied). + * - `reference` + copyable kind -> `unmapped-copyable` (map it or select it for copy). + */ +export function forkSyncBlockerReasonFor(ref: ForkClearedRef): ForkSyncBlockerReason | null { + if (ref.cause === 'workflow') return 'workflow-missing' + if (ref.cause !== 'reference') return null + if (ref.sourceDeleted) return 'source-deleted' + if (ref.kind === 'mcp-server') return 'unmapped-mcp-server' + if (COPYABLE_BLOCKER_KINDS.has(ref.kind)) return 'unmapped-copyable' + // Credential / env-var / knowledge-document never reach the cleared list (excluded by the + // collector; the first two gate via the kind-level required gate, documents follow their KB). + return null +} + +/** The would-clear entries that BLOCK the sync, paired with their reason. */ +export function selectForkSyncBlockingRefs( + clearedRefs: ForkClearedRef[] +): Array<{ ref: ForkClearedRef; reason: ForkSyncBlockerReason }> { + return clearedRefs.flatMap((ref) => { + const reason = forkSyncBlockerReasonFor(ref) + return reason ? [{ ref, reason }] : [] + }) +} + +/** Map blocking entries to the wire {@link ForkSyncBlocker} shape of the promote gate error. */ +export function toForkSyncBlockers( + blocking: Array<{ ref: ForkClearedRef; reason: ForkSyncBlockerReason }> +): ForkSyncBlocker[] { + return blocking.map(({ ref, reason }) => ({ + workflowName: ref.workflowName, + blockLabel: ref.blockLabel, + fieldLabel: ref.fieldLabel, + kind: ref.kind, + sourceId: ref.sourceId, + sourceLabel: ref.sourceLabel, + reason, + })) +} diff --git a/apps/sim/lib/workspaces/fork/remap/remap-references.test.ts b/apps/sim/lib/workspaces/fork/remap/remap-references.test.ts index 849273501aa..d549381d74a 100644 --- a/apps/sim/lib/workspaces/fork/remap/remap-references.test.ts +++ b/apps/sim/lib/workspaces/fork/remap/remap-references.test.ts @@ -25,6 +25,7 @@ import { applyDependentOverrides, clearDependentsOnRemap, collectClearedDependents, + createForkSubBlockTransform, parseNestedDependentKey, readTargetDraftDependentValue, remapForkSubBlocks, @@ -413,6 +414,263 @@ describe('createForkBootstrapTransform document-selector remap', () => { }) }) +describe('MCP block server remap follows the tool selection (optimistic verbatim)', () => { + // Shape of the real MCP block: tool depends on server, arguments depend on tool. + const mcpBlock = () => + blockWith([ + { id: 'server', title: 'MCP Server', type: 'mcp-server-selector', required: true }, + { + id: 'tool', + title: 'Tool', + type: 'mcp-tool-selector', + required: true, + dependsOn: ['server'], + }, + { id: 'arguments', title: '', type: 'mcp-dynamic-args', dependsOn: ['tool'] }, + ]) + const mcpSubBlocks = (): SubBlockRecord => ({ + server: { id: 'server', type: 'mcp-server-selector', value: 'mcp-src1' }, + tool: { id: 'tool', type: 'mcp-tool-selector', value: 'mcp-src1-search_docs' }, + arguments: { id: 'arguments', type: 'mcp-dynamic-args', value: '{"query":"hello"}' }, + }) + const mapServer = (kind: string, id: string) => + kind === 'mcp-server' && id === 'mcp-src1' ? 'mcp-tgt9' : null + + it('sync transform: keeps the tool (embedded server id swapped, name verbatim) and its arguments', () => { + // The same transform serves BOTH create- and replace-mode sync targets, so a freshly + // created target deploys with the tool intact instead of an empty required field. + vi.mocked(getBlock).mockReturnValue(mcpBlock()) + const transform = createForkSubBlockTransform(mapServer) + const result = transform(mcpSubBlocks(), 'mcp') + expect(result.server.value).toBe('mcp-tgt9') + expect(result.tool.value).toBe('mcp-tgt9-search_docs') + expect(result.arguments.value).toBe('{"query":"hello"}') + }) + + it('keeps a bare tool name (no embedded server id) verbatim under the remapped server', () => { + vi.mocked(getBlock).mockReturnValue(mcpBlock()) + const subBlocks = mcpSubBlocks() + subBlocks.tool = { id: 'tool', type: 'mcp-tool-selector', value: 'search_docs' } + const transform = createForkSubBlockTransform(mapServer) + const result = transform(subBlocks, 'mcp') + expect(result.server.value).toBe('mcp-tgt9') + expect(result.tool.value).toBe('search_docs') + expect(result.arguments.value).toBe('{"query":"hello"}') + }) + + it('sync transform: an UNMAPPED server is cleared and still clears tool + arguments (defense-in-depth)', () => { + // The zero-cleared-refs gate blocks a sync before this state can persist; the remap's + // clear-unresolved backstop must still never leave a tool under a cleared server. + vi.mocked(getBlock).mockReturnValue(mcpBlock()) + const transform = createForkSubBlockTransform(() => null) + const result = transform(mcpSubBlocks(), 'mcp') + expect(result.server.value).toBe('') + expect(result.tool.value).toBe('') + expect(result.arguments.value).toBe('') + }) + + it('fork-create: servers are not copied, so the reference clears and dependents clear with it', () => { + vi.mocked(getBlock).mockReturnValue(mcpBlock()) + const transform = createForkBootstrapTransform(() => null) + const result = transform(mcpSubBlocks(), 'mcp') + expect(result.server.value).toBe('') + expect(result.tool.value).toBe('') + expect(result.arguments.value).toBe('') + }) + + it('remap layer: the tool follow-rewrite is not registered as a remapped parent key', () => { + // Only `server` may drive dependent clears; the followed tool must not (its own + // dependent - arguments - is preserved with it). + const result = remapForkSubBlocks(mcpSubBlocks(), mapServer, 'promote') + expect(result.subBlocks.tool.value).toBe('mcp-tgt9-search_docs') + expect(result.remappedKeys).toEqual(new Set(['server'])) + }) + + it('clearDependentsOnRemap: exemption applies ONLY to the mcp tool selector, not other kinds', () => { + // A knowledge-base parent remapped to a non-empty target still clears its + // document-selector dependent (regression guard for the mcp-only exemption). + vi.mocked(getBlock).mockReturnValue( + blockWith([ + { id: 'knowledgeBaseId', title: 'KB', type: 'knowledge-base-selector' }, + { + id: 'documentId', + title: 'Doc', + type: 'document-selector', + dependsOn: ['knowledgeBaseId'], + }, + ]) + ) + const result = clearDependentsOnRemap( + { + knowledgeBaseId: { + id: 'knowledgeBaseId', + type: 'knowledge-base-selector', + value: 'kb-dst', + }, + documentId: { id: 'documentId', type: 'document-selector', value: 'doc-src' }, + }, + 'knowledge', + new Set(['knowledgeBaseId']) + ) + expect(result.documentId.value).toBe('') + }) + + it('clearDependentsOnRemap: preserve holds when a SECOND remapped key also reaches the tool selector', () => { + // Synthetic config (no registry block wires this today): the tool selector hangs off BOTH a + // remapped mcp-server parent (preserve) and another remapped parent (no preserve). The + // selector-keyed preserve must win over the other key's clear, in either key order, while + // the other key's own non-exempt dependent still clears. + vi.mocked(getBlock).mockReturnValue( + blockWith([ + { id: 'server', title: 'MCP Server', type: 'mcp-server-selector' }, + { id: 'knowledgeBaseId', title: 'KB', type: 'knowledge-base-selector' }, + { + id: 'tool', + title: 'Tool', + type: 'mcp-tool-selector', + dependsOn: ['server', 'knowledgeBaseId'], + }, + { id: 'arguments', title: '', type: 'mcp-dynamic-args', dependsOn: ['tool'] }, + { + id: 'documentId', + title: 'Doc', + type: 'document-selector', + dependsOn: ['knowledgeBaseId'], + }, + ]) + ) + const subBlocks = (): SubBlockRecord => ({ + server: { id: 'server', type: 'mcp-server-selector', value: 'mcp-tgt9' }, + knowledgeBaseId: { id: 'knowledgeBaseId', type: 'knowledge-base-selector', value: 'kb-dst' }, + tool: { id: 'tool', type: 'mcp-tool-selector', value: 'mcp-tgt9-search_docs' }, + arguments: { id: 'arguments', type: 'mcp-dynamic-args', value: '{"query":"hello"}' }, + documentId: { id: 'documentId', type: 'document-selector', value: 'doc-src' }, + }) + for (const keys of [ + ['server', 'knowledgeBaseId'], + ['knowledgeBaseId', 'server'], + ]) { + const result = clearDependentsOnRemap(subBlocks(), 'mcp', new Set(keys)) + expect(result.tool.value).toBe('mcp-tgt9-search_docs') + expect(result.arguments.value).toBe('{"query":"hello"}') + expect(result.documentId.value).toBe('') + } + }) + + it('clearDependentsOnRemap: a CLEARED server alongside another remapped key still clears the tool', () => { + // Same two-key config, but the server was cleared (unmapped): no preserve applies anywhere, + // so the tool and its arguments clear as ordinary dependents. + vi.mocked(getBlock).mockReturnValue( + blockWith([ + { id: 'server', title: 'MCP Server', type: 'mcp-server-selector' }, + { id: 'knowledgeBaseId', title: 'KB', type: 'knowledge-base-selector' }, + { + id: 'tool', + title: 'Tool', + type: 'mcp-tool-selector', + dependsOn: ['server', 'knowledgeBaseId'], + }, + { id: 'arguments', title: '', type: 'mcp-dynamic-args', dependsOn: ['tool'] }, + ]) + ) + const result = clearDependentsOnRemap( + { + server: { id: 'server', type: 'mcp-server-selector', value: '' }, + knowledgeBaseId: { + id: 'knowledgeBaseId', + type: 'knowledge-base-selector', + value: 'kb-dst', + }, + tool: { id: 'tool', type: 'mcp-tool-selector', value: 'mcp-src1-search_docs' }, + arguments: { id: 'arguments', type: 'mcp-dynamic-args', value: '{"query":"hello"}' }, + }, + 'mcp', + new Set(['server', 'knowledgeBaseId']) + ) + expect(result.tool.value).toBe('') + expect(result.arguments.value).toBe('') + }) +}) + +describe('tool-input MCP entry server remap rewrites embedded server metadata', () => { + const toolInputSubBlocks = (params: Record): SubBlockRecord => ({ + tools: { + id: 'tools', + type: 'tool-input', + value: [{ type: 'mcp', title: 'search', toolId: 'mcp-src1-search', params }], + }, + }) + const entryParams = () => ({ + serverId: 'mcp-src1', + serverUrl: 'https://old.example/mcp', + toolName: 'search', + serverName: 'Old Server', + }) + const mapServer = (kind: string, id: string) => + kind === 'mcp-server' && id === 'mcp-src1' ? 'mcp-tgt9' : null + + it('rewrites serverUrl/serverName from the mapped TARGET row; tool name verbatim, toolId rebuilt', () => { + const result = remapForkSubBlocks(toolInputSubBlocks(entryParams()), mapServer, 'promote', { + resolveMcpServerMeta: (targetServerId) => + targetServerId === 'mcp-tgt9' + ? { name: 'New Server', url: 'https://new.example/mcp' } + : undefined, + }) + const [tool] = result.subBlocks.tools.value as Array<{ + toolId: string + params: Record + }> + expect(tool.params).toEqual({ + serverId: 'mcp-tgt9', + serverUrl: 'https://new.example/mcp', + toolName: 'search', + serverName: 'New Server', + }) + expect(tool.toolId).toBe('mcp-tgt9-search') + }) + + it('drops the stale serverUrl when the target server has no url', () => { + const result = remapForkSubBlocks(toolInputSubBlocks(entryParams()), mapServer, 'promote', { + resolveMcpServerMeta: () => ({ name: 'New Server', url: null }), + }) + const [tool] = result.subBlocks.tools.value as Array<{ params: Record }> + expect(tool.params).toEqual({ + serverId: 'mcp-tgt9', + toolName: 'search', + serverName: 'New Server', + }) + }) + + it('without a meta resolver (scan-only callers) the id remaps and metadata is left as-is', () => { + const result = remapForkSubBlocks(toolInputSubBlocks(entryParams()), mapServer, 'promote') + const [tool] = result.subBlocks.tools.value as Array<{ + toolId: string + params: Record + }> + expect(tool.params).toEqual({ + serverId: 'mcp-tgt9', + serverUrl: 'https://old.example/mcp', + toolName: 'search', + serverName: 'Old Server', + }) + expect(tool.toolId).toBe('mcp-tgt9-search') + }) + + it('threads the meta resolver through the sync transform', () => { + // Transform-level check: promote passes the batch-loaded target rows via options. + vi.mocked(getBlock).mockReturnValue( + blockWith([{ id: 'tools', title: 'Tools', type: 'tool-input' }]) + ) + const transform = createForkSubBlockTransform(mapServer, { + resolveMcpServerMeta: () => ({ name: 'New Server', url: 'https://new.example/mcp' }), + }) + const result = transform(toolInputSubBlocks(entryParams()), 'agent') + const [tool] = result.tools.value as Array<{ params: Record }> + expect(tool.params.serverUrl).toBe('https://new.example/mcp') + expect(tool.params.serverName).toBe('New Server') + }) +}) + describe('clearDependentsOnRemap canonical-pair gating', () => { const kbCanonicalBlock = () => blockWith([ diff --git a/apps/sim/lib/workspaces/fork/remap/remap-references.ts b/apps/sim/lib/workspaces/fork/remap/remap-references.ts index dc4e9c269bf..fe645f8b9ad 100644 --- a/apps/sim/lib/workspaces/fork/remap/remap-references.ts +++ b/apps/sim/lib/workspaces/fork/remap/remap-references.ts @@ -1,5 +1,6 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' +import { omit } from '@sim/utils/object' import type { SubBlockType } from '@sim/workflow-types/blocks' import type { z } from 'zod' import type { forkRemapKindSchema } from '@/lib/api/contracts/workspace-fork' @@ -33,6 +34,7 @@ import { } from '@/lib/workspaces/fork/remap/remap-files' import { getBlock } from '@/blocks/registry' import type { SubBlockConfig } from '@/blocks/types' +import { getSubBlocksDependingOnChange } from '@/blocks/utils' /** * Resource kinds the fork remapper rewrites across workspaces, derived from the @@ -82,9 +84,11 @@ export const REGISTRY_KIND_TO_FORK_KIND: Partial< // map when its referenced document was copied into the fork; an unmapped document (its // parent KB wasn't copied, or the doc wasn't copyable) resolves to null and is cleared, // and `clearDependentsOnRemap` still clears it as a `knowledgeBaseId` dependent when the -// parent KB itself is unmapped. `mcp-tool-selector` is cleared by `dependsOn` when its -// `mcp-server-selector` parent is remapped - the tool list is server-scoped and may -// differ in the target. +// parent KB itself is unmapped. `mcp-tool-selector` follows its `mcp-server-selector` +// parent's remap: mapping asserts the servers are equivalent, so the tool SELECTION is +// kept (its embedded server id swapped to the target's, the tool name verbatim - see +// {@link remapForkSubBlocks}) and `clearDependentsOnRemap` exempts it. When the server +// is CLEARED (unmapped / fork-create) the tool still clears as a dependent. /** Matches `{{ENV_KEY}}` references inside subblock values; shared with cascade detection. */ export const ENV_REF_PATTERN = /\{\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*\}\}/g @@ -128,6 +132,22 @@ export type ForkReferenceResolver = ( sourceId: string ) => string | null | undefined +/** Identity metadata of a mapped TARGET MCP server row (url is null for url-less transports). */ +export interface ForkMcpServerMeta { + name: string + url: string | null +} + +/** + * Resolves a mapped TARGET MCP server id to its row metadata, so a remapped tool-input + * entry's embedded `serverUrl`/`serverName` are rewritten from the target server instead + * of carrying the source server's (which would show a false "URL changed" stale badge in + * the target UI). Undefined when the row is unknown - the entry's metadata is then left + * as-is. Threaded by promote (which batch-loads the mapped targets); scan-only callers + * omit it because they never persist the remapped value. + */ +export type ForkMcpServerMetaResolver = (targetServerId: string) => ForkMcpServerMeta | undefined + export interface ForkReference { kind: ForkRemapKind sourceId: string @@ -153,6 +173,8 @@ export interface RemapForkContext { blockType?: string /** Canonical-mode overrides (`block.data.canonicalModes`), picking the active member per pair. */ canonicalModes?: CanonicalModeOverrides + /** Target MCP server row lookup for rewriting remapped tool-input entries' server metadata. */ + resolveMcpServerMeta?: ForkMcpServerMetaResolver } function remapEnvInValue( @@ -349,6 +371,8 @@ interface ForkToolInputOptions { /** Fork-create drops unresolved tools / clears params; promote keeps + records. */ clearUnresolved: boolean record?: (kind: ForkRemapKind, sourceId: string, mapped: boolean) => void + /** Target MCP server row lookup for rewriting a remapped MCP entry's server metadata. */ + resolveMcpServerMeta?: ForkMcpServerMetaResolver } /** @@ -394,10 +418,22 @@ function remapForkToolInputValue( changed = true const toolName = typeof tool.params.toolName === 'string' ? tool.params.toolName : undefined + let nextParams: Record = { ...tool.params, serverId: target } + // The entry embeds the server's identity metadata (`serverUrl`/`serverName`); rewrite + // it from the mapped TARGET row so the target UI never flags a false "URL changed" + // stale badge against the source server's url (a url-less target drops the stale key). + // The tool NAME stays verbatim - mapping asserts server equivalence; a name missing on + // the target degrades to the existing tool_not_found badge / runtime skip. Without a + // meta resolver (scan-only callers) the metadata is left as-is. + const meta = opts.resolveMcpServerMeta?.(target) + if (meta) { + nextParams = { ...omit(nextParams, ['serverUrl']), serverName: meta.name } + if (meta.url) nextParams.serverUrl = meta.url + } return [ { ...tool, - params: { ...tool.params, serverId: target }, + params: nextParams, toolId: toolName ? createMcpToolId(target, toolName) : tool.toolId, }, ] @@ -476,6 +512,8 @@ export function remapForkSubBlocks( const references = new Map() const unmapped = new Map() const remappedKeys = new Set() + /** MCP server ids remapped to a DIFFERENT mapped target this pass (source id -> target id). */ + const mcpServerRemaps = new Map() const recordReference = (key: string, reference: ForkReference, mapped: boolean) => { if (mode !== 'promote') return @@ -542,6 +580,7 @@ export function remapForkSubBlocks( if (!isDormant) recordReference(`${forkKind}:${ref.rawValue}`, reference, mapped) if (mapped) { if (target !== ref.rawValue) { + if (forkKind === 'mcp-server') mcpServerRemaps.set(ref.rawValue, target) const replaceResult = definition.codec.replace(value, ref.rawValue, target) if (replaceResult.success) value = replaceResult.nextValue } @@ -591,7 +630,11 @@ export function remapForkSubBlocks( ) value = subBlockType === 'tool-input' - ? remapForkToolInputValue(value, resolve, { clearUnresolved, record }) + ? remapForkToolInputValue(value, resolve, { + clearUnresolved, + record, + resolveMcpServerMeta: context?.resolveMcpServerMeta, + }) : remapForkSkillInputValue(value, resolve, { clearUnresolved, record }) } @@ -618,6 +661,32 @@ export function remapForkSubBlocks( result[subBlockKey] = { ...subBlock, value } } + // An MCP block's tool SELECTION follows its server's remap instead of clearing: the stored + // value embeds the server id (`mcp--`), so swap the embedded id for the + // mapped target's and keep the tool NAME verbatim - mapping asserts the servers are + // equivalent, mirroring how tool-input MCP entries keep their tool name. A value that does + // not embed a remapped server id (a bare tool name) is already server-agnostic and kept + // as-is. Deliberately NOT added to `remappedKeys`: the selection is preserved, so its own + // dependents (the tool's arguments) must be preserved with it, and `clearDependentsOnRemap` + // exempts the selector under a remapped (non-cleared) server parent. + if (mcpServerRemaps.size > 0) { + for (const [subBlockKey, subBlock] of Object.entries(result)) { + if (!subBlock || typeof subBlock !== 'object') continue + if (subBlock.type !== 'mcp-tool-selector') continue + const toolValue = subBlock.value + if (typeof toolValue !== 'string' || !toolValue) continue + for (const [sourceServerId, targetServerId] of mcpServerRemaps) { + const sourcePrefix = createMcpToolId(sourceServerId, '') + if (!toolValue.startsWith(sourcePrefix)) continue + result[subBlockKey] = { + ...subBlock, + value: createMcpToolId(targetServerId, toolValue.slice(sourcePrefix.length)), + } + break + } + } + } + return { subBlocks: result, references: Array.from(references.values()), @@ -629,11 +698,17 @@ export function remapForkSubBlocks( /** * Clear every subblock whose `dependsOn` parent was remapped to a different * target this pass, so a child scoped to the old parent (a KB's document, a - * Slack channel, a sheet tab) never carries a stale id into the target. Reuses - * the search-replace dependent-clear walk (canonical-pair aware, transitive over - * `dependsOn` chains) so fork/promote and in-editor search-replace clear - * identically. Children of an unchanged parent are preserved; a no-op for - * unknown block types or when nothing was remapped. + * Slack channel, a sheet tab) never carries a stale id into the target. Uses + * the same dependent walk as search-replace (canonical-pair aware, transitive + * over `dependsOn` chains) so fork/promote and in-editor search-replace clear + * identically - with ONE remap-specific exemption: an `mcp-tool-selector` under + * an `mcp-server-selector` parent that was REMAPPED to a mapped target (its + * post-remap value is non-empty) is preserved along with its own dependents + * (the tool's arguments), because mapping asserts the servers are equivalent + * and {@link remapForkSubBlocks} already followed the selection onto the target + * server. A CLEARED server (unmapped / fork-create) still clears its dependents. + * Children of an unchanged parent are preserved; a no-op for unknown block + * types or when nothing was remapped. */ export function clearDependentsOnRemap( subBlocks: SubBlockRecord, @@ -661,11 +736,50 @@ export function clearDependentsOnRemap( return (mode === 'advanced') !== group.advancedIds.includes(baseKey) } + // The exemption's parent test: an mcp-server selector whose POST-remap value is non-empty was + // remapped to a mapped target (a cleared one is empty), so its tool selection is preserved. + const configTypeById = new Map( + config.subBlocks.filter((cfg) => cfg.id).map((cfg) => [cfg.id, cfg.type]) + ) + const isRemappedMcpServerParent = (key: string): boolean => { + if (configTypeById.get(key.replace(/_\d+$/, '')) !== 'mcp-server-selector') return false + const parent = subBlocks[key] + return parent && typeof parent === 'object' ? isNonEmptyValue(parent.value) : false + } + + // The preserve decision is hoisted out of the per-key walk and keyed on the SELECTOR (not on + // which remapped key reaches it): `toClear` is a union across per-key BFS passes (each with its + // own `visited`), so an in-loop exemption holds only against the exempting key - a second + // remapped key (or a longer dependsOn path) reaching the same tool selector would re-add it. + // Unreachable with today's registry (the tool selector's only dependsOn parent is its server), + // but this makes the exemption independent of key order and path by construction. + const preservedMcpToolSelectors = new Set() + for (const key of remappedKeys) { + if (isDormantCanonicalMember(key) || !isRemappedMcpServerParent(key)) continue + for (const dependent of getSubBlocksDependingOnChange(config.subBlocks, key)) { + if (dependent.id && dependent.type === 'mcp-tool-selector') { + preservedMcpToolSelectors.add(dependent.id) + } + } + } + + // Same BFS as `getWorkflowSearchDependentClears`, with the preserved tool selector's subtree + // pruned (skipping it keeps its own dependents - the arguments - out of the clear set too). const toClear = new Set() for (const key of remappedKeys) { if (isDormantCanonicalMember(key)) continue - for (const clear of getWorkflowSearchDependentClears(config.subBlocks, key)) { - if (!remappedKeys.has(clear.subBlockId)) toClear.add(clear.subBlockId) + const visited = new Set([key]) + const queue = [key] + while (queue.length > 0) { + const current = queue.shift() + if (!current) continue + for (const dependent of getSubBlocksDependingOnChange(config.subBlocks, current)) { + if (!dependent.id || visited.has(dependent.id)) continue + if (preservedMcpToolSelectors.has(dependent.id)) continue + visited.add(dependent.id) + if (!remappedKeys.has(dependent.id)) toClear.add(dependent.id) + queue.push(dependent.id) + } } } @@ -975,14 +1089,20 @@ export function remapSubBlocks( /** A `copyWorkflowStateIntoTarget` subBlock transform that rewrites references via the resolver. */ export function createForkSubBlockTransform( - resolve: ForkReferenceResolver + resolve: ForkReferenceResolver, + options?: { + /** Mapped-target MCP server rows, so remapped tool-input entries rewrite their server metadata. */ + resolveMcpServerMeta?: ForkMcpServerMetaResolver + } ): ( subBlocks: SubBlockRecord, blockType: string, canonicalModes?: CanonicalModeOverrides ) => SubBlockRecord { return (subBlocks, blockType, canonicalModes) => { - const result = remapSubBlocks(subBlocks, resolve) + const result = remapSubBlocks(subBlocks, resolve, { + resolveMcpServerMeta: options?.resolveMcpServerMeta, + }) return clearDependentsOnRemap(result.subBlocks, blockType, result.remappedKeys, canonicalModes) } } diff --git a/apps/sim/lib/workspaces/fork/remap/remap-table-groups.test.ts b/apps/sim/lib/workspaces/fork/remap/remap-table-groups.test.ts index e05b8b00971..c49ee410b03 100644 --- a/apps/sim/lib/workspaces/fork/remap/remap-table-groups.test.ts +++ b/apps/sim/lib/workspaces/fork/remap/remap-table-groups.test.ts @@ -3,7 +3,10 @@ */ import { describe, expect, it } from 'vitest' import type { TableSchema } from '@/lib/table/types' -import { deriveForkBlockId } from '@/lib/workspaces/fork/remap/block-identity' +import { + buildForkBlockIdResolver, + deriveForkBlockId, +} from '@/lib/workspaces/fork/remap/block-identity' import { remapForkTableWorkflowGroups } from '@/lib/workspaces/fork/remap/remap-table-groups' describe('remapForkTableWorkflowGroups', () => { @@ -28,6 +31,36 @@ describe('remapForkTableWorkflowGroups', () => { expect(result.columns[0].workflowGroupId).toBe('g1') }) + // Promote threads its persisted-pair resolver: a paired block resolves to the pair's target id + // (on push, the parent's ORIGINAL id - never the derive); an unpaired block falls back to the + // derive, matching the workflow write path. + it('prefers a provided block-id resolver (persisted pair) over the derive, deriving unpaired blocks', () => { + const map = new Map([['src-wf', 'child-wf']]) + const schema: TableSchema = { + columns: [], + workflowGroups: [ + { + id: 'g1', + workflowId: 'src-wf', + outputs: [ + { blockId: 'src-block', path: 'out', columnName: 'col_1' }, + { blockId: 'src-unpaired', path: 'out2', columnName: 'col_2' }, + ], + }, + ], + } + const resolver = buildForkBlockIdResolver(true, { + parentToChild: new Map([ + ['src-block', { targetBlockId: 'original-parent-block', targetWorkflowId: 'child-wf' }], + ]), + childToParent: new Map(), + }) + const result = remapForkTableWorkflowGroups(schema, map, resolver) + const outputs = result.workflowGroups?.[0].outputs + expect(outputs?.[0].blockId).toBe('original-parent-block') + expect(outputs?.[1].blockId).toBe(deriveForkBlockId('child-wf', 'src-unpaired')) + }) + it('drops a group whose backing workflow was not copied and clears its column wiring', () => { const schema: TableSchema = { columns: [{ id: 'col_1', name: 'Out', type: 'string', workflowGroupId: 'g1' }], diff --git a/apps/sim/lib/workspaces/fork/remap/remap-table-groups.ts b/apps/sim/lib/workspaces/fork/remap/remap-table-groups.ts index 9eb00d914c3..1d278f2b2ad 100644 --- a/apps/sim/lib/workspaces/fork/remap/remap-table-groups.ts +++ b/apps/sim/lib/workspaces/fork/remap/remap-table-groups.ts @@ -1,19 +1,28 @@ import type { TableSchema } from '@/lib/table/types' -import { deriveForkBlockId } from '@/lib/workspaces/fork/remap/block-identity' +import { + deriveForkBlockId, + type ForkBlockIdResolver, +} from '@/lib/workspaces/fork/remap/block-identity' /** * Remap the workflow/block references embedded in a copied table's schema so its * workflow groups keep working in the child workspace. `workflowGroups[].workflowId` * is rewritten through the source→child workflow identity map, and each - * `outputs[].blockId` is rewritten to the deterministic forked block id (matching - * `copyWorkflowStateIntoTarget`). Manual groups whose backing workflow was not + * `outputs[].blockId` is rewritten through `resolveBlockId` - which MUST be the + * same resolver that assigns the target workflows' block ids, or the outputs + * point at nonexistent blocks. Fork-create omits it and defaults to the + * deterministic {@link deriveForkBlockId} (a fresh child has no persisted + * pairs, matching `copyWorkflowStateIntoTarget`); promote passes its + * persisted-pair resolver (a push keeps the parent's ORIGINAL block ids, which + * never equal the derive). Manual groups whose backing workflow was not * copied are dropped, and any columns wired to a dropped group have their * `workflowGroupId` cleared. Enrichment groups (empty `workflowId`) and column * ids are left untouched. */ export function remapForkTableWorkflowGroups( schema: TableSchema, - workflowIdMap: Map + workflowIdMap: Map, + resolveBlockId: ForkBlockIdResolver = deriveForkBlockId ): TableSchema { const groups = schema.workflowGroups ?? [] if (groups.length === 0) return schema @@ -33,7 +42,7 @@ export function remapForkTableWorkflowGroups( outputs: group.outputs.map((output) => ({ ...output, blockId: output.blockId - ? deriveForkBlockId(childWorkflowId, output.blockId) + ? resolveBlockId(childWorkflowId, output.blockId) : output.blockId, })), }, diff --git a/apps/sim/tools/workflow/executor.test.ts b/apps/sim/tools/workflow/executor.test.ts index f1c5ca56bd5..90b08329e1a 100644 --- a/apps/sim/tools/workflow/executor.test.ts +++ b/apps/sim/tools/workflow/executor.test.ts @@ -52,6 +52,39 @@ describe('workflowExecutorTool', () => { }) }) + it.concurrent('should declare parentWorkspaceId when the context has a workspace', () => { + const params = { + workflowId: 'test-workflow-id', + inputMapping: { name: 'Test' }, + _context: { workspaceId: 'workspace-parent' }, + } + + const result = buildBody(params) + + expect(result).toEqual({ + input: { name: 'Test' }, + triggerType: 'workflow', + useDraftState: true, + parentWorkspaceId: 'workspace-parent', + }) + }) + + it.concurrent('should omit parentWorkspaceId when the context has no workspace', () => { + const params = { + workflowId: 'test-workflow-id', + inputMapping: { name: 'Test' }, + _context: { isDeployedContext: true }, + } + + const result = buildBody(params) + + expect(result).toEqual({ + input: { name: 'Test' }, + triggerType: 'workflow', + useDraftState: false, + }) + }) + it.concurrent('should parse JSON string inputMapping (UI-provided via tool-input)', () => { const params = { workflowId: 'test-workflow-id', diff --git a/apps/sim/tools/workflow/executor.ts b/apps/sim/tools/workflow/executor.ts index 95bfa1ff52b..6abc33eed29 100644 --- a/apps/sim/tools/workflow/executor.ts +++ b/apps/sim/tools/workflow/executor.ts @@ -44,10 +44,12 @@ export const workflowExecutorTool: ToolConfig< } // Use draft state for manual runs (not deployed), deployed state for deployed runs const isDeployedContext = params._context?.isDeployedContext + const parentWorkspaceId = params._context?.workspaceId return { input: inputData, triggerType: 'workflow', useDraftState: !isDeployedContext, + ...(parentWorkspaceId ? { parentWorkspaceId } : {}), } }, }, diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index 9f5d387d0a1..4858463d4a5 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -25,7 +25,7 @@ const BOUNDARY_POLICY_BASELINE = { clientHookRawFetches: 0, clientSameOriginApiFetches: 0, doubleCasts: 8, - rawJsonReads: 21, + rawJsonReads: 8, untypedResponses: 0, annotationsMissingReason: 0, } as const From 577a7a2ef1f8b1ebd5810d26cd6b3999f3c87971 Mon Sep 17 00:00:00 2001 From: Waleed Date: Thu, 2 Jul 2026 13:00:29 -0700 Subject: [PATCH 27/28] fix(landing): inset careers row hover, drop trusted-by section, gate contact submit (#5380) --- apps/sim/app/(landing)/careers/careers.tsx | 3 --- .../careers/components/job-board/job-groups.tsx | 4 ++-- .../contact/components/contact-form/contact-form.tsx | 10 +++++++++- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/apps/sim/app/(landing)/careers/careers.tsx b/apps/sim/app/(landing)/careers/careers.tsx index d1c53b5f4e6..b8c8de250d3 100644 --- a/apps/sim/app/(landing)/careers/careers.tsx +++ b/apps/sim/app/(landing)/careers/careers.tsx @@ -9,7 +9,6 @@ import { JobGroups, } from '@/app/(landing)/careers/components/job-board' import { careersSearchParamsCache } from '@/app/(landing)/careers/search-params' -import { TrustedBy } from '@/app/(landing)/components/trusted-by' interface CareersProps { searchParams: Promise @@ -87,8 +86,6 @@ export default async function Careers({ searchParams }: CareersProps) { > - - ) diff --git a/apps/sim/app/(landing)/careers/components/job-board/job-groups.tsx b/apps/sim/app/(landing)/careers/components/job-board/job-groups.tsx index 5f0be48b263..8962a59843d 100644 --- a/apps/sim/app/(landing)/careers/components/job-board/job-groups.tsx +++ b/apps/sim/app/(landing)/careers/components/job-board/job-groups.tsx @@ -83,8 +83,8 @@ function JobRow({ posting }: JobRowProps) { target='_blank' rel='noopener noreferrer' className={cn( - 'group flex items-center justify-between gap-6 border-[var(--border)] border-t py-5', - 'transition-colors hover:bg-[var(--surface-hover)]' + '-mx-3 group flex items-center justify-between gap-6 rounded-lg border-[var(--border)]', + 'border-t px-3 py-5 transition-colors hover:bg-[var(--surface-hover)]' )} >
diff --git a/apps/sim/app/(landing)/contact/components/contact-form/contact-form.tsx b/apps/sim/app/(landing)/contact/components/contact-form/contact-form.tsx index 8a18a8094c9..28f8d516ee5 100644 --- a/apps/sim/app/(landing)/contact/components/contact-form/contact-form.tsx +++ b/apps/sim/app/(landing)/contact/components/contact-form/contact-form.tsx @@ -12,6 +12,7 @@ import { } from '@/lib/api/contracts/contact' import { flattenFieldErrors } from '@/lib/api/contracts/primitives' import { getEnv } from '@/lib/core/config/env' +import { quickValidateEmail } from '@/lib/messaging/email/validation' import { captureClientEvent } from '@/lib/posthog/client' import { useSubmitContact } from '@/hooks/queries/contact' @@ -175,6 +176,13 @@ export function ContactForm() { ) } + const canSubmit = + quickValidateEmail(form.email.trim()).isValid && + form.name.trim().length > 0 && + form.topic.length > 0 && + form.subject.trim().length > 0 && + form.message.trim().length > 0 + const isBusy = contactMutation.isPending || isSubmitting const submitError = contactMutation.isError @@ -336,7 +344,7 @@ export function ContactForm() { variant='primary' flush fullWidth - disabled={isBusy} + disabled={isBusy || !canSubmit} className='mt-1 justify-center [&>span]:flex-none' > {isBusy ? 'Sending…' : 'Send message'} From 4085a1ea0741813fc145a3a6cdc30d1a8e6d5bab Mon Sep 17 00:00:00 2001 From: Waleed Date: Thu, 2 Jul 2026 15:11:13 -0700 Subject: [PATCH 28/28] fix(resume): fix click-blocking footer overlay + hardening on HITL resume page (#5381) * fix(resume): fix click-blocking footer overlay + hardening on HITL resume page - SupportFooter rendered position:absolute with no space reserved for it in InterfacesShell/AuthShell/file-share auth gate, silently overlapping and eating clicks on whatever content ended up in its ~50px footprint (on the resume page, the Resume Execution button itself) - Applied the same fix to /invite and /f/[token], which shared the identical absolute-positioning pattern - handleResume no longer fails silently on validation errors or unexpected exceptions - getPauseContextDetail no longer duplicates a pause point's full response payload in the same API response - Oversized HITL display-data values are now truncated in the resume page preview instead of rendering unbounded * fix(resume): use consistent string for truncation-notice length The object-branch truncation notice sliced from the prettified JSON.stringify(value, null, 2) but reported the total against the compact JSON.stringify(value).length, which could show a nonsensical "5,000 of 4,800 characters shown" when the compact form is shorter than the prettified slice. Derive both from the same string. * fix(resume): show em dash for empty-string display data values renderStructuredValuePreview only treated null/undefined as empty; an empty string fell through to the plain-text branch and rendered as a bordered, padded, contentless pill that reads as a stray UI element (e.g. an unstyled toggle) in the Display Data table. --- .../app/(auth)/components/support-footer.tsx | 14 ++- .../interfaces-shell/interfaces-shell.tsx | 2 +- .../[executionId]/resume-page-client.tsx | 113 +++++++++++------ .../app/f/[token]/public-file-auth-shell.tsx | 2 +- apps/sim/app/invite/components/layout.tsx | 2 +- .../human-in-the-loop-manager.test.ts | 119 ++++++++++++++++++ .../executor/human-in-the-loop-manager.ts | 12 +- 7 files changed, 218 insertions(+), 46 deletions(-) diff --git a/apps/sim/app/(auth)/components/support-footer.tsx b/apps/sim/app/(auth)/components/support-footer.tsx index 29ea677ea1b..6b21046388d 100644 --- a/apps/sim/app/(auth)/components/support-footer.tsx +++ b/apps/sim/app/(auth)/components/support-footer.tsx @@ -4,7 +4,16 @@ import { cn } from '@sim/emcn' import { useBrandConfig } from '@/ee/whitelabeling' export interface SupportFooterProps { - position?: 'fixed' | 'absolute' + /** + * `fixed`/`absolute` pin the footer over the page (short, centered forms + * only — content must never render underneath it). `static` renders it in + * normal document flow after the content, which is required for pages with + * unbounded content height (e.g. the resume gate's HITL form): an + * absolutely-positioned footer with no reserved space is not pushed down by + * flow content, so it silently overlaps and eats clicks on whatever content + * ends up in its footprint. + */ + position?: 'fixed' | 'absolute' | 'static' } export function SupportFooter({ position = 'fixed' }: SupportFooterProps) { @@ -13,7 +22,8 @@ export function SupportFooter({ position = 'fixed' }: SupportFooterProps) { return (
diff --git a/apps/sim/app/(interfaces)/components/interfaces-shell/interfaces-shell.tsx b/apps/sim/app/(interfaces)/components/interfaces-shell/interfaces-shell.tsx index 7f41bd212d7..c3ea3a614c3 100644 --- a/apps/sim/app/(interfaces)/components/interfaces-shell/interfaces-shell.tsx +++ b/apps/sim/app/(interfaces)/components/interfaces-shell/interfaces-shell.tsx @@ -18,5 +18,5 @@ interface InterfacesShellProps { } export function InterfacesShell({ children }: InterfacesShellProps) { - return }>{children} + return }>{children} } diff --git a/apps/sim/app/(interfaces)/resume/[workflowId]/[executionId]/resume-page-client.tsx b/apps/sim/app/(interfaces)/resume/[workflowId]/[executionId]/resume-page-client.tsx index f148c358fd5..e6bf318c428 100644 --- a/apps/sim/app/(interfaces)/resume/[workflowId]/[executionId]/resume-page-client.tsx +++ b/apps/sim/app/(interfaces)/resume/[workflowId]/[executionId]/resume-page-client.tsx @@ -103,28 +103,51 @@ function getBlockNameFromSnapshot( } } +const DISPLAY_VALUE_PREVIEW_MAX_CHARS = 5000 + +function truncateForPreview(text: string): { text: string; truncated: boolean } { + if (text.length <= DISPLAY_VALUE_PREVIEW_MAX_CHARS) return { text, truncated: false } + return { text: text.slice(0, DISPLAY_VALUE_PREVIEW_MAX_CHARS), truncated: true } +} + function renderStructuredValuePreview(value: unknown) { - if (value === null || value === undefined) { + if (value === null || value === undefined || value === '') { return } if (typeof value === 'object') { + const prettyPrinted = JSON.stringify(value, null, 2) + const { text, truncated } = truncateForPreview(prettyPrinted) return (
+ {truncated && ( +

+ Value truncated for preview ({DISPLAY_VALUE_PREVIEW_MAX_CHARS.toLocaleString()} of{' '} + {prettyPrinted.length.toLocaleString()} characters shown). +

+ )}
) } - const stringValue = String(value) + const { text: stringValue, truncated } = truncateForPreview(String(value)) return ( -
- {stringValue} +
+
+ {truncated ? `${stringValue}…` : stringValue} +
+ {truncated && ( +

+ Value truncated for preview ({DISPLAY_VALUE_PREVIEW_MAX_CHARS.toLocaleString()} of{' '} + {String(value).length.toLocaleString()} characters shown). +

+ )}
) } @@ -516,50 +539,60 @@ export default function ResumeExecutionPage({ const handleResume = useCallback( async () => { - if (!selectedContextId || !selectedDetail) return + if (!selectedContextId || !selectedDetail) { + setError('No pause point is selected. Refresh and try again.') + return + } setLoadingAction(true) setError(null) setMessage(null) let resumePayload: any - if (isHumanMode && hasInputFormat) { - const errors: Record = {} - const submission: Record = {} - for (const field of inputFormatFields) { - const rawValue = formValues[field.name] ?? '' - const hasValue = - field.type === 'boolean' - ? rawValue === 'true' || rawValue === 'false' - : rawValue.trim().length > 0 && rawValue !== '__unset__' - if (!hasValue || rawValue === '__unset__') { - if (field.required) errors[field.name] = 'This field is required.' - continue - } - const { value, error: parseError } = parseFormValue(field, rawValue) - if (parseError) { - errors[field.name] = parseError - continue + try { + if (isHumanMode && hasInputFormat) { + const errors: Record = {} + const submission: Record = {} + for (const field of inputFormatFields) { + const rawValue = formValues[field.name] ?? '' + const hasValue = + field.type === 'boolean' + ? rawValue === 'true' || rawValue === 'false' + : rawValue.trim().length > 0 && rawValue !== '__unset__' + if (!hasValue || rawValue === '__unset__') { + if (field.required) errors[field.name] = 'This field is required.' + continue + } + const { value, error: parseError } = parseFormValue(field, rawValue) + if (parseError) { + errors[field.name] = parseError + continue + } + if (value !== undefined) submission[field.name] = value } - if (value !== undefined) submission[field.name] = value - } - if (Object.keys(errors).length > 0) { - setFormErrors(errors) - setLoadingAction(false) - return - } - setFormErrors({}) - resumePayload = { submission } - } else { - let parsedInput: any - if (resumeInput && resumeInput.trim().length > 0) { - try { - parsedInput = JSON.parse(resumeInput) - } catch { - setError('Resume input must be valid JSON.') + if (Object.keys(errors).length > 0) { + setFormErrors(errors) + setError('Fix the highlighted fields before resuming.') setLoadingAction(false) return } + setFormErrors({}) + resumePayload = { submission } + } else { + let parsedInput: any + if (resumeInput && resumeInput.trim().length > 0) { + try { + parsedInput = JSON.parse(resumeInput) + } catch { + setError('Resume input must be valid JSON.') + setLoadingAction(false) + return + } + } + resumePayload = parsedInput } - resumePayload = parsedInput + } catch (err: any) { + setError(err?.message || 'Failed to prepare resume payload.') + setLoadingAction(false) + return } try { const { ok, payload } = await resumeMutation.mutateAsync({ diff --git a/apps/sim/app/f/[token]/public-file-auth-shell.tsx b/apps/sim/app/f/[token]/public-file-auth-shell.tsx index bc4e18c3f9d..5adb3ae8fd1 100644 --- a/apps/sim/app/f/[token]/public-file-auth-shell.tsx +++ b/apps/sim/app/f/[token]/public-file-auth-shell.tsx @@ -15,7 +15,7 @@ interface PublicFileAuthShellProps { */ export function PublicFileAuthShell({ title, subtitle, children }: PublicFileAuthShellProps) { return ( - }> + }>

diff --git a/apps/sim/app/invite/components/layout.tsx b/apps/sim/app/invite/components/layout.tsx index 8f7c0bb2fa0..614e7c69356 100644 --- a/apps/sim/app/invite/components/layout.tsx +++ b/apps/sim/app/invite/components/layout.tsx @@ -10,5 +10,5 @@ interface InviteLayoutProps { * so the invite-to-workspace flow is visually aligned with the rest of auth. */ export default function InviteLayout({ children }: InviteLayoutProps) { - return }>{children} + return }>{children} } diff --git a/apps/sim/lib/workflows/executor/human-in-the-loop-manager.test.ts b/apps/sim/lib/workflows/executor/human-in-the-loop-manager.test.ts index 15de7f9f066..1c2d7cfed18 100644 --- a/apps/sim/lib/workflows/executor/human-in-the-loop-manager.test.ts +++ b/apps/sim/lib/workflows/executor/human-in-the-loop-manager.test.ts @@ -152,6 +152,125 @@ describe('updateResumeOutputInAggregationBuffers', () => { }) }) +describe('PauseResumeManager.getPauseContextDetail', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it('does not duplicate a pause point large response payload between pausePoint and execution.pausePoints', async () => { + const largeDisplayValue = 'x'.repeat(50_000) + + const row = { + id: 'paused-exec-1', + workflowId: 'workflow-1', + executionId: 'execution-1', + status: 'paused', + pausedAt: null, + updatedAt: null, + expiresAt: null, + metadata: {}, + executionSnapshot: { triggerIds: [] }, + pausePoints: { + 'ctx-1': { + contextId: 'ctx-1', + blockId: 'hitl-1', + resumeStatus: 'paused', + snapshotReady: true, + pauseKind: 'human', + registeredAt: '2026-07-02T00:00:00.000Z', + response: { + data: { + operation: 'human', + inputFormat: [{ id: 'field_0', name: 'approved', type: 'boolean', required: false }], + submission: null, + responseStructure: [ + { name: 'ai_analysis', type: 'string', value: largeDisplayValue }, + ], + }, + status: 200, + headers: {}, + }, + }, + 'ctx-2': { + contextId: 'ctx-2', + blockId: 'hitl-2', + resumeStatus: 'paused', + snapshotReady: true, + pauseKind: 'human', + registeredAt: '2026-07-02T00:00:00.000Z', + response: { + data: { operation: 'human', inputFormat: [], submission: null }, + status: 200, + headers: {}, + }, + }, + }, + } + + dbChainMockFns.limit.mockResolvedValueOnce([row]) + dbChainMockFns.orderBy.mockResolvedValueOnce([]) + + const detail = await PauseResumeManager.getPauseContextDetail({ + workflowId: 'workflow-1', + executionId: 'execution-1', + contextId: 'ctx-1', + }) + + expect(detail).not.toBeNull() + // The requested pause point keeps its full response payload. + expect(detail!.pausePoint.response.data.responseStructure[0].value).toBe(largeDisplayValue) + expect(detail!.pausePoint.contextId).toBe('ctx-1') + + // `execution.pausePoints` must not re-embed the (potentially large) + // response payload — it's already available via `pausePoint` above. + for (const point of detail!.execution.pausePoints) { + expect(point.response?.data).toBeUndefined() + } + // Non-payload fields are still present on the execution's pause points. + expect(detail!.execution.pausePoints.map((p) => p.contextId).sort()).toEqual(['ctx-1', 'ctx-2']) + expect(detail!.execution.pausePoints.find((p) => p.contextId === 'ctx-1')?.resumeStatus).toBe( + 'paused' + ) + }) + + it('returns null when the pause context no longer exists', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([ + { + id: 'paused-exec-1', + workflowId: 'workflow-1', + executionId: 'execution-1', + status: 'paused', + pausedAt: null, + updatedAt: null, + expiresAt: null, + metadata: {}, + executionSnapshot: { triggerIds: [] }, + pausePoints: { + 'ctx-1': { + contextId: 'ctx-1', + blockId: 'hitl-1', + resumeStatus: 'paused', + snapshotReady: true, + pauseKind: 'human', + registeredAt: '2026-07-02T00:00:00.000Z', + response: { data: { operation: 'human' }, status: 200, headers: {} }, + }, + }, + }, + ]) + dbChainMockFns.orderBy.mockResolvedValueOnce([]) + + const detail = await PauseResumeManager.getPauseContextDetail({ + workflowId: 'workflow-1', + executionId: 'execution-1', + contextId: 'missing-ctx', + }) + + expect(detail).toBeNull() + }) +}) + describe('PauseResumeManager.persistPauseResult metadata merge on re-pause', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts b/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts index cc678b88755..a5a5a40d870 100644 --- a/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts +++ b/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts @@ -2048,8 +2048,18 @@ export class PauseResumeManager { entry.contextId === contextId && (entry.status === 'claimed' || entry.status === 'pending') ) + // The selected pause point's full `response.data` is already returned via + // `pausePoint` below; strip it from `execution.pausePoints` so a large + // HITL display payload isn't duplicated in full within the same response. + const execution: PausedExecutionDetail = { + ...detail, + pausePoints: detail.pausePoints.map((point) => + point.response ? { ...point, response: { ...point.response, data: undefined } } : point + ), + } + return { - execution: detail, + execution, pausePoint, queue: detail.queue, activeResumeEntry,