From 732e4ec975642446485ca22dea1a99da96c57ea1 Mon Sep 17 00:00:00 2001 From: Waleed Date: Sun, 5 Jul 2026 10:59:16 -0700 Subject: [PATCH 01/35] fix(custom-blocks): dedupe redundant workspace lookup in POST admin check (#5429) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit hasWorkspaceAdminAccess + a separate getWorkspaceWithOwner call each independently re-fetched the workspace row for the same (userId, workspaceId) pair. Consolidated into a single checkWorkspaceAccess call, matching the pattern the GET handler in this same file already uses. access.canAdmin is logically identical to hasWorkspaceAdminAccess's result (admin is the top PERMISSION_RANK, nothing else satisfies it) — no behavior change, one fewer DB round-trip per publish request. --- apps/sim/app/api/custom-blocks/route.ts | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/apps/sim/app/api/custom-blocks/route.ts b/apps/sim/app/api/custom-blocks/route.ts index c244ae88ad4..069751f97d4 100644 --- a/apps/sim/app/api/custom-blocks/route.ts +++ b/apps/sim/app/api/custom-blocks/route.ts @@ -17,11 +17,7 @@ import { listCustomBlocksWithInputs, publishCustomBlock, } from '@/lib/workflows/custom-blocks/operations' -import { - checkWorkspaceAccess, - getWorkspaceWithOwner, - hasWorkspaceAdminAccess, -} from '@/lib/workspaces/permissions/utils' +import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' const logger = createLogger('CustomBlocksAPI') @@ -84,18 +80,18 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const userId = session.user.id const { workspaceId, workflowId, name, description, iconUrl, exposedOutputs } = parsed.data.body - if (!(await hasWorkspaceAdminAccess(userId, workspaceId))) { + const access = await checkWorkspaceAccess(workspaceId, userId) + if (!access.canAdmin) { return NextResponse.json({ error: 'Admin permissions required' }, { status: 403 }) } - const ws = await getWorkspaceWithOwner(workspaceId) - if (!ws?.organizationId) { + const organizationId = access.workspace?.organizationId + if (!organizationId) { return NextResponse.json( { error: 'Publishing a block requires the workspace to belong to an organization' }, { status: 400 } ) } - const organizationId = ws.organizationId if (!(await isFeatureEnabled('deploy-as-block', { userId, orgId: organizationId }))) { return NextResponse.json({ error: 'Deploy as block is not enabled' }, { status: 403 }) From 20e86543fb3de021b8f8bb85707c148dda71f068 Mon Sep 17 00:00:00 2001 From: Waleed Date: Sun, 5 Jul 2026 10:59:26 -0700 Subject: [PATCH 02/35] refactor(permissions): make hasWorkspaceAdminAccess and getUserEntityPermissions thin wrappers of checkWorkspaceAccess (#5430) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit hasWorkspaceAdminAccess and getUserEntityPermissions('workspace', ...) each independently re-implemented the same getWorkspaceWithOwner + getEffectiveWorkspacePermission fetch that checkWorkspaceAccess already does — this is exactly the duplication that let the custom-blocks POST handler drift into two separate DB round-trips for one permission resolution (fixed separately). Centralizing all three onto one implementation means a future logic change or bug fix only has to happen once, and any future caller that (accidentally) calls two of these functions together is now at least drawing from one consistent definition of "effective permission" instead of two that could diverge. Adds a `permission: PermissionType | null` field to the WorkspaceAccess return shape so getUserEntityPermissions doesn't need a second independent getEffectiveWorkspacePermission call to get the raw value. No behavior change: verified analytically (canAdmin is exactly permission === 'admin' per PERMISSION_RANK) and confirmed by an independent security review focused on argument-order and privilege-escalation risks in this kind of refactor. 616 existing tests across lib/workspaces, lib/credentials, lib/invitations, lib/copilot/vfs, and the affected API routes pass unmodified (plus 2 test assertions updated for the new additive field). --- .../lib/workspaces/permissions/utils.test.ts | 2 ++ apps/sim/lib/workspaces/permissions/utils.ts | 27 +++++++++---------- 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/apps/sim/lib/workspaces/permissions/utils.test.ts b/apps/sim/lib/workspaces/permissions/utils.test.ts index 503be8036aa..f54ce9d1044 100644 --- a/apps/sim/lib/workspaces/permissions/utils.test.ts +++ b/apps/sim/lib/workspaces/permissions/utils.test.ts @@ -772,6 +772,7 @@ describe('Permission Utils', () => { canWrite: false, canAdmin: false, workspace: null, + permission: null, }) }) @@ -793,6 +794,7 @@ describe('Permission Utils', () => { canWrite: true, canAdmin: true, workspace: { id: 'workspace123', ownerId: 'user123' }, + permission: 'admin', }) }) diff --git a/apps/sim/lib/workspaces/permissions/utils.ts b/apps/sim/lib/workspaces/permissions/utils.ts index b815a69be86..3ec92b4c131 100644 --- a/apps/sim/lib/workspaces/permissions/utils.ts +++ b/apps/sim/lib/workspaces/permissions/utils.ts @@ -33,6 +33,8 @@ export interface WorkspaceAccess { canWrite: boolean canAdmin: boolean workspace: WorkspaceWithOwner | null + /** The viewer's raw effective permission, or `null` when the workspace doesn't exist or they have none. */ + permission: PermissionType | null } /** @@ -143,7 +145,14 @@ export async function checkWorkspaceAccess( const ws = await getWorkspaceWithOwner(workspaceId) if (!ws) { - return { exists: false, hasAccess: false, canWrite: false, canAdmin: false, workspace: null } + return { + exists: false, + hasAccess: false, + canWrite: false, + canAdmin: false, + workspace: null, + permission: null, + } } const permission = await getEffectiveWorkspacePermission(userId, ws) @@ -151,7 +160,7 @@ export async function checkWorkspaceAccess( const canWrite = permissionSatisfies(permission, 'write') const canAdmin = permissionSatisfies(permission, 'admin') - return { exists: true, hasAccess, canWrite, canAdmin, workspace: ws } + return { exists: true, hasAccess, canWrite, canAdmin, workspace: ws, permission } } /** @@ -200,11 +209,7 @@ export async function getUserEntityPermissions( entityId: string ): Promise { if (entityType === 'workspace') { - const ws = await getWorkspaceWithOwner(entityId) - if (!ws) { - return null - } - return getEffectiveWorkspacePermission(userId, ws) + return (await checkWorkspaceAccess(entityId, userId)).permission } const result = await db @@ -387,13 +392,7 @@ export async function hasWorkspaceAdminAccess( userId: string, workspaceId: string ): Promise { - const ws = await getWorkspaceWithOwner(workspaceId) - - if (!ws) { - return false - } - - return (await getEffectiveWorkspacePermission(userId, ws)) === 'admin' + return (await checkWorkspaceAccess(workspaceId, userId)).canAdmin } /** From 487166f41439fec226ccb4ada49d1ec2ed170240 Mon Sep 17 00:00:00 2001 From: Waleed Date: Sun, 5 Jul 2026 10:59:38 -0700 Subject: [PATCH 03/35] refactor(react-query): hoist every staleTime into a named exported constant (#5427) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(react-query): hoist every staleTime into a named exported constant Adds a repo-wide convention (documented in .claude/rules/sim-queries.md, CLAUDE.md, and the react-query-best-practices skill/command) that every staleTime value must come from a named exported constant instead of an inline numeric literal. This prevents a server-side prefetch and its client hook from independently duplicating the same duration and drifting out of sync, which Greptile caught on PR #5426. Applies the convention across all of hooks/queries/** and their prefetch.ts consumers. * refactor(react-query): use FOLDER_LIST_STALE_TIME in folders.ts hooks useFolders and useFolderMap still used inline 60 * 1000 despite folders.ts already exporting FOLDER_LIST_STALE_TIME — the same prefetch-drift gap this refactor closes everywhere else. Same value, no behavior change. --- .../react-query-best-practices/SKILL.md | 2 +- .../commands/react-query-best-practices.md | 2 +- .claude/rules/sim-queries.md | 6 ++- .../commands/react-query-best-practices.md | 2 +- CLAUDE.md | 8 ++-- .../workspace/[workspaceId]/files/prefetch.ts | 14 ++++-- .../workspace/[workspaceId]/home/prefetch.ts | 7 ++- .../[workspaceId]/knowledge/prefetch.ts | 4 +- .../settings/[section]/prefetch.ts | 20 ++++++--- .../[workspaceId]/tables/prefetch.ts | 4 +- apps/sim/hooks/queries/admin-users.ts | 4 +- apps/sim/hooks/queries/allowed-providers.ts | 4 +- apps/sim/hooks/queries/api-keys.ts | 4 +- apps/sim/hooks/queries/background-work.ts | 4 +- apps/sim/hooks/queries/byok-keys.ts | 4 +- apps/sim/hooks/queries/chats.ts | 4 +- apps/sim/hooks/queries/copilot-chats.ts | 4 +- apps/sim/hooks/queries/copilot-keys.ts | 4 +- apps/sim/hooks/queries/credential-sets.ts | 19 +++++--- apps/sim/hooks/queries/credentials.ts | 12 ++++-- apps/sim/hooks/queries/custom-blocks.ts | 4 +- apps/sim/hooks/queries/custom-tools.ts | 4 +- apps/sim/hooks/queries/deployments.ts | 18 +++++--- apps/sim/hooks/queries/environment.ts | 7 ++- apps/sim/hooks/queries/folders.ts | 4 +- apps/sim/hooks/queries/general-settings.ts | 6 ++- apps/sim/hooks/queries/github-stars.ts | 4 +- apps/sim/hooks/queries/inbox.ts | 10 +++-- apps/sim/hooks/queries/invitations.ts | 4 +- apps/sim/hooks/queries/kb/connectors.ts | 10 +++-- apps/sim/hooks/queries/kb/knowledge.ts | 28 ++++++++---- apps/sim/hooks/queries/logs.ts | 18 +++++--- apps/sim/hooks/queries/mcp.ts | 13 ++++-- apps/sim/hooks/queries/mothership-admin.ts | 16 ++++--- apps/sim/hooks/queries/mothership-chats.ts | 3 +- .../hooks/queries/oauth/oauth-connections.ts | 7 ++- .../hooks/queries/oauth/oauth-credentials.ts | 7 ++- apps/sim/hooks/queries/organization.ts | 25 +++++++---- apps/sim/hooks/queries/providers.ts | 4 +- apps/sim/hooks/queries/public-shares.ts | 4 +- apps/sim/hooks/queries/resume-execution.ts | 4 +- apps/sim/hooks/queries/schedules.ts | 10 +++-- apps/sim/hooks/queries/session.ts | 4 +- apps/sim/hooks/queries/skills.ts | 4 +- apps/sim/hooks/queries/subscription.ts | 16 ++++--- apps/sim/hooks/queries/tables.ts | 25 +++++++---- apps/sim/hooks/queries/unsubscribe.ts | 4 +- apps/sim/hooks/queries/usage-logs.ts | 7 ++- apps/sim/hooks/queries/user-profile.ts | 4 +- apps/sim/hooks/queries/utils/table-keys.ts | 2 + apps/sim/hooks/queries/voice-settings.ts | 4 +- apps/sim/hooks/queries/webhooks.ts | 4 +- .../sim/hooks/queries/workflow-mcp-servers.ts | 13 ++++-- .../hooks/queries/workflow-search-replace.ts | 43 +++++++++++++------ apps/sim/hooks/queries/workflows.ts | 9 ++-- .../hooks/queries/workspace-file-folders.ts | 4 +- .../sim/hooks/queries/workspace-file-table.ts | 4 +- apps/sim/hooks/queries/workspace-files.ts | 15 ++++--- apps/sim/hooks/queries/workspace-fork.ts | 13 ++++-- apps/sim/hooks/queries/workspace.ts | 22 ++++++---- 60 files changed, 370 insertions(+), 169 deletions(-) diff --git a/.agents/skills/react-query-best-practices/SKILL.md b/.agents/skills/react-query-best-practices/SKILL.md index fd8da063632..8a595d1b4dc 100644 --- a/.agents/skills/react-query-best-practices/SKILL.md +++ b/.agents/skills/react-query-best-practices/SKILL.md @@ -31,7 +31,7 @@ Read these before analyzing: ### Query hooks - Every `queryFn` must forward `signal` for request cancellation -- Every query must have an explicit `staleTime` (default 0 is almost never correct) +- Every query must have an explicit `staleTime` (default 0 is almost never correct), assigned from a named exported constant — never an inline numeric literal. A server-side prefetch hydrating the same query key must import and reuse that constant instead of restating the number - `keepPreviousData` / `placeholderData` only on variable-key queries (where params change), never on static keys - Use `enabled` to prevent queries from running without required params diff --git a/.claude/commands/react-query-best-practices.md b/.claude/commands/react-query-best-practices.md index 22bdc320dde..0fc450958bf 100644 --- a/.claude/commands/react-query-best-practices.md +++ b/.claude/commands/react-query-best-practices.md @@ -31,7 +31,7 @@ Read these before analyzing: ### Query hooks - Every `queryFn` must forward `signal` for request cancellation -- Every query must have an explicit `staleTime` (default 0 is almost never correct) +- Every query must have an explicit `staleTime` (default 0 is almost never correct), assigned from a named exported constant — never an inline numeric literal. A server-side prefetch hydrating the same query key must import and reuse that constant instead of restating the number - `keepPreviousData` / `placeholderData` only on variable-key queries (where params change), never on static keys - Use `enabled` to prevent queries from running without required params diff --git a/.claude/rules/sim-queries.md b/.claude/rules/sim-queries.md index d1db9437ff0..4eccbe2d7ef 100644 --- a/.claude/rules/sim-queries.md +++ b/.claude/rules/sim-queries.md @@ -50,7 +50,7 @@ The `'use client'` hook module then imports these back for its hooks. **Never** ## Query Hook - Every `queryFn` must destructure and forward `signal` for request cancellation -- Every query must have an explicit `staleTime` +- Every query must have an explicit `staleTime`, assigned from a named exported constant (`ENTITY_LIST_STALE_TIME`), never an inline numeric literal. A server-side prefetch (`prefetch.ts`) hydrating the same query key must import and reuse that constant, not restate the number — this is what keeps a prefetched cache entry from going stale out of sync with the client hook that reads it - Use `keepPreviousData` only on variable-key queries (where params change), never on static keys - Same-origin JSON calls must go through `requestJson(contract, ...)` from `@/lib/api/client/request` against the contract in `@/lib/api/contracts/**` @@ -58,6 +58,8 @@ The `'use client'` hook module then imports these back for its hooks. **Never** import { requestJson } from '@/lib/api/client/request' import { listEntitiesContract, type EntityList } from '@/lib/api/contracts/entities' +export const ENTITY_LIST_STALE_TIME = 60 * 1000 + async function fetchEntities(workspaceId: string, signal?: AbortSignal): Promise { const data = await requestJson(listEntitiesContract, { query: { workspaceId }, @@ -71,7 +73,7 @@ export function useEntityList(workspaceId?: string, options?: { enabled?: boolea queryKey: entityKeys.list(workspaceId), queryFn: ({ signal }) => fetchEntities(workspaceId as string, signal), enabled: Boolean(workspaceId) && (options?.enabled ?? true), - staleTime: 60 * 1000, + staleTime: ENTITY_LIST_STALE_TIME, placeholderData: keepPreviousData, // OK: workspaceId varies }) } diff --git a/.cursor/commands/react-query-best-practices.md b/.cursor/commands/react-query-best-practices.md index 6aced33b7b2..420f52a2c93 100644 --- a/.cursor/commands/react-query-best-practices.md +++ b/.cursor/commands/react-query-best-practices.md @@ -26,7 +26,7 @@ Read these before analyzing: ### Query hooks - Every `queryFn` must forward `signal` for request cancellation -- Every query must have an explicit `staleTime` (default 0 is almost never correct) +- Every query must have an explicit `staleTime` (default 0 is almost never correct), assigned from a named exported constant — never an inline numeric literal. A server-side prefetch hydrating the same query key must import and reuse that constant instead of restating the number - `keepPreviousData` / `placeholderData` only on variable-key queries (where params change), never on static keys - Use `enabled` to prevent queries from running without required params diff --git a/CLAUDE.md b/CLAUDE.md index df1f7fb9826..a0632e40ad7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -290,6 +290,8 @@ import { keepPreviousData, useQuery } from '@tanstack/react-query' import { requestJson } from '@/lib/api/client/request' import { listEntitiesContract, type EntityList } from '@/lib/api/contracts/entities' +export const ENTITY_LIST_STALE_TIME = 60 * 1000 + async function fetchEntities(workspaceId: string, signal?: AbortSignal): Promise { const data = await requestJson(listEntitiesContract, { query: { workspaceId }, @@ -303,7 +305,7 @@ export function useEntityList(workspaceId?: string) { queryKey: entityKeys.list(workspaceId), queryFn: ({ signal }) => fetchEntities(workspaceId as string, signal), enabled: Boolean(workspaceId), - staleTime: 60 * 1000, + staleTime: ENTITY_LIST_STALE_TIME, placeholderData: keepPreviousData, }) } @@ -326,7 +328,7 @@ export const entityKeys = { ### Query Hooks - Every `queryFn` must forward `signal` for request cancellation -- Every query must have an explicit `staleTime` +- Every query must have an explicit `staleTime`, assigned from a named exported constant, never an inline numeric literal — a server-side prefetch hydrating the same query key must import and reuse that constant so the two never drift out of sync - Use `keepPreviousData` only on variable-key queries (where params change), never on static keys ```typescript @@ -335,7 +337,7 @@ export function useEntityList(workspaceId?: string) { queryKey: entityKeys.list(workspaceId), queryFn: ({ signal }) => fetchEntities(workspaceId as string, signal), enabled: Boolean(workspaceId), - staleTime: 60 * 1000, + staleTime: ENTITY_LIST_STALE_TIME, placeholderData: keepPreviousData, // OK: workspaceId varies }) } diff --git a/apps/sim/app/workspace/[workspaceId]/files/prefetch.ts b/apps/sim/app/workspace/[workspaceId]/files/prefetch.ts index 8780aa537fa..0fb0de96156 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/prefetch.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/prefetch.ts @@ -2,8 +2,14 @@ import type { QueryClient } from '@tanstack/react-query' import type { WorkspaceFileFolderApi } from '@/lib/api/contracts/workspace-file-folders' import type { ListWorkspaceFilesResponse } from '@/lib/api/contracts/workspace-files' import { prefetchInternalJson } from '@/app/workspace/[workspaceId]/lib/prefetch-internal-fetch' -import { workspaceFileFolderKeys } from '@/hooks/queries/workspace-file-folders' -import { workspaceFilesKeys } from '@/hooks/queries/workspace-files' +import { + WORKSPACE_FILE_FOLDERS_STALE_TIME, + workspaceFileFolderKeys, +} from '@/hooks/queries/workspace-file-folders' +import { + WORKSPACE_FILES_LIST_STALE_TIME, + workspaceFilesKeys, +} from '@/hooks/queries/workspace-files' /** * Prefetches the Files browser's two lists — workspace files and file folders — @@ -27,7 +33,7 @@ export async function prefetchFilesBrowser( ) return data.success ? data.files : [] }, - staleTime: 30 * 1000, + staleTime: WORKSPACE_FILES_LIST_STALE_TIME, }), queryClient.prefetchQuery({ queryKey: workspaceFileFolderKeys.list(workspaceId, 'active'), @@ -37,7 +43,7 @@ export async function prefetchFilesBrowser( ) return data.folders ?? [] }, - staleTime: 30 * 1000, + staleTime: WORKSPACE_FILE_FOLDERS_STALE_TIME, }), ]) } diff --git a/apps/sim/app/workspace/[workspaceId]/home/prefetch.ts b/apps/sim/app/workspace/[workspaceId]/home/prefetch.ts index 956a9e95555..adf7a42630b 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/prefetch.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/prefetch.ts @@ -4,7 +4,10 @@ import type { ListWorkspaceFilesResponse } from '@/lib/api/contracts/workspace-f import { prefetchInternalJson } from '@/app/workspace/[workspaceId]/lib/prefetch-internal-fetch' import { FOLDER_LIST_STALE_TIME, mapFolder } from '@/hooks/queries/folders' import { folderKeys } from '@/hooks/queries/utils/folder-keys' -import { workspaceFilesKeys } from '@/hooks/queries/workspace-files' +import { + WORKSPACE_FILES_LIST_STALE_TIME, + workspaceFilesKeys, +} from '@/hooks/queries/workspace-files' /** * Prefetches the home page's secondary lists — folders and workspace files — @@ -42,7 +45,7 @@ export async function prefetchHomeLists( ) return data.success ? data.files : [] }, - staleTime: 30 * 1000, + staleTime: WORKSPACE_FILES_LIST_STALE_TIME, }), ]) } diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/prefetch.ts b/apps/sim/app/workspace/[workspaceId]/knowledge/prefetch.ts index 0e8e5578840..11b61bb07b9 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/prefetch.ts +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/prefetch.ts @@ -1,7 +1,7 @@ import type { QueryClient } from '@tanstack/react-query' import type { KnowledgeBaseData } from '@/lib/api/contracts/knowledge' import { prefetchInternalJson } from '@/app/workspace/[workspaceId]/lib/prefetch-internal-fetch' -import { knowledgeKeys } from '@/hooks/queries/kb/knowledge' +import { KNOWLEDGE_BASE_LIST_STALE_TIME, knowledgeKeys } from '@/hooks/queries/kb/knowledge' /** * Prefetches the workspace's knowledge-bases list under the same query key the @@ -23,6 +23,6 @@ export async function prefetchKnowledgeBases( ) return result.data }, - staleTime: 60 * 1000, + staleTime: KNOWLEDGE_BASE_LIST_STALE_TIME, }) } diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/prefetch.ts b/apps/sim/app/workspace/[workspaceId]/settings/[section]/prefetch.ts index 0df3a17358e..059690a037b 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/prefetch.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/prefetch.ts @@ -3,9 +3,17 @@ import { headers } from 'next/headers' import { getSession } from '@/lib/auth' import { getInternalApiBaseUrl } from '@/lib/core/utils/urls' import { getUserProfile, getUserSettings } from '@/lib/users/queries' -import { generalSettingsKeys, mapGeneralSettingsResponse } from '@/hooks/queries/general-settings' -import { subscriptionKeys } from '@/hooks/queries/subscription' -import { mapUserProfileResponse, userProfileKeys } from '@/hooks/queries/user-profile' +import { + GENERAL_SETTINGS_STALE_TIME, + generalSettingsKeys, + mapGeneralSettingsResponse, +} from '@/hooks/queries/general-settings' +import { SUBSCRIPTION_DATA_STALE_TIME, subscriptionKeys } from '@/hooks/queries/subscription' +import { + mapUserProfileResponse, + USER_PROFILE_STALE_TIME, + userProfileKeys, +} from '@/hooks/queries/user-profile' /** * Prefetch general settings server-side via the shared data layer. @@ -20,7 +28,7 @@ export function prefetchGeneralSettings(queryClient: QueryClient) { const data = await getUserSettings(session?.user?.id ?? null) return mapGeneralSettingsResponse(data) }, - staleTime: 60 * 60 * 1000, + staleTime: GENERAL_SETTINGS_STALE_TIME, }) } @@ -46,7 +54,7 @@ export function prefetchSubscriptionData(queryClient: QueryClient) { if (!response.ok) throw new Error(`Subscription prefetch failed: ${response.status}`) return response.json() }, - staleTime: 5 * 60 * 1000, + staleTime: SUBSCRIPTION_DATA_STALE_TIME, }) } @@ -65,6 +73,6 @@ export function prefetchUserProfile(queryClient: QueryClient) { if (!user) throw new Error('User not found') return mapUserProfileResponse(user) }, - staleTime: 5 * 60 * 1000, + staleTime: USER_PROFILE_STALE_TIME, }) } diff --git a/apps/sim/app/workspace/[workspaceId]/tables/prefetch.ts b/apps/sim/app/workspace/[workspaceId]/tables/prefetch.ts index 8d41a1d6680..f5f9d2a41a8 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/prefetch.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/prefetch.ts @@ -1,7 +1,7 @@ import type { QueryClient } from '@tanstack/react-query' import type { TableDefinition } from '@/lib/table' import { prefetchInternalJson } from '@/app/workspace/[workspaceId]/lib/prefetch-internal-fetch' -import { tableKeys } from '@/hooks/queries/utils/table-keys' +import { TABLE_LIST_STALE_TIME, tableKeys } from '@/hooks/queries/utils/table-keys' /** * Prefetches the workspace's tables list under the same query key the client @@ -21,6 +21,6 @@ export async function prefetchTables(queryClient: QueryClient, workspaceId: stri ) return response.data.tables }, - staleTime: 30 * 1000, + staleTime: TABLE_LIST_STALE_TIME, }) } diff --git a/apps/sim/hooks/queries/admin-users.ts b/apps/sim/hooks/queries/admin-users.ts index ded7ebe7736..658b6ebe009 100644 --- a/apps/sim/hooks/queries/admin-users.ts +++ b/apps/sim/hooks/queries/admin-users.ts @@ -5,6 +5,8 @@ import { client } from '@/lib/auth/auth-client' const logger = createLogger('AdminUsersQuery') +export const ADMIN_USER_LIST_STALE_TIME = 30 * 1000 + export const adminUserKeys = { all: ['adminUsers'] as const, lists: () => [...adminUserKeys.all, 'list'] as const, @@ -84,7 +86,7 @@ export function useAdminUsers(offset: number, limit: number, searchQuery: string queryKey: adminUserKeys.list(offset, limit, searchQuery), queryFn: ({ signal }) => fetchAdminUsers(offset, limit, searchQuery, signal), enabled: searchQuery.length > 0, - staleTime: 30 * 1000, + staleTime: ADMIN_USER_LIST_STALE_TIME, placeholderData: keepPreviousData, }) } diff --git a/apps/sim/hooks/queries/allowed-providers.ts b/apps/sim/hooks/queries/allowed-providers.ts index 218cc9ce7bd..f49de382073 100644 --- a/apps/sim/hooks/queries/allowed-providers.ts +++ b/apps/sim/hooks/queries/allowed-providers.ts @@ -13,6 +13,8 @@ export const allowedProvidersKeys = { blacklisted: () => [...allowedProvidersKeys.all, 'blacklisted'] as const, } +export const BLACKLISTED_PROVIDERS_STALE_TIME = 5 * 60 * 1000 + type BlacklistedProvidersResponse = ContractJsonResponse async function fetchBlacklistedProviders( @@ -32,7 +34,7 @@ export function useBlacklistedProviders({ enabled = true }: { enabled?: boolean return useQuery({ queryKey: allowedProvidersKeys.blacklisted(), queryFn: ({ signal }) => fetchBlacklistedProviders(signal), - staleTime: 5 * 60 * 1000, + staleTime: BLACKLISTED_PROVIDERS_STALE_TIME, enabled, }) } diff --git a/apps/sim/hooks/queries/api-keys.ts b/apps/sim/hooks/queries/api-keys.ts index 0ecdc8ed1e3..a45f6ea79d8 100644 --- a/apps/sim/hooks/queries/api-keys.ts +++ b/apps/sim/hooks/queries/api-keys.ts @@ -27,6 +27,8 @@ export const apiKeysKeys = { combined: (workspaceId: string) => [...apiKeysKeys.combineds(), workspaceId] as const, } +export const API_KEYS_COMBINED_STALE_TIME = 60 * 1000 + type CombinedApiKeysData = { workspaceKeys: ApiKey[] personalKeys: ApiKey[] @@ -67,7 +69,7 @@ export function useApiKeys(workspaceId: string) { queryKey: apiKeysKeys.combined(workspaceId), queryFn: ({ signal }) => fetchApiKeys(workspaceId, signal), enabled: !!workspaceId, - staleTime: 60 * 1000, + staleTime: API_KEYS_COMBINED_STALE_TIME, placeholderData: keepPreviousData, }) } diff --git a/apps/sim/hooks/queries/background-work.ts b/apps/sim/hooks/queries/background-work.ts index e3b2b1a1d76..8af0e33ab68 100644 --- a/apps/sim/hooks/queries/background-work.ts +++ b/apps/sim/hooks/queries/background-work.ts @@ -11,6 +11,8 @@ export const backgroundWorkKeys = { list: (workspaceId?: string) => [...backgroundWorkKeys.lists(), workspaceId ?? ''] as const, } +export const BACKGROUND_WORK_STALE_TIME = 5_000 + async function fetchWorkspaceBackgroundWork( workspaceId: string, signal?: AbortSignal @@ -36,7 +38,7 @@ export function useWorkspaceBackgroundWork(workspaceId?: string) { queryKey: backgroundWorkKeys.list(workspaceId), queryFn: ({ signal }) => fetchWorkspaceBackgroundWork(workspaceId as string, signal), enabled: Boolean(workspaceId), - staleTime: 5_000, + staleTime: BACKGROUND_WORK_STALE_TIME, refetchInterval: (query) => ((query.state.data ?? []).some(isActive) ? 5_000 : false), refetchOnWindowFocus: true, }) diff --git a/apps/sim/hooks/queries/byok-keys.ts b/apps/sim/hooks/queries/byok-keys.ts index 8a6392dbf52..6b16ed8652c 100644 --- a/apps/sim/hooks/queries/byok-keys.ts +++ b/apps/sim/hooks/queries/byok-keys.ts @@ -20,6 +20,8 @@ export const byokKeysKeys = { list: (workspaceId?: string) => [...byokKeysKeys.lists(), workspaceId ?? ''] as const, } +export const BYOK_KEY_LIST_STALE_TIME = 60 * 1000 + async function fetchBYOKKeys(workspaceId: string, signal?: AbortSignal): Promise { const data = await requestJson(listByokKeysContract, { params: { id: workspaceId }, @@ -35,7 +37,7 @@ export function useBYOKKeys(workspaceId: string) { queryKey: byokKeysKeys.list(workspaceId), queryFn: ({ signal }) => fetchBYOKKeys(workspaceId, signal), enabled: !!workspaceId, - staleTime: 60 * 1000, + staleTime: BYOK_KEY_LIST_STALE_TIME, placeholderData: keepPreviousData, }) } diff --git a/apps/sim/hooks/queries/chats.ts b/apps/sim/hooks/queries/chats.ts index 96eb12bb4f1..1c1da29faa1 100644 --- a/apps/sim/hooks/queries/chats.ts +++ b/apps/sim/hooks/queries/chats.ts @@ -34,6 +34,8 @@ export const chatKeys = { config: (identifier?: string) => [...chatKeys.configs(), identifier ?? ''] as const, } +export const DEPLOYED_CHAT_CONFIG_STALE_TIME = 60 * 1000 + /** * Auth types for chat access control */ @@ -91,7 +93,7 @@ export function useDeployedChatConfig(identifier: string) { queryKey: chatKeys.config(identifier), queryFn: ({ signal }) => fetchDeployedChatConfig(identifier, signal), enabled: Boolean(identifier), - staleTime: 60 * 1000, + staleTime: DEPLOYED_CHAT_CONFIG_STALE_TIME, retry: false, }) } diff --git a/apps/sim/hooks/queries/copilot-chats.ts b/apps/sim/hooks/queries/copilot-chats.ts index fe6f7462d2b..e9df553dbff 100644 --- a/apps/sim/hooks/queries/copilot-chats.ts +++ b/apps/sim/hooks/queries/copilot-chats.ts @@ -11,6 +11,8 @@ export const copilotChatsKeys = { list: (workflowId?: string) => [...copilotChatsKeys.lists(), workflowId ?? ''] as const, } +export const COPILOT_CHAT_LIST_STALE_TIME = 30 * 1000 + async function fetchCopilotChats( workflowId: string, signal?: AbortSignal @@ -33,6 +35,6 @@ export function useCopilotChats(workflowId?: string) { return useQuery({ queryKey: copilotChatsKeys.list(workflowId), queryFn: workflowId ? ({ signal }) => fetchCopilotChats(workflowId, signal) : skipToken, - staleTime: 30 * 1000, + staleTime: COPILOT_CHAT_LIST_STALE_TIME, }) } diff --git a/apps/sim/hooks/queries/copilot-keys.ts b/apps/sim/hooks/queries/copilot-keys.ts index 3be208c2cd6..a6bc9273520 100644 --- a/apps/sim/hooks/queries/copilot-keys.ts +++ b/apps/sim/hooks/queries/copilot-keys.ts @@ -20,6 +20,8 @@ export const copilotKeysKeys = { keys: () => [...copilotKeysKeys.all, 'api-keys'] as const, } +export const COPILOT_KEY_LIST_STALE_TIME = 30 * 1000 + /** * Copilot API key type (re-exported from the API contract). */ @@ -41,7 +43,7 @@ export function useCopilotKeys() { queryKey: copilotKeysKeys.keys(), queryFn: ({ signal }) => fetchCopilotKeys(signal), enabled: isHosted, - staleTime: 30 * 1000, // 30 seconds + staleTime: COPILOT_KEY_LIST_STALE_TIME, }) } diff --git a/apps/sim/hooks/queries/credential-sets.ts b/apps/sim/hooks/queries/credential-sets.ts index c63d87f1341..a984e339eea 100644 --- a/apps/sim/hooks/queries/credential-sets.ts +++ b/apps/sim/hooks/queries/credential-sets.ts @@ -39,6 +39,13 @@ export type { CredentialSetMembership, } +export const CREDENTIAL_SET_LIST_STALE_TIME = 60 * 1000 +export const CREDENTIAL_SET_DETAIL_STALE_TIME = 60 * 1000 +export const CREDENTIAL_SET_MEMBERSHIP_STALE_TIME = 60 * 1000 +export const CREDENTIAL_SET_INVITATION_LIST_STALE_TIME = 30 * 1000 +export const CREDENTIAL_SET_MEMBER_LIST_STALE_TIME = 30 * 1000 +export const CREDENTIAL_SET_INVITATION_DETAIL_STALE_TIME = 30 * 1000 + export const credentialSetKeys = { all: ['credentialSets'] as const, lists: () => [...credentialSetKeys.all, 'list'] as const, @@ -71,7 +78,7 @@ export function useCredentialSets(organizationId?: string, enabled = true) { queryKey: credentialSetKeys.list(organizationId), queryFn: ({ signal }) => fetchCredentialSets(organizationId ?? '', signal), enabled: Boolean(organizationId) && enabled, - staleTime: 60 * 1000, + staleTime: CREDENTIAL_SET_LIST_STALE_TIME, placeholderData: keepPreviousData, }) } @@ -81,7 +88,7 @@ export function useCredentialSetDetail(id?: string, enabled = true) { queryKey: credentialSetKeys.detail(id), queryFn: ({ signal }) => fetchCredentialSetById(id ?? '', signal), enabled: Boolean(id) && enabled, - staleTime: 60 * 1000, + staleTime: CREDENTIAL_SET_DETAIL_STALE_TIME, placeholderData: keepPreviousData, }) } @@ -93,7 +100,7 @@ export function useCredentialSetMemberships() { const data = await requestJson(listCredentialSetMembershipsContract, { signal }) return data.memberships ?? [] }, - staleTime: 60 * 1000, + staleTime: CREDENTIAL_SET_MEMBERSHIP_STALE_TIME, }) } @@ -104,7 +111,7 @@ export function useCredentialSetInvitations() { const data = await requestJson(listCredentialSetInvitationsContract, { signal }) return data.invitations ?? [] }, - staleTime: 30 * 1000, + staleTime: CREDENTIAL_SET_INVITATION_LIST_STALE_TIME, }) } @@ -178,7 +185,7 @@ export function useCredentialSetMembers(credentialSetId?: string) { return data.members ?? [] }, enabled: Boolean(credentialSetId), - staleTime: 30 * 1000, + staleTime: CREDENTIAL_SET_MEMBER_LIST_STALE_TIME, }) } @@ -262,7 +269,7 @@ export function useCredentialSetInvitationsDetail(credentialSetId?: string) { return (data.invitations ?? []).filter((inv) => inv.status === 'pending') }, enabled: Boolean(credentialSetId), - staleTime: 30 * 1000, + staleTime: CREDENTIAL_SET_INVITATION_DETAIL_STALE_TIME, }) } diff --git a/apps/sim/hooks/queries/credentials.ts b/apps/sim/hooks/queries/credentials.ts index 9e20147faab..d567a4ec406 100644 --- a/apps/sim/hooks/queries/credentials.ts +++ b/apps/sim/hooks/queries/credentials.ts @@ -29,6 +29,10 @@ import { fetchWorkspaceCredentialList } from '@/hooks/queries/utils/fetch-worksp */ const OAUTH_CREDENTIALS_KEY = ['oauthCredentials'] as const +export const WORKSPACE_CREDENTIAL_LIST_STALE_TIME = 60 * 1000 +export const WORKSPACE_CREDENTIAL_DETAIL_STALE_TIME = 60 * 1000 +export const WORKSPACE_CREDENTIAL_MEMBER_LIST_STALE_TIME = 30 * 1000 + export type { WorkspaceCredential, WorkspaceCredentialMember, @@ -44,7 +48,7 @@ export function prefetchWorkspaceCredentials(queryClient: QueryClient, workspace queryClient.prefetchQuery({ queryKey: workspaceCredentialKeys.list(workspaceId), queryFn: ({ signal }) => fetchWorkspaceCredentialList(workspaceId, signal), - staleTime: 60 * 1000, + staleTime: WORKSPACE_CREDENTIAL_LIST_STALE_TIME, }) } @@ -71,7 +75,7 @@ export function useWorkspaceCredentials(params: { return data.credentials ?? [] }, enabled: Boolean(workspaceId) && enabled, - staleTime: 60 * 1000, + staleTime: WORKSPACE_CREDENTIAL_LIST_STALE_TIME, }) } @@ -87,7 +91,7 @@ export function useWorkspaceCredential(credentialId?: string, enabled = true) { return data.credential ?? null }, enabled: Boolean(credentialId) && enabled, - staleTime: 60 * 1000, + staleTime: WORKSPACE_CREDENTIAL_DETAIL_STALE_TIME, }) } @@ -216,7 +220,7 @@ export function useWorkspaceCredentialMembers(credentialId?: string) { return data.members ?? [] }, enabled: Boolean(credentialId), - staleTime: 30 * 1000, + staleTime: WORKSPACE_CREDENTIAL_MEMBER_LIST_STALE_TIME, }) } diff --git a/apps/sim/hooks/queries/custom-blocks.ts b/apps/sim/hooks/queries/custom-blocks.ts index 5771533c440..8859a65b73b 100644 --- a/apps/sim/hooks/queries/custom-blocks.ts +++ b/apps/sim/hooks/queries/custom-blocks.ts @@ -10,6 +10,8 @@ import { updateCustomBlockContract, } from '@/lib/api/contracts/custom-blocks' +export const CUSTOM_BLOCK_LIST_STALE_TIME = 60 * 1000 + export const customBlockKeys = { all: ['custom-blocks'] as const, lists: () => [...customBlockKeys.all, 'list'] as const, @@ -36,7 +38,7 @@ function useCustomBlocksQuery( queryKey: customBlockKeys.list(workspaceId), queryFn: ({ signal }) => fetchCustomBlocks(workspaceId as string, signal), enabled: Boolean(workspaceId), - staleTime: 60 * 1000, + staleTime: CUSTOM_BLOCK_LIST_STALE_TIME, select, }) } diff --git a/apps/sim/hooks/queries/custom-tools.ts b/apps/sim/hooks/queries/custom-tools.ts index f5370f2e4ad..5af4a5166a3 100644 --- a/apps/sim/hooks/queries/custom-tools.ts +++ b/apps/sim/hooks/queries/custom-tools.ts @@ -11,6 +11,8 @@ import { customToolsKeys } from '@/hooks/queries/utils/custom-tool-keys' const logger = createLogger('CustomToolsQueries') +export const CUSTOM_TOOL_LIST_STALE_TIME = 60 * 1000 + interface CustomToolSchema { [key: string]: unknown type: 'function' @@ -170,7 +172,7 @@ export function useCustomTools(workspaceId: string) { queryKey: customToolsKeys.list(workspaceId), queryFn: ({ signal }) => fetchCustomTools(workspaceId, signal), enabled: !!workspaceId, - staleTime: 60 * 1000, // 1 minute - tools don't change frequently + staleTime: CUSTOM_TOOL_LIST_STALE_TIME, // 1 minute - tools don't change frequently placeholderData: keepPreviousData, }) } diff --git a/apps/sim/hooks/queries/deployments.ts b/apps/sim/hooks/queries/deployments.ts index a6097793ac5..fdc6c22b4fe 100644 --- a/apps/sim/hooks/queries/deployments.ts +++ b/apps/sim/hooks/queries/deployments.ts @@ -31,6 +31,12 @@ const logger = createLogger('DeploymentQueries') export type { ChatDetail, DeploymentVersionsResponse } +export const DEPLOYMENT_INFO_STALE_TIME = 30 * 1000 +export const DEPLOYED_WORKFLOW_STATE_STALE_TIME = 30 * 1000 +export const DEPLOYMENT_VERSIONS_STALE_TIME = 30 * 1000 +export const CHAT_DEPLOYMENT_STATUS_STALE_TIME = 30 * 1000 +export const CHAT_DETAIL_STALE_TIME = 30 * 1000 + /** * Query key factory for deployment-related queries */ @@ -111,7 +117,7 @@ export function useDeploymentInfo( queryKey: deploymentKeys.info(workflowId), queryFn: ({ signal }) => fetchDeploymentInfo(workflowId!, signal), enabled: Boolean(workflowId) && (options?.enabled ?? true), - staleTime: 30 * 1000, // 30 seconds + staleTime: DEPLOYMENT_INFO_STALE_TIME, ...(options?.refetchOnMount !== undefined && { refetchOnMount: options.refetchOnMount }), }) } @@ -142,7 +148,7 @@ export function useDeployedWorkflowState( queryKey: deploymentKeys.deployedState(workflowId), queryFn: ({ signal }) => fetchDeployedWorkflowState(workflowId!, signal), enabled: Boolean(workflowId) && (options?.enabled ?? true), - staleTime: 30 * 1000, + staleTime: DEPLOYED_WORKFLOW_STATE_STALE_TIME, }) } @@ -171,7 +177,7 @@ export function useDeploymentVersions(workflowId: string | null, options?: { ena queryKey: deploymentKeys.versions(workflowId), queryFn: ({ signal }) => fetchDeploymentVersions(workflowId!, signal), enabled: Boolean(workflowId) && (options?.enabled ?? true), - staleTime: 30 * 1000, // 30 seconds + staleTime: DEPLOYMENT_VERSIONS_STALE_TIME, }) } @@ -204,7 +210,7 @@ export function useChatDeploymentStatus( queryKey: deploymentKeys.chatStatus(workflowId), queryFn: ({ signal }) => fetchChatDeploymentStatus(workflowId!, signal), enabled: Boolean(workflowId) && (options?.enabled ?? true), - staleTime: 30 * 1000, // 30 seconds + staleTime: CHAT_DEPLOYMENT_STATUS_STALE_TIME, }) } @@ -227,7 +233,7 @@ export function useChatDetail(chatId: string | null, options?: { enabled?: boole queryKey: deploymentKeys.chatDetail(chatId), queryFn: ({ signal }) => fetchChatDetail(chatId!, signal), enabled: Boolean(chatId) && (options?.enabled ?? true), - staleTime: 30 * 1000, // 30 seconds + staleTime: CHAT_DETAIL_STALE_TIME, }) } @@ -253,7 +259,7 @@ export function useChatDeploymentInfo(workflowId: string | null, options?: { ena await queryClient.fetchQuery({ queryKey: deploymentKeys.chatDetail(nextChatId), queryFn: ({ signal }) => fetchChatDetail(nextChatId, signal), - staleTime: 30 * 1000, + staleTime: CHAT_DETAIL_STALE_TIME, }) } }, [queryClient, statusQuery.refetch]) diff --git a/apps/sim/hooks/queries/environment.ts b/apps/sim/hooks/queries/environment.ts index 2d07dd70ba4..7c25a09ccde 100644 --- a/apps/sim/hooks/queries/environment.ts +++ b/apps/sim/hooks/queries/environment.ts @@ -15,6 +15,9 @@ const logger = createLogger('EnvironmentQueries') /** * Query key factories for environment variable queries */ +export const PERSONAL_ENVIRONMENT_STALE_TIME = 60 * 1000 +export const WORKSPACE_ENVIRONMENT_STALE_TIME = 60 * 1000 + export const environmentKeys = { all: ['environment'] as const, personal: () => [...environmentKeys.all, 'personal'] as const, @@ -29,7 +32,7 @@ export function usePersonalEnvironment() { return useQuery({ queryKey: environmentKeys.personal(), queryFn: ({ signal }) => fetchPersonalEnvironment(signal), - staleTime: 60 * 1000, + staleTime: PERSONAL_ENVIRONMENT_STALE_TIME, }) } @@ -44,7 +47,7 @@ export function useWorkspaceEnvironment( queryKey: environmentKeys.workspace(workspaceId), queryFn: ({ signal }) => fetchWorkspaceEnvironment(workspaceId, signal), enabled: !!workspaceId, - staleTime: 60 * 1000, // 1 minute + staleTime: WORKSPACE_ENVIRONMENT_STALE_TIME, placeholderData: keepPreviousData, ...options, }) diff --git a/apps/sim/hooks/queries/folders.ts b/apps/sim/hooks/queries/folders.ts index 34b695374fd..2658c303c48 100644 --- a/apps/sim/hooks/queries/folders.ts +++ b/apps/sim/hooks/queries/folders.ts @@ -69,7 +69,7 @@ export function useFolders(workspaceId?: string, options?: { scope?: FolderQuery queryFn: ({ signal }) => fetchFolders(workspaceId as string, scope, signal), enabled: Boolean(workspaceId), placeholderData: keepPreviousData, - staleTime: 60 * 1000, + staleTime: FOLDER_LIST_STALE_TIME, }) } @@ -82,7 +82,7 @@ export function useFolderMap(workspaceId?: string) { queryFn: ({ signal }) => fetchFolders(workspaceId as string, 'active', signal), enabled: Boolean(workspaceId), placeholderData: keepPreviousData, - staleTime: 60 * 1000, + staleTime: FOLDER_LIST_STALE_TIME, select: selectFolderMap, }) } diff --git a/apps/sim/hooks/queries/general-settings.ts b/apps/sim/hooks/queries/general-settings.ts index 867292a1092..1c110f49d68 100644 --- a/apps/sim/hooks/queries/general-settings.ts +++ b/apps/sim/hooks/queries/general-settings.ts @@ -21,6 +21,8 @@ export const generalSettingsKeys = { settings: () => [...generalSettingsKeys.all, 'settings'] as const, } +export const GENERAL_SETTINGS_STALE_TIME = 60 * 60 * 1000 + /** * General settings type */ @@ -79,7 +81,7 @@ export function useGeneralSettings() { syncThemeToNextThemes(settings.theme) return settings }, - staleTime: 60 * 60 * 1000, + staleTime: GENERAL_SETTINGS_STALE_TIME, }) } @@ -95,7 +97,7 @@ export function prefetchGeneralSettings(queryClient: QueryClient) { syncThemeToNextThemes(settings.theme) return settings }, - staleTime: 60 * 60 * 1000, + staleTime: GENERAL_SETTINGS_STALE_TIME, }) } diff --git a/apps/sim/hooks/queries/github-stars.ts b/apps/sim/hooks/queries/github-stars.ts index 1e0f9c9fe0f..4b0d8925c52 100644 --- a/apps/sim/hooks/queries/github-stars.ts +++ b/apps/sim/hooks/queries/github-stars.ts @@ -16,6 +16,8 @@ export const githubStarsKeys = { */ export const GITHUB_STARS_FALLBACK = '27.8k' +export const GITHUB_STARS_STALE_TIME = 60 * 60 * 1000 + async function fetchGitHubStars(signal?: AbortSignal): Promise { const data = await requestJson(getStarsContract, { signal }) const value = data.stars @@ -34,7 +36,7 @@ export function useGitHubStars() { return useQuery({ queryKey: githubStarsKeys.count(), queryFn: ({ signal }) => fetchGitHubStars(signal), - staleTime: 60 * 60 * 1000, + staleTime: GITHUB_STARS_STALE_TIME, initialData: GITHUB_STARS_FALLBACK, initialDataUpdatedAt: 0, }) diff --git a/apps/sim/hooks/queries/inbox.ts b/apps/sim/hooks/queries/inbox.ts index e40e45f2e0c..9577ddeee6a 100644 --- a/apps/sim/hooks/queries/inbox.ts +++ b/apps/sim/hooks/queries/inbox.ts @@ -19,6 +19,10 @@ export type { InboxConfig, InboxSendersResponseBody } export type InboxTaskItem = InboxTask export type InboxTasksResponse = InboxTasksResponseBody +export const INBOX_CONFIG_STALE_TIME = 30 * 1000 +export const INBOX_SENDER_LIST_STALE_TIME = 60 * 1000 +export const INBOX_TASK_LIST_STALE_TIME = 15 * 1000 + export const inboxKeys = { all: ['inbox'] as const, configs: () => [...inboxKeys.all, 'config'] as const, @@ -70,7 +74,7 @@ export function useInboxConfig(workspaceId: string) { queryKey: inboxKeys.config(workspaceId), queryFn: ({ signal }) => fetchInboxConfig(workspaceId, signal), enabled: Boolean(workspaceId), - staleTime: 30 * 1000, + staleTime: INBOX_CONFIG_STALE_TIME, }) } @@ -79,7 +83,7 @@ export function useInboxSenders(workspaceId: string) { queryKey: inboxKeys.senderList(workspaceId), queryFn: ({ signal }) => fetchInboxSenders(workspaceId, signal), enabled: Boolean(workspaceId), - staleTime: 60 * 1000, + staleTime: INBOX_SENDER_LIST_STALE_TIME, }) } @@ -91,7 +95,7 @@ export function useInboxTasks( queryKey: inboxKeys.taskList(workspaceId, opts.status, opts.cursor, opts.limit), queryFn: ({ signal }) => fetchInboxTasks(workspaceId, opts, signal), enabled: Boolean(workspaceId), - staleTime: 15 * 1000, + staleTime: INBOX_TASK_LIST_STALE_TIME, placeholderData: keepPreviousData, }) } diff --git a/apps/sim/hooks/queries/invitations.ts b/apps/sim/hooks/queries/invitations.ts index 86a772a5e70..410bf83ff04 100644 --- a/apps/sim/hooks/queries/invitations.ts +++ b/apps/sim/hooks/queries/invitations.ts @@ -21,6 +21,8 @@ export const invitationKeys = { list: (workspaceId: string) => [...invitationKeys.lists(), workspaceId] as const, } +export const WORKSPACE_INVITATION_LIST_STALE_TIME = 30 * 1000 + export interface WorkspaceInvitation { email: string permissionType: 'admin' | 'write' | 'read' @@ -61,7 +63,7 @@ export function usePendingInvitations(workspaceId: string | undefined) { queryKey: invitationKeys.list(workspaceId ?? ''), queryFn: ({ signal }) => fetchPendingInvitations(workspaceId as string, signal), enabled: Boolean(workspaceId), - staleTime: 30 * 1000, + staleTime: WORKSPACE_INVITATION_LIST_STALE_TIME, placeholderData: keepPreviousData, }) } diff --git a/apps/sim/hooks/queries/kb/connectors.ts b/apps/sim/hooks/queries/kb/connectors.ts index 9f1a0ad4eb8..5e86d8549f4 100644 --- a/apps/sim/hooks/queries/kb/connectors.ts +++ b/apps/sim/hooks/queries/kb/connectors.ts @@ -21,6 +21,10 @@ const logger = createLogger('KnowledgeConnectorQueries') export type { ConnectorData, ConnectorDetailData, SyncLogData } +export const CONNECTOR_LIST_STALE_TIME = 30 * 1000 +export const CONNECTOR_DETAIL_STALE_TIME = 30 * 1000 +export const CONNECTOR_DOCUMENT_LIST_STALE_TIME = 30 * 1000 + export const connectorKeys = { all: (knowledgeBaseId?: string) => [...knowledgeKeys.detail(knowledgeBaseId), 'connectors'] as const, @@ -76,7 +80,7 @@ export function useConnectorList(knowledgeBaseId?: string) { queryKey: connectorKeys.list(knowledgeBaseId), queryFn: ({ signal }) => fetchConnectors(knowledgeBaseId as string, signal), enabled: Boolean(knowledgeBaseId), - staleTime: 30 * 1000, + staleTime: CONNECTOR_LIST_STALE_TIME, placeholderData: keepPreviousData, refetchInterval: (query) => { const connectors = query.state.data @@ -92,7 +96,7 @@ export function useConnectorDetail(knowledgeBaseId?: string, connectorId?: strin queryFn: ({ signal }) => fetchConnectorDetail(knowledgeBaseId as string, connectorId as string, signal), enabled: Boolean(knowledgeBaseId && connectorId), - staleTime: 30 * 1000, + staleTime: CONNECTOR_DETAIL_STALE_TIME, placeholderData: keepPreviousData, }) } @@ -263,7 +267,7 @@ export function useConnectorDocuments( signal ), enabled: Boolean(knowledgeBaseId && connectorId), - staleTime: 30 * 1000, + staleTime: CONNECTOR_DOCUMENT_LIST_STALE_TIME, placeholderData: keepPreviousData, }) } diff --git a/apps/sim/hooks/queries/kb/knowledge.ts b/apps/sim/hooks/queries/kb/knowledge.ts index 03b5d91afc8..f31a3e00154 100644 --- a/apps/sim/hooks/queries/kb/knowledge.ts +++ b/apps/sim/hooks/queries/kb/knowledge.ts @@ -61,6 +61,16 @@ export type { TagUsageData, } +export const KNOWLEDGE_BASE_LIST_STALE_TIME = 60 * 1000 +export const KNOWLEDGE_BASE_DETAIL_STALE_TIME = 60 * 1000 +export const KNOWLEDGE_DOCUMENT_DETAIL_STALE_TIME = 60 * 1000 +export const KNOWLEDGE_DOCUMENT_LIST_STALE_TIME = 60 * 1000 +export const KNOWLEDGE_CHUNK_LIST_STALE_TIME = 60 * 1000 +export const KNOWLEDGE_CHUNK_SEARCH_STALE_TIME = 60 * 1000 +export const KNOWLEDGE_TAG_DEFINITION_LIST_STALE_TIME = 60 * 1000 +export const KNOWLEDGE_TAG_USAGE_STALE_TIME = 60 * 1000 +export const KNOWLEDGE_DOCUMENT_TAG_DEFINITION_LIST_STALE_TIME = 60 * 1000 + export const knowledgeKeys = { all: ['knowledge'] as const, lists: () => [...knowledgeKeys.all, 'list'] as const, @@ -235,7 +245,7 @@ export function useKnowledgeBasesQuery( queryKey: knowledgeKeys.list(workspaceId, scope), queryFn: ({ signal }) => fetchKnowledgeBases(workspaceId, scope, signal), enabled: options?.enabled ?? true, - staleTime: 60 * 1000, + staleTime: KNOWLEDGE_BASE_LIST_STALE_TIME, placeholderData: keepPreviousData, }) } @@ -245,7 +255,7 @@ export function useKnowledgeBaseQuery(knowledgeBaseId?: string) { queryKey: knowledgeKeys.detail(knowledgeBaseId), queryFn: ({ signal }) => fetchKnowledgeBase(knowledgeBaseId as string, signal), enabled: Boolean(knowledgeBaseId), - staleTime: 60 * 1000, + staleTime: KNOWLEDGE_BASE_DETAIL_STALE_TIME, }) } @@ -254,7 +264,7 @@ export function useDocumentQuery(knowledgeBaseId?: string, documentId?: string) queryKey: knowledgeKeys.document(knowledgeBaseId ?? '', documentId ?? ''), queryFn: ({ signal }) => fetchDocument(knowledgeBaseId as string, documentId as string, signal), enabled: Boolean(knowledgeBaseId && documentId), - staleTime: 60 * 1000, + staleTime: KNOWLEDGE_DOCUMENT_DETAIL_STALE_TIME, placeholderData: keepPreviousData, }) } @@ -285,7 +295,7 @@ export function useKnowledgeDocumentsQuery( queryKey: knowledgeKeys.documents(params.knowledgeBaseId, paramsKey), queryFn: ({ signal }) => fetchKnowledgeDocuments(params, signal), enabled: (options?.enabled ?? true) && Boolean(params.knowledgeBaseId), - staleTime: 60 * 1000, + staleTime: KNOWLEDGE_DOCUMENT_LIST_STALE_TIME, placeholderData: keepPreviousData, refetchInterval: options?.refetchInterval ?? false, }) @@ -312,7 +322,7 @@ export function useKnowledgeChunksQuery( queryKey: knowledgeKeys.chunks(params.knowledgeBaseId, params.documentId, paramsKey), queryFn: ({ signal }) => fetchKnowledgeChunks(params, signal), enabled: (options?.enabled ?? true) && Boolean(params.knowledgeBaseId && params.documentId), - staleTime: 60 * 1000, + staleTime: KNOWLEDGE_CHUNK_LIST_STALE_TIME, placeholderData: keepPreviousData, }) } @@ -371,7 +381,7 @@ export function useDocumentChunkSearchQuery( enabled: (options?.enabled ?? true) && Boolean(params.knowledgeBaseId && params.documentId && params.search.trim()), - staleTime: 60 * 1000, + staleTime: KNOWLEDGE_CHUNK_SEARCH_STALE_TIME, placeholderData: keepPreviousData, }) } @@ -782,7 +792,7 @@ export function useTagDefinitionsQuery(knowledgeBaseId?: string | null) { queryKey: knowledgeKeys.tagDefinitions(knowledgeBaseId ?? ''), queryFn: ({ signal }) => fetchTagDefinitions(knowledgeBaseId as string, signal), enabled: Boolean(knowledgeBaseId), - staleTime: 60 * 1000, + staleTime: KNOWLEDGE_TAG_DEFINITION_LIST_STALE_TIME, placeholderData: keepPreviousData, }) } @@ -804,7 +814,7 @@ export function useTagUsageQuery(knowledgeBaseId?: string | null, options?: { en queryKey: knowledgeKeys.tagUsage(knowledgeBaseId ?? ''), queryFn: ({ signal }) => fetchTagUsage(knowledgeBaseId as string, signal), enabled: Boolean(knowledgeBaseId) && (options?.enabled ?? true), - staleTime: 60 * 1000, + staleTime: KNOWLEDGE_TAG_USAGE_STALE_TIME, }) } @@ -927,7 +937,7 @@ export function useDocumentTagDefinitionsQuery( queryFn: ({ signal }) => fetchDocumentTagDefinitions(knowledgeBaseId as string, documentId as string, signal), enabled: Boolean(knowledgeBaseId && documentId), - staleTime: 60 * 1000, + staleTime: KNOWLEDGE_DOCUMENT_TAG_DEFINITION_LIST_STALE_TIME, placeholderData: keepPreviousData, }) } diff --git a/apps/sim/hooks/queries/logs.ts b/apps/sim/hooks/queries/logs.ts index e76f9c4c99f..772fb28de48 100644 --- a/apps/sim/hooks/queries/logs.ts +++ b/apps/sim/hooks/queries/logs.ts @@ -31,6 +31,12 @@ export type { DashboardStatsResponse, WorkflowStats } export type LogSortBy = 'date' | 'duration' | 'cost' | 'status' export type LogSortOrder = 'asc' | 'desc' +export const LOG_LIST_STALE_TIME = 30 * 1000 +export const LOG_DETAIL_STALE_TIME = 30 * 1000 +export const LOG_BY_EXECUTION_STALE_TIME = 30 * 1000 +export const LOG_DASHBOARD_STATS_STALE_TIME = 30 * 1000 +export const EXECUTION_SNAPSHOT_STALE_TIME = 5 * 60 * 1000 + export const logKeys = { all: ['logs'] as const, lists: () => [...logKeys.all, 'list'] as const, @@ -169,7 +175,7 @@ export function useLogsList( fetchLogsPage(workspaceId as string, filters, pageParam, signal), enabled: Boolean(workspaceId) && (options?.enabled ?? true), refetchInterval: options?.refetchInterval ?? false, - staleTime: 30 * 1000, + staleTime: LOG_LIST_STALE_TIME, placeholderData: keepPreviousData, initialPageParam: null as string | null, getNextPageParam: (lastPage) => lastPage.nextCursor, @@ -194,7 +200,7 @@ export function useLogDetail( queryFn: ({ signal }) => fetchLogDetail(logId as string, workspaceId as string, signal), enabled: Boolean(logId) && Boolean(workspaceId) && (options?.enabled ?? true), refetchInterval: options?.refetchInterval ?? false, - staleTime: 30 * 1000, + staleTime: LOG_DETAIL_STALE_TIME, retry: (failureCount, err) => !(isApiClientError(err) && err.status === 404) && failureCount < 3, }) @@ -217,7 +223,7 @@ export function useLogByExecutionId( return data }, enabled: Boolean(workspaceId) && Boolean(executionId), - staleTime: 30 * 1000, + staleTime: LOG_BY_EXECUTION_STALE_TIME, }) } @@ -225,7 +231,7 @@ export function prefetchLogDetail(queryClient: QueryClient, logId: string, works queryClient.prefetchQuery({ queryKey: logKeys.detail(workspaceId, logId), queryFn: ({ signal }) => fetchLogDetail(logId, workspaceId, signal), - staleTime: 30 * 1000, + staleTime: LOG_DETAIL_STALE_TIME, }) } @@ -261,7 +267,7 @@ export function useDashboardStats( queryFn: ({ signal }) => fetchDashboardStats(workspaceId as string, filters, signal), enabled: Boolean(workspaceId) && (options?.enabled ?? true), refetchInterval: options?.refetchInterval ?? false, - staleTime: 30 * 1000, + staleTime: LOG_DASHBOARD_STATS_STALE_TIME, placeholderData: keepPreviousData, }) } @@ -288,7 +294,7 @@ export function useExecutionSnapshot(executionId: string | undefined) { queryKey: logKeys.executionSnapshot(executionId), queryFn: ({ signal }) => fetchExecutionSnapshot(executionId as string, signal), enabled: Boolean(executionId), - staleTime: 5 * 60 * 1000, + staleTime: EXECUTION_SNAPSHOT_STALE_TIME, }) } diff --git a/apps/sim/hooks/queries/mcp.ts b/apps/sim/hooks/queries/mcp.ts index b87ec642ee0..902dd17597a 100644 --- a/apps/sim/hooks/queries/mcp.ts +++ b/apps/sim/hooks/queries/mcp.ts @@ -41,6 +41,11 @@ const logger = createLogger('McpQueries') export type { McpServerStatusConfig, McpTool, StoredMcpTool } +export const MCP_SERVER_LIST_STALE_TIME = 60 * 1000 +export const MCP_SERVER_TOOLS_STALE_TIME = 30 * 1000 +export const MCP_STORED_TOOL_LIST_STALE_TIME = 60 * 1000 +export const MCP_ALLOWED_DOMAINS_STALE_TIME = 5 * 60 * 1000 + export const mcpKeys = { all: ['mcp'] as const, servers: () => [...mcpKeys.all, 'servers'] as const, @@ -91,7 +96,7 @@ export function useMcpServers(workspaceId: string) { queryFn: ({ signal }) => fetchMcpServers(workspaceId, signal), enabled: !!workspaceId, retry: false, - staleTime: 60 * 1000, + staleTime: MCP_SERVER_LIST_STALE_TIME, placeholderData: keepPreviousData, }) } @@ -147,7 +152,7 @@ export function useMcpToolsQuery(workspaceId: string) { fetchMcpTools(workspaceId, false, signal, serverId), enabled: !!workspaceId, retry: false, - staleTime: 30 * 1000, + staleTime: MCP_SERVER_TOOLS_STALE_TIME, refetchOnWindowFocus: false, })), }) @@ -441,7 +446,7 @@ export function useStoredMcpTools(workspaceId: string) { queryKey: mcpKeys.storedToolsList(workspaceId), queryFn: ({ signal }) => fetchStoredMcpTools(workspaceId, signal), enabled: !!workspaceId, - staleTime: 60 * 1000, + staleTime: MCP_STORED_TOOL_LIST_STALE_TIME, }) } @@ -595,6 +600,6 @@ export function useAllowedMcpDomains() { return useQuery({ queryKey: mcpKeys.allowedDomains(), queryFn: ({ signal }) => fetchAllowedMcpDomains(signal), - staleTime: 5 * 60 * 1000, + staleTime: MCP_ALLOWED_DOMAINS_STALE_TIME, }) } diff --git a/apps/sim/hooks/queries/mothership-admin.ts b/apps/sim/hooks/queries/mothership-admin.ts index c2466015392..8f34adf8840 100644 --- a/apps/sim/hooks/queries/mothership-admin.ts +++ b/apps/sim/hooks/queries/mothership-admin.ts @@ -70,6 +70,12 @@ async function byokFetch(url: string, init?: RequestInit) { return res.json() } +export const MOTHERSHIP_BYOK_STALE_TIME = 30 * 1000 +export const MOTHERSHIP_REQUESTS_STALE_TIME = 60 * 1000 +export const MOTHERSHIP_USER_BREAKDOWN_STALE_TIME = 60 * 1000 +export const MOTHERSHIP_LICENSE_LIST_STALE_TIME = 60 * 1000 +export const MOTHERSHIP_LICENSE_DETAIL_STALE_TIME = 60 * 1000 + export const mothershipKeys = { all: ['mothership-admin'] as const, requests: (env: MothershipEnv, start: string, end: string, userId?: string) => @@ -97,7 +103,7 @@ export function useMothershipByokKeys(workspaceId: string) { queryFn: ({ signal }) => byokFetch(`${BYOK_BASE}?workspaceId=${encodeURIComponent(workspaceId)}`, { signal }), enabled: !!workspaceId, - staleTime: 30 * 1000, + staleTime: MOTHERSHIP_BYOK_STALE_TIME, }) } @@ -153,7 +159,7 @@ export function useMothershipRequests( signal ), enabled: !!start && !!end, - staleTime: 60 * 1000, + staleTime: MOTHERSHIP_REQUESTS_STALE_TIME, placeholderData: keepPreviousData, }) } @@ -163,7 +169,7 @@ export function useMothershipUserBreakdown(environment: MothershipEnv, start: st queryKey: mothershipKeys.userBreakdown(environment, start, end), queryFn: ({ signal }) => mothershipPost('user-breakdown', environment, { start, end }, signal), enabled: !!start && !!end, - staleTime: 60 * 1000, + staleTime: MOTHERSHIP_USER_BREAKDOWN_STALE_TIME, placeholderData: keepPreviousData, }) } @@ -172,7 +178,7 @@ export function useMothershipLicenses(environment: MothershipEnv) { return useQuery({ queryKey: mothershipKeys.licenses(environment), queryFn: ({ signal }) => mothershipGet('licenses', environment, undefined, signal), - staleTime: 60 * 1000, + staleTime: MOTHERSHIP_LICENSE_LIST_STALE_TIME, }) } @@ -194,7 +200,7 @@ export function useMothershipLicenseDetails( signal ), enabled: !!(id || name), - staleTime: 60 * 1000, + staleTime: MOTHERSHIP_LICENSE_DETAIL_STALE_TIME, }) } diff --git a/apps/sim/hooks/queries/mothership-chats.ts b/apps/sim/hooks/queries/mothership-chats.ts index 10e458c5aef..1d802c13d93 100644 --- a/apps/sim/hooks/queries/mothership-chats.ts +++ b/apps/sim/hooks/queries/mothership-chats.ts @@ -63,6 +63,7 @@ export const mothershipChatKeys = { /** Shared by the `useMothershipChats` hook and the workspace sidebar prefetch. */ export const MOTHERSHIP_CHAT_LIST_STALE_TIME = 60 * 1000 +export const MOTHERSHIP_CHAT_HISTORY_STALE_TIME = 30 * 1000 function assertValid(condition: unknown, message: string): asserts condition { if (!condition) { @@ -262,7 +263,7 @@ export function useMothershipChatHistory(chatId: string | undefined) { return useQuery({ queryKey: mothershipChatKeys.detail(chatId), queryFn: chatId ? ({ signal }) => fetchMothershipChatHistory(chatId, signal) : skipToken, - staleTime: 30 * 1000, + staleTime: MOTHERSHIP_CHAT_HISTORY_STALE_TIME, }) } diff --git a/apps/sim/hooks/queries/oauth/oauth-connections.ts b/apps/sim/hooks/queries/oauth/oauth-connections.ts index 63dd0b79483..0319ee21689 100644 --- a/apps/sim/hooks/queries/oauth/oauth-connections.ts +++ b/apps/sim/hooks/queries/oauth/oauth-connections.ts @@ -14,6 +14,9 @@ import { OAUTH_PROVIDERS, type OAuthServiceConfig } from '@/lib/oauth' const logger = createLogger('OAuthConnectionsQuery') +export const OAUTH_CONNECTIONS_STALE_TIME = 30 * 1000 +export const OAUTH_CONNECTED_ACCOUNTS_STALE_TIME = 60 * 1000 + /** * Query key factory for OAuth connection queries. * Provides hierarchical cache keys for connections and provider-specific accounts. @@ -116,7 +119,7 @@ export function useOAuthConnections() { return useQuery({ queryKey: oauthConnectionsKeys.connections(), queryFn: ({ signal }) => fetchOAuthConnections(signal), - staleTime: 30 * 1000, + staleTime: OAUTH_CONNECTIONS_STALE_TIME, retry: false, }) } @@ -249,7 +252,7 @@ export function useConnectedAccounts(provider: string, options?: { enabled?: boo queryKey: oauthConnectionsKeys.account(provider), queryFn: ({ signal }) => fetchConnectedAccounts(provider, signal), enabled: options?.enabled ?? true, - staleTime: 60 * 1000, + staleTime: OAUTH_CONNECTED_ACCOUNTS_STALE_TIME, placeholderData: keepPreviousData, }) } diff --git a/apps/sim/hooks/queries/oauth/oauth-credentials.ts b/apps/sim/hooks/queries/oauth/oauth-credentials.ts index 11b3f62b433..bfe0d75c403 100644 --- a/apps/sim/hooks/queries/oauth/oauth-credentials.ts +++ b/apps/sim/hooks/queries/oauth/oauth-credentials.ts @@ -6,6 +6,9 @@ import { CREDENTIAL_SET } from '@/executor/constants' import { useCredentialSetDetail } from '@/hooks/queries/credential-sets' import { useWorkspaceCredential } from '@/hooks/queries/credentials' +export const OAUTH_CREDENTIAL_LIST_STALE_TIME = 60 * 1000 +export const OAUTH_CREDENTIAL_DETAIL_STALE_TIME = 60 * 1000 + export const oauthCredentialKeys = { all: ['oauthCredentials'] as const, lists: () => [...oauthCredentialKeys.all, 'list'] as const, @@ -102,7 +105,7 @@ export function useOAuthCredentials( signal ), enabled: Boolean(providerId) && enabled, - staleTime: 60 * 1000, + staleTime: OAUTH_CREDENTIAL_LIST_STALE_TIME, }) } @@ -115,7 +118,7 @@ export function useOAuthCredentialDetail( queryKey: oauthCredentialKeys.detail(credentialId, workflowId), queryFn: ({ signal }) => fetchOAuthCredentialDetail(credentialId ?? '', workflowId, signal), enabled: Boolean(credentialId) && enabled, - staleTime: 60 * 1000, + staleTime: OAUTH_CREDENTIAL_DETAIL_STALE_TIME, }) } diff --git a/apps/sim/hooks/queries/organization.ts b/apps/sim/hooks/queries/organization.ts index f005c7eaeed..ca5118c581b 100644 --- a/apps/sim/hooks/queries/organization.ts +++ b/apps/sim/hooks/queries/organization.ts @@ -49,6 +49,15 @@ import { workspaceKeys } from '@/hooks/queries/workspace' const logger = createLogger('OrganizationQueries') const invitationListsKey = ['invitations', 'list'] as const +export const ORGANIZATION_ROSTER_STALE_TIME = 30 * 1000 +export const ORGANIZATION_LIST_STALE_TIME = 30 * 1000 +export const ORGANIZATION_DETAIL_STALE_TIME = 30 * 1000 +export const ORGANIZATION_SUBSCRIPTION_STALE_TIME = 30 * 1000 +export const ORGANIZATION_BILLING_STALE_TIME = 30 * 1000 +export const ORGANIZATION_MEMBERS_STALE_TIME = 30 * 1000 +export const ORGANIZATION_MEMBER_USAGE_LIMIT_STALE_TIME = 30 * 1000 +export const ORGANIZATION_MY_MEMBER_CREDITS_STALE_TIME = 30 * 1000 + type OrganizationSubscriptionCandidate = { id: string referenceId: string @@ -140,7 +149,7 @@ export function useOrganizationRoster(orgId: string | undefined | null) { queryKey: organizationKeys.roster(orgId ?? ''), queryFn: ({ signal }) => fetchOrganizationRoster(orgId as string, signal), enabled: !!orgId, - staleTime: 30 * 1000, + staleTime: ORGANIZATION_ROSTER_STALE_TIME, placeholderData: keepPreviousData, }) } @@ -169,7 +178,7 @@ export function useOrganizations() { return useQuery({ queryKey: organizationKeys.lists(), queryFn: ({ signal }) => fetchOrganizations(signal), - staleTime: 30 * 1000, + staleTime: ORGANIZATION_LIST_STALE_TIME, }) } @@ -197,7 +206,7 @@ export function useOrganization(orgId: string) { queryKey: organizationKeys.detail(orgId), queryFn: ({ signal }) => fetchOrganization(orgId, signal), enabled: !!orgId, - staleTime: 30 * 1000, + staleTime: ORGANIZATION_DETAIL_STALE_TIME, placeholderData: keepPreviousData, }) } @@ -244,7 +253,7 @@ export function useOrganizationSubscription(orgId: string) { queryFn: ({ signal }) => fetchOrganizationSubscription(orgId, signal), enabled: !!orgId, retry: false, - staleTime: 30 * 1000, + staleTime: ORGANIZATION_SUBSCRIPTION_STALE_TIME, placeholderData: keepPreviousData, }) } @@ -281,7 +290,7 @@ export function useOrganizationBilling( queryFn: ({ signal }) => fetchOrganizationBilling(orgId, signal), enabled: !!orgId && (options?.enabled ?? true), retry: false, - staleTime: 30 * 1000, + staleTime: ORGANIZATION_BILLING_STALE_TIME, placeholderData: keepPreviousData, }) } @@ -321,7 +330,7 @@ export function useOrganizationMembers(orgId: string) { queryKey: organizationKeys.memberUsage(orgId), queryFn: ({ signal }) => fetchOrganizationMembers(orgId, signal), enabled: !!orgId, - staleTime: 30 * 1000, + staleTime: ORGANIZATION_MEMBERS_STALE_TIME, placeholderData: keepPreviousData, }) } @@ -534,7 +543,7 @@ export function useOrganizationMemberUsageLimit(orgId?: string, userId?: string, queryFn: ({ signal }) => fetchOrganizationMemberUsageLimit(orgId as string, userId as string, signal), enabled: Boolean(orgId) && Boolean(userId) && enabled, - staleTime: 30 * 1000, + staleTime: ORGANIZATION_MEMBER_USAGE_LIMIT_STALE_TIME, }) } @@ -583,7 +592,7 @@ export function useMyMemberCredits(workspaceId?: string) { queryKey: organizationKeys.myMemberCredits(workspaceId ?? ''), queryFn: ({ signal }) => fetchMyMemberCredits(workspaceId as string, signal), enabled: Boolean(workspaceId), - staleTime: 30 * 1000, + staleTime: ORGANIZATION_MY_MEMBER_CREDITS_STALE_TIME, }) } diff --git a/apps/sim/hooks/queries/providers.ts b/apps/sim/hooks/queries/providers.ts index 17f07996fff..dd92beeec67 100644 --- a/apps/sim/hooks/queries/providers.ts +++ b/apps/sim/hooks/queries/providers.ts @@ -18,6 +18,8 @@ import type { ProviderName } from '@/stores/providers' const logger = createLogger('ProviderModelsQuery') +export const PROVIDER_MODELS_STALE_TIME = 5 * 60 * 1000 + export const providerKeys = { all: ['provider-models'] as const, lists: () => [...providerKeys.all, 'list'] as const, @@ -90,6 +92,6 @@ export function useProviderModels(provider: ProviderName, workspaceId?: string) return useQuery({ queryKey: providerKeys.list(provider, workspaceId), queryFn: ({ signal }) => fetchProviderModels(provider, signal, workspaceId), - staleTime: 5 * 60 * 1000, + staleTime: PROVIDER_MODELS_STALE_TIME, }) } diff --git a/apps/sim/hooks/queries/public-shares.ts b/apps/sim/hooks/queries/public-shares.ts index 6507cc14cf1..8793405055d 100644 --- a/apps/sim/hooks/queries/public-shares.ts +++ b/apps/sim/hooks/queries/public-shares.ts @@ -14,6 +14,8 @@ import { } from '@/lib/api/contracts/public-shares' import { workspaceFilesKeys } from '@/hooks/queries/workspace-files' +export const FILE_SHARE_STALE_TIME = 30 * 1000 + /** * Query key factories for public shares */ @@ -41,7 +43,7 @@ export function useFileShare(workspaceId: string, fileId: string, options?: { en queryKey: shareKeys.detail(workspaceId, fileId), queryFn: ({ signal }) => fetchFileShare(workspaceId, fileId, signal), enabled: Boolean(workspaceId) && Boolean(fileId) && (options?.enabled ?? true), - staleTime: 30 * 1000, + staleTime: FILE_SHARE_STALE_TIME, }) } diff --git a/apps/sim/hooks/queries/resume-execution.ts b/apps/sim/hooks/queries/resume-execution.ts index a1d3e9c65a0..4b83fc43456 100644 --- a/apps/sim/hooks/queries/resume-execution.ts +++ b/apps/sim/hooks/queries/resume-execution.ts @@ -7,6 +7,8 @@ import { } from '@/lib/api/contracts/workflows' import type { ResumeStatus } from '@/executor/types' +export const RESUME_EXECUTION_DETAIL_STALE_TIME = 30 * 1000 + export const resumeKeys = { all: ['resume-execution'] as const, executions: () => [...resumeKeys.all, 'execution'] as const, @@ -120,7 +122,7 @@ export function useResumeExecutionDetail( return raw as unknown as PausedExecutionDetail }, enabled: Boolean(workflowId && executionId), - staleTime: 30 * 1000, + staleTime: RESUME_EXECUTION_DETAIL_STALE_TIME, initialData, }) } diff --git a/apps/sim/hooks/queries/schedules.ts b/apps/sim/hooks/queries/schedules.ts index efa503f36fc..76f769fcccd 100644 --- a/apps/sim/hooks/queries/schedules.ts +++ b/apps/sim/hooks/queries/schedules.ts @@ -25,6 +25,10 @@ import { deploymentKeys } from '@/hooks/queries/deployments' const logger = createLogger('ScheduleQueries') +export const SCHEDULE_LIST_STALE_TIME = 30 * 1000 +export const SCHEDULE_DETAIL_STALE_TIME = 30 * 1000 +export const SCHEDULE_BLOCK_STALE_TIME = 30 * 1000 + export const scheduleKeys = { all: ['schedules'] as const, lists: () => [...scheduleKeys.all, 'list'] as const, @@ -85,7 +89,7 @@ export function useWorkspaceSchedules(workspaceId?: string) { return data.schedules || [] }, enabled: Boolean(workspaceId), - staleTime: 30 * 1000, + staleTime: SCHEDULE_LIST_STALE_TIME, placeholderData: keepPreviousData, }) } @@ -109,7 +113,7 @@ export function useScheduleById(scheduleId?: string) { return data.schedule }, enabled: Boolean(scheduleId), - staleTime: 30 * 1000, + staleTime: SCHEDULE_DETAIL_STALE_TIME, placeholderData: keepPreviousData, }) } @@ -126,7 +130,7 @@ export function useScheduleQuery( queryKey: scheduleKeys.schedule(workflowId ?? '', blockId ?? ''), queryFn: ({ signal }) => fetchSchedule(workflowId!, blockId!, signal), enabled: !!workflowId && !!blockId && (options?.enabled ?? true), - staleTime: 30 * 1000, // 30 seconds + staleTime: SCHEDULE_BLOCK_STALE_TIME, retry: false, placeholderData: keepPreviousData, }) diff --git a/apps/sim/hooks/queries/session.ts b/apps/sim/hooks/queries/session.ts index e41d1db7a70..a8597852452 100644 --- a/apps/sim/hooks/queries/session.ts +++ b/apps/sim/hooks/queries/session.ts @@ -5,6 +5,8 @@ import { extractSessionDataFromAuthClientResult, } from '@/lib/auth/session-response' +export const SESSION_STALE_TIME = 5 * 60 * 1000 + export const sessionKeys = { all: ['session'] as const, detail: () => [...sessionKeys.all, 'detail'] as const, @@ -29,7 +31,7 @@ export function useSessionQuery() { return useQuery({ queryKey: sessionKeys.detail(), queryFn: ({ signal }) => fetchSession(signal), - staleTime: 5 * 60 * 1000, + staleTime: SESSION_STALE_TIME, retry: false, }) } diff --git a/apps/sim/hooks/queries/skills.ts b/apps/sim/hooks/queries/skills.ts index 678c9e80f33..e2bd5a1265f 100644 --- a/apps/sim/hooks/queries/skills.ts +++ b/apps/sim/hooks/queries/skills.ts @@ -10,6 +10,8 @@ import { const logger = createLogger('SkillsQueries') +export const SKILL_LIST_STALE_TIME = 60 * 1000 + export type SkillDefinition = Skill /** @@ -40,7 +42,7 @@ export function useSkills(workspaceId: string) { queryKey: skillsKeys.list(workspaceId), queryFn: ({ signal }) => fetchSkills(workspaceId, signal), enabled: !!workspaceId, - staleTime: 60 * 1000, + staleTime: SKILL_LIST_STALE_TIME, placeholderData: keepPreviousData, }) } diff --git a/apps/sim/hooks/queries/subscription.ts b/apps/sim/hooks/queries/subscription.ts index 1ea86c92845..bafee85ad5b 100644 --- a/apps/sim/hooks/queries/subscription.ts +++ b/apps/sim/hooks/queries/subscription.ts @@ -17,6 +17,10 @@ import { workspaceKeys } from '@/hooks/queries/workspace' export type { SubscriptionApiResponse } +export const SUBSCRIPTION_DATA_STALE_TIME = 5 * 60 * 1000 +export const USAGE_LIMIT_STALE_TIME = 30 * 1000 +export const INVOICES_STALE_TIME = 5 * 60 * 1000 + /** * Query key factories for subscription-related queries */ @@ -58,7 +62,7 @@ interface UseSubscriptionDataOptions { * @param options - Optional configuration */ export function useSubscriptionData(options: UseSubscriptionDataOptions = {}) { - const { includeOrg = false, enabled = true, staleTime = 5 * 60 * 1000 } = options + const { includeOrg = false, enabled = true, staleTime = SUBSCRIPTION_DATA_STALE_TIME } = options return useQuery({ queryKey: subscriptionKeys.user(includeOrg), @@ -77,7 +81,7 @@ export function prefetchSubscriptionData(queryClient: QueryClient) { queryClient.prefetchQuery({ queryKey: subscriptionKeys.user(false), queryFn: ({ signal }) => fetchSubscriptionData(false, signal), - staleTime: 5 * 60 * 1000, + staleTime: SUBSCRIPTION_DATA_STALE_TIME, }) } @@ -96,12 +100,12 @@ export function prefetchUpgradeBillingData(queryClient: QueryClient) { queryClient.prefetchQuery({ queryKey: subscriptionKeys.user(true), queryFn: ({ signal }) => fetchSubscriptionData(true, signal), - staleTime: 5 * 60 * 1000, + staleTime: SUBSCRIPTION_DATA_STALE_TIME, }) queryClient.prefetchQuery({ queryKey: subscriptionKeys.usage(), queryFn: ({ signal }) => fetchUsageLimitData(signal), - staleTime: 30 * 1000, + staleTime: USAGE_LIMIT_STALE_TIME, }) } @@ -133,7 +137,7 @@ export function useUsageLimitData(options: UseUsageLimitDataOptions = {}) { return useQuery({ queryKey: subscriptionKeys.usage(), queryFn: ({ signal }) => fetchUsageLimitData(signal), - staleTime: 30 * 1000, + staleTime: USAGE_LIMIT_STALE_TIME, enabled, }) } @@ -172,7 +176,7 @@ export function useInvoices(options: UseInvoicesOptions = {}) { return useQuery({ queryKey: subscriptionKeys.invoices(context, organizationId), queryFn: ({ signal }) => fetchInvoices(context, organizationId, signal), - staleTime: 5 * 60 * 1000, + staleTime: INVOICES_STALE_TIME, enabled: enabled && (context !== 'organization' || Boolean(organizationId)), }) } diff --git a/apps/sim/hooks/queries/tables.ts b/apps/sim/hooks/queries/tables.ts index 72878e36039..e9e10f908dd 100644 --- a/apps/sim/hooks/queries/tables.ts +++ b/apps/sim/hooks/queries/tables.ts @@ -95,13 +95,22 @@ import { optimisticallyScheduleNewlyEligibleGroups, } from '@/lib/table/deps' import { runUploadStrategy } from '@/lib/uploads/client/direct-upload' -import { type TableQueryScope, tableKeys } from '@/hooks/queries/utils/table-keys' +import { + TABLE_LIST_STALE_TIME, + type TableQueryScope, + tableKeys, +} from '@/hooks/queries/utils/table-keys' import { getNextTableRowsPageParam, type TableRowsPageParam, } from '@/hooks/queries/utils/table-rows-pagination' const logger = createLogger('TableQueries') +export const TABLE_DETAIL_STALE_TIME = 30 * 1000 +export const TABLE_RUN_STATE_STALE_TIME = 30 * 1000 +export const TABLE_FIND_STALE_TIME = 30 * 1000 +export const TABLE_ROWS_STALE_TIME = 30 * 1000 +export const TABLE_EXPORT_JOBS_STALE_TIME = 5 * 1000 type TableRowsParams = Omit & TableIdParamsInput & { @@ -226,7 +235,7 @@ export function useTablesList( return response.data.tables }, enabled: Boolean(workspaceId), - staleTime: 30 * 1000, + staleTime: TABLE_LIST_STALE_TIME, placeholderData: keepPreviousData, refetchInterval: typeof refetchInterval === 'function' @@ -244,7 +253,7 @@ export function useTable(workspaceId: string | undefined, tableId: string | unde queryKey: tableKeys.detail(tableId ?? ''), queryFn: ({ signal }) => fetchTable(workspaceId as string, tableId as string, signal), enabled: Boolean(workspaceId && tableId), - staleTime: 30 * 1000, + staleTime: TABLE_DETAIL_STALE_TIME, }) } @@ -256,7 +265,7 @@ export function getTableDetailQueryOptions(workspaceId: string, tableId: string) return { queryKey: tableKeys.detail(tableId), queryFn: ({ signal }: { signal?: AbortSignal }) => fetchTable(workspaceId, tableId, signal), - staleTime: 30 * 1000, + staleTime: TABLE_DETAIL_STALE_TIME, } } @@ -382,7 +391,7 @@ export function useTableRunState(tableId: string | undefined) { queryKey: tableKeys.activeDispatches(tableId ?? ''), queryFn: ({ signal }) => fetchTableRunState(tableId as string, signal), enabled: Boolean(tableId), - staleTime: 30 * 1000, + staleTime: TABLE_RUN_STATE_STALE_TIME, }) } @@ -444,7 +453,7 @@ export function useFindTableRows({ workspaceId, tableId, q, filter, sort }: Find queryFn: ({ signal }) => fetchTableRowMatches({ workspaceId, tableId, q, filter, sort, signal }), enabled: Boolean(workspaceId && tableId) && q.trim().length > 0, - staleTime: 30 * 1000, + staleTime: TABLE_FIND_STALE_TIME, placeholderData: keepPreviousData, }) } @@ -480,7 +489,7 @@ export function tableRowsInfiniteOptions({ // Sorted views (and legacy rows without an order key) fall back to offset paging. getNextPageParam: (_lastPage, allPages): TableRowsPageParam | undefined => getNextTableRowsPageParam(allPages, Boolean(sort)), - staleTime: 30 * 1000, + staleTime: TABLE_ROWS_STALE_TIME, }) } @@ -1675,7 +1684,7 @@ export function useWorkspaceExportJobs(workspaceId?: string) { queryKey: tableKeys.exportJobs(workspaceId), queryFn: ({ signal }) => fetchWorkspaceExportJobs(workspaceId as string, signal), enabled: Boolean(workspaceId), - staleTime: 5 * 1000, + staleTime: TABLE_EXPORT_JOBS_STALE_TIME, refetchInterval: (query) => query.state.data?.some((j) => j.status === 'running') ? 2000 : false, }) diff --git a/apps/sim/hooks/queries/unsubscribe.ts b/apps/sim/hooks/queries/unsubscribe.ts index 29116ffe82f..39fa99802a3 100644 --- a/apps/sim/hooks/queries/unsubscribe.ts +++ b/apps/sim/hooks/queries/unsubscribe.ts @@ -8,6 +8,8 @@ import { unsubscribePostContract, } from '@/lib/api/contracts/user' +export const UNSUBSCRIBE_DETAIL_STALE_TIME = 5 * 60 * 1000 + export const unsubscribeKeys = { all: ['unsubscribe'] as const, details: () => [...unsubscribeKeys.all, 'detail'] as const, @@ -32,7 +34,7 @@ export function useUnsubscribe(email?: string, token?: string) { queryKey: unsubscribeKeys.detail(email, token), queryFn: ({ signal }) => fetchUnsubscribe(email as string, token as string, signal), enabled: Boolean(email) && Boolean(token), - staleTime: 5 * 60 * 1000, + staleTime: UNSUBSCRIBE_DETAIL_STALE_TIME, retry: false, }) } diff --git a/apps/sim/hooks/queries/usage-logs.ts b/apps/sim/hooks/queries/usage-logs.ts index f67bb058f51..1f1429fa6ca 100644 --- a/apps/sim/hooks/queries/usage-logs.ts +++ b/apps/sim/hooks/queries/usage-logs.ts @@ -11,6 +11,9 @@ import { usageLogKeys } from '@/hooks/queries/utils/usage-log-keys' const PAGE_SIZE = 25 +export const USAGE_LOGS_LIST_STALE_TIME = 30 * 1000 +export const USAGE_SUMMARY_STALE_TIME = 30 * 1000 + interface UsagePeriodFilter { period: UsageLogPeriod /** Required when `period` is `'custom'`. */ @@ -51,7 +54,7 @@ export function useUsageLogs({ period, startDate, endDate, enabled = true }: Use getNextPageParam: (lastPage) => lastPage.pagination.hasMore ? lastPage.pagination.nextCursor : undefined, enabled, - staleTime: 30 * 1000, + staleTime: USAGE_LOGS_LIST_STALE_TIME, placeholderData: keepPreviousData, }) } @@ -65,7 +68,7 @@ export function useUsageSummary(period: Exclude) { return useQuery({ queryKey: usageLogKeys.summary(period), queryFn: ({ signal }) => fetchUsageLogs({ period }, 1, undefined, signal, false), - staleTime: 30 * 1000, + staleTime: USAGE_SUMMARY_STALE_TIME, select: (data) => data.summary.totalCredits, }) } diff --git a/apps/sim/hooks/queries/user-profile.ts b/apps/sim/hooks/queries/user-profile.ts index 8c15ac12286..55baf174f7e 100644 --- a/apps/sim/hooks/queries/user-profile.ts +++ b/apps/sim/hooks/queries/user-profile.ts @@ -15,6 +15,8 @@ const logger = createLogger('UserProfileQuery') /** * Query key factories for user profile */ +export const USER_PROFILE_STALE_TIME = 5 * 60 * 1000 + export const userProfileKeys = { all: ['userProfile'] as const, profile: () => [...userProfileKeys.all, 'profile'] as const, @@ -54,7 +56,7 @@ export function useUserProfile() { return useQuery({ queryKey: userProfileKeys.profile(), queryFn: ({ signal }) => fetchUserProfile(signal), - staleTime: 5 * 60 * 1000, + staleTime: USER_PROFILE_STALE_TIME, }) } diff --git a/apps/sim/hooks/queries/utils/table-keys.ts b/apps/sim/hooks/queries/utils/table-keys.ts index cf27bddd013..b1ca6a5fe7b 100644 --- a/apps/sim/hooks/queries/utils/table-keys.ts +++ b/apps/sim/hooks/queries/utils/table-keys.ts @@ -10,6 +10,8 @@ export type TableQueryScope = 'active' | 'archived' | 'all' +export const TABLE_LIST_STALE_TIME = 30 * 1000 + export const tableKeys = { all: ['tables'] as const, lists: () => [...tableKeys.all, 'list'] as const, diff --git a/apps/sim/hooks/queries/voice-settings.ts b/apps/sim/hooks/queries/voice-settings.ts index b629e5cea07..31bd9f874e0 100644 --- a/apps/sim/hooks/queries/voice-settings.ts +++ b/apps/sim/hooks/queries/voice-settings.ts @@ -6,6 +6,8 @@ import { getVoiceSettingsContract } from '@/lib/api/contracts' /** * Query key factory for voice settings queries */ +export const VOICE_SETTINGS_STALE_TIME = 5 * 60 * 1000 + export const voiceSettingsKeys = { all: ['voiceSettings'] as const, availability: () => [...voiceSettingsKeys.all, 'availability'] as const, @@ -30,6 +32,6 @@ export function useVoiceSettings() { return useQuery({ queryKey: voiceSettingsKeys.availability(), queryFn: ({ signal }) => fetchVoiceSettings(signal), - staleTime: 5 * 60 * 1000, + staleTime: VOICE_SETTINGS_STALE_TIME, }) } diff --git a/apps/sim/hooks/queries/webhooks.ts b/apps/sim/hooks/queries/webhooks.ts index 2ce10105a68..5db9bc9422f 100644 --- a/apps/sim/hooks/queries/webhooks.ts +++ b/apps/sim/hooks/queries/webhooks.ts @@ -8,6 +8,8 @@ import { type WebhookData, } from '@/lib/api/contracts/webhooks' +export const WEBHOOK_DETAIL_STALE_TIME = 60 * 1000 + export const webhookKeys = { all: ['webhooks'] as const, details: () => [...webhookKeys.all, 'detail'] as const, @@ -45,7 +47,7 @@ export function useWebhookQuery(workflowId: string, blockId: string, enabled = t queryKey: webhookKeys.byBlock(workflowId, blockId), queryFn: ({ signal }) => fetchWebhooks(workflowId, blockId, signal), enabled: enabled && Boolean(workflowId && blockId), - staleTime: 60 * 1000, + staleTime: WEBHOOK_DETAIL_STALE_TIME, }) } diff --git a/apps/sim/hooks/queries/workflow-mcp-servers.ts b/apps/sim/hooks/queries/workflow-mcp-servers.ts index f03cdbcd309..3e9e64d412e 100644 --- a/apps/sim/hooks/queries/workflow-mcp-servers.ts +++ b/apps/sim/hooks/queries/workflow-mcp-servers.ts @@ -38,6 +38,11 @@ export const workflowMcpServerKeys = { export type { WorkflowMcpServer, WorkflowMcpTool } +export const WORKFLOW_MCP_SERVERS_LIST_STALE_TIME = 60 * 1000 +export const WORKFLOW_MCP_SERVER_DETAIL_STALE_TIME = 30 * 1000 +export const WORKFLOW_MCP_TOOLS_STALE_TIME = 30 * 1000 +export const WORKFLOW_MCP_DEPLOYED_WORKFLOWS_STALE_TIME = 30 * 1000 + /** * Fetch workflow MCP servers for a workspace */ @@ -68,7 +73,7 @@ export function useWorkflowMcpServers(workspaceId: string) { queryFn: ({ signal }) => fetchWorkflowMcpServers(workspaceId, signal), enabled: !!workspaceId, retry: false, - staleTime: 60 * 1000, + staleTime: WORKFLOW_MCP_SERVERS_LIST_STALE_TIME, placeholderData: keepPreviousData, }) } @@ -102,7 +107,7 @@ export function useWorkflowMcpServer(workspaceId: string, serverId: string | nul queryFn: ({ signal }) => fetchWorkflowMcpServer(workspaceId, serverId!, signal), enabled: !!workspaceId && !!serverId, retry: false, - staleTime: 30 * 1000, + staleTime: WORKFLOW_MCP_SERVER_DETAIL_STALE_TIME, }) } @@ -138,7 +143,7 @@ export function useWorkflowMcpTools(workspaceId: string, serverId: string | null queryFn: ({ signal }) => fetchWorkflowMcpTools(workspaceId, serverId!, signal), enabled: !!workspaceId && !!serverId, retry: false, - staleTime: 30 * 1000, + staleTime: WORKFLOW_MCP_TOOLS_STALE_TIME, placeholderData: keepPreviousData, }) } @@ -414,7 +419,7 @@ export function useDeployedWorkflows(workspaceId: string) { queryKey: workflowMcpServerKeys.deployedWorkflows(workspaceId), queryFn: ({ signal }) => fetchDeployedWorkflows(workspaceId, signal), enabled: !!workspaceId, - staleTime: 30 * 1000, + staleTime: WORKFLOW_MCP_DEPLOYED_WORKFLOWS_STALE_TIME, placeholderData: keepPreviousData, }) } diff --git a/apps/sim/hooks/queries/workflow-search-replace.ts b/apps/sim/hooks/queries/workflow-search-replace.ts index 02e3bf1d506..66a3e20fc0b 100644 --- a/apps/sim/hooks/queries/workflow-search-replace.ts +++ b/apps/sim/hooks/queries/workflow-search-replace.ts @@ -102,6 +102,21 @@ export const workflowSearchReplaceKeys = { ] as const, } +export const WORKFLOW_SEARCH_OAUTH_DETAIL_STALE_TIME = 60 * 1000 +export const WORKFLOW_SEARCH_KNOWLEDGE_DETAIL_STALE_TIME = 60 * 1000 +export const WORKFLOW_SEARCH_TABLE_DETAIL_STALE_TIME = 60 * 1000 +export const WORKFLOW_SEARCH_FILE_LIST_STALE_TIME = 60 * 1000 +export const WORKFLOW_SEARCH_MCP_SERVER_LIST_STALE_TIME = 60 * 1000 +export const WORKFLOW_SEARCH_MCP_TOOL_LIST_STALE_TIME = 60 * 1000 +export const WORKFLOW_SEARCH_SELECTOR_DETAIL_STALE_TIME = 60 * 1000 +export const WORKFLOW_SEARCH_OAUTH_REPLACEMENT_STALE_TIME = 60 * 1000 +export const WORKFLOW_SEARCH_KNOWLEDGE_REPLACEMENT_STALE_TIME = 60 * 1000 +export const WORKFLOW_SEARCH_TABLE_REPLACEMENT_STALE_TIME = 60 * 1000 +export const WORKFLOW_SEARCH_FILE_REPLACEMENT_STALE_TIME = 60 * 1000 +export const WORKFLOW_SEARCH_MCP_SERVER_REPLACEMENT_STALE_TIME = 60 * 1000 +export const WORKFLOW_SEARCH_MCP_TOOL_REPLACEMENT_STALE_TIME = 60 * 1000 +export const WORKFLOW_SEARCH_SELECTOR_REPLACEMENT_STALE_TIME = 60 * 1000 + function uniqueMatches( matches: WorkflowSearchMatch[], kind: WorkflowSearchMatch['kind'] @@ -172,7 +187,7 @@ export function useWorkflowSearchOAuthCredentialDetails( queryFn: ({ signal }: { signal: AbortSignal }) => fetchOAuthCredentialDetail(match.rawValue, workflowId, signal), enabled: Boolean(match.rawValue), - staleTime: 60 * 1000, + staleTime: WORKFLOW_SEARCH_OAUTH_DETAIL_STALE_TIME, select: (credentials: Credential[]): WorkflowSearchResolvedResource => { const credential = credentials[0] return { @@ -195,7 +210,7 @@ export function useWorkflowSearchKnowledgeBaseDetails(matches: WorkflowSearchMat queryKey: workflowSearchReplaceKeys.knowledgeDetail(match.rawValue), queryFn: ({ signal }: { signal: AbortSignal }) => fetchKnowledgeBase(match.rawValue, signal), enabled: Boolean(match.rawValue), - staleTime: 60 * 1000, + staleTime: WORKFLOW_SEARCH_KNOWLEDGE_DETAIL_STALE_TIME, select: (knowledgeBase: KnowledgeBaseData): WorkflowSearchResolvedResource => ({ matchRawValue: match.rawValue, resourceGroupKey: match.resource?.resourceGroupKey, @@ -223,7 +238,7 @@ export function useWorkflowSearchTableDetails( signal, }), enabled: Boolean(workspaceId && match.rawValue), - staleTime: 60 * 1000, + staleTime: WORKFLOW_SEARCH_TABLE_DETAIL_STALE_TIME, select: (response: GetTableResponse): WorkflowSearchResolvedResource => ({ matchRawValue: match.rawValue, resourceGroupKey: match.resource?.resourceGroupKey, @@ -254,7 +269,7 @@ export function useWorkflowSearchFileDetails(matches: WorkflowSearchMatch[], wor signal, }), enabled: Boolean(workspaceId && fileMatches.length > 0), - staleTime: 60 * 1000, + staleTime: WORKFLOW_SEARCH_FILE_LIST_STALE_TIME, }) return useMemo( @@ -293,7 +308,7 @@ export function useWorkflowSearchMcpServerDetails( signal, }), enabled: Boolean(workspaceId && serverMatches.length > 0), - staleTime: 60 * 1000, + staleTime: WORKFLOW_SEARCH_MCP_SERVER_LIST_STALE_TIME, }) return useMemo( @@ -330,7 +345,7 @@ export function useWorkflowSearchMcpToolDetails( signal, }), enabled: Boolean(workspaceId && toolMatches.length > 0), - staleTime: 60 * 1000, + staleTime: WORKFLOW_SEARCH_MCP_TOOL_LIST_STALE_TIME, }) return useMemo( @@ -382,7 +397,7 @@ export function useWorkflowSearchSelectorDetails(matches: WorkflowSearchMatch[]) return options.find((option) => option.id === match.rawValue) ?? null }, enabled: Boolean(selectorKey && match.rawValue && baseEnabled), - staleTime: definition.staleTime ?? 60 * 1000, + staleTime: definition.staleTime ?? WORKFLOW_SEARCH_SELECTOR_DETAIL_STALE_TIME, select: (option: SelectorOption | null): WorkflowSearchResolvedResource => ({ matchRawValue: match.rawValue, resourceGroupKey: match.resource?.resourceGroupKey, @@ -420,7 +435,7 @@ export function useWorkflowSearchOAuthReplacementOptions( queryFn: ({ signal }: { signal: AbortSignal }) => fetchOAuthCredentials({ providerId, workspaceId, workflowId }, signal), enabled: Boolean(providerId && workspaceId), - staleTime: 60 * 1000, + staleTime: WORKFLOW_SEARCH_OAUTH_REPLACEMENT_STALE_TIME, select: (credentials: Credential[]): WorkflowSearchReplacementOption[] => credentials.map((credential) => ({ kind: 'oauth-credential', @@ -449,7 +464,7 @@ export function useWorkflowSearchKnowledgeReplacementOptions( queryFn: ({ signal }: { signal: AbortSignal }) => fetchKnowledgeBases(workspaceId, 'active', signal), enabled: Boolean(workspaceId && knowledgeGroups.length > 0), - staleTime: 60 * 1000, + staleTime: WORKFLOW_SEARCH_KNOWLEDGE_REPLACEMENT_STALE_TIME, placeholderData: (previous: KnowledgeBaseData[] | undefined) => previous, select: (knowledgeBases: KnowledgeBaseData[]): WorkflowSearchReplacementOption[] => knowledgeGroups.flatMap((match) => @@ -481,7 +496,7 @@ export function useWorkflowSearchTableReplacementOptions( signal, }), enabled: Boolean(workspaceId && tableGroups.length > 0), - staleTime: 60 * 1000, + staleTime: WORKFLOW_SEARCH_TABLE_REPLACEMENT_STALE_TIME, select: (response: ListTablesResponse): WorkflowSearchReplacementOption[] => tableGroups.flatMap((match) => response.data.tables.map((table) => ({ @@ -516,7 +531,7 @@ export function useWorkflowSearchFileReplacementOptions( signal, }), enabled: Boolean(workspaceId && fileGroups.length > 0), - staleTime: 60 * 1000, + staleTime: WORKFLOW_SEARCH_FILE_REPLACEMENT_STALE_TIME, select: (response: ListWorkspaceFilesResponse): WorkflowSearchReplacementOption[] => fileGroups.flatMap((match) => response.files.map((file) => ({ @@ -553,7 +568,7 @@ export function useWorkflowSearchMcpServerReplacementOptions( signal, }), enabled: Boolean(workspaceId && serverGroups.length > 0), - staleTime: 60 * 1000, + staleTime: WORKFLOW_SEARCH_MCP_SERVER_REPLACEMENT_STALE_TIME, select: (response: ListMcpServersResponse): WorkflowSearchReplacementOption[] => serverGroups.flatMap((match) => response.data.servers.map((server) => ({ @@ -601,7 +616,7 @@ export function useWorkflowSearchMcpToolReplacementOptions( signal, }), enabled: Boolean(workspaceId && toolGroups.length > 0), - staleTime: 60 * 1000, + staleTime: WORKFLOW_SEARCH_MCP_TOOL_REPLACEMENT_STALE_TIME, select: (response: DiscoverMcpToolsResponse): WorkflowSearchReplacementOption[] => buildWorkflowSearchMcpToolReplacementOptions(toolGroups, response.data.tools), }, @@ -626,7 +641,7 @@ export function useWorkflowSearchSelectorReplacementOptions(matches: WorkflowSea queryFn: ({ signal }: { signal: AbortSignal }) => loadAllSelectorOptions(definition, { ...queryArgs, signal }), enabled: Boolean(selectorKey && baseEnabled), - staleTime: definition.staleTime ?? 60 * 1000, + staleTime: definition.staleTime ?? WORKFLOW_SEARCH_SELECTOR_REPLACEMENT_STALE_TIME, select: (options: SelectorOption[]): WorkflowSearchReplacementOption[] => options.map((option) => ({ kind: match.kind, diff --git a/apps/sim/hooks/queries/workflows.ts b/apps/sim/hooks/queries/workflows.ts index 55df6d469a8..15aed17e712 100644 --- a/apps/sim/hooks/queries/workflows.ts +++ b/apps/sim/hooks/queries/workflows.ts @@ -50,6 +50,9 @@ const logger = createLogger('WorkflowQueries') export { type WorkflowQueryScope, workflowKeys } from '@/hooks/queries/utils/workflow-keys' +export const WORKFLOW_STATE_STALE_TIME = 30 * 1000 +export const WORKFLOW_DEPLOYMENT_VERSION_STATE_STALE_TIME = 5 * 60 * 1000 + /** * Projects the in-state slice of the workflow envelope into the canvas-facing * `WorkflowState` shape consumed by preview/editor surfaces. @@ -78,7 +81,7 @@ export function useWorkflowState(workflowId: string | undefined) { queryKey: workflowKeys.state(workflowId), queryFn: workflowId ? ({ signal }) => fetchWorkflowEnvelope(workflowId, signal) : skipToken, select: mapWorkflowState, - staleTime: 30 * 1000, + staleTime: WORKFLOW_STATE_STALE_TIME, }) } @@ -98,7 +101,7 @@ export function useWorkflowStates( queryKey: workflowKeys.state(id), queryFn: ({ signal }: { signal?: AbortSignal }) => fetchWorkflowEnvelope(id, signal), select: mapWorkflowState, - staleTime: 30 * 1000, + staleTime: WORKFLOW_STATE_STALE_TIME, })), }) const map = new Map() @@ -563,7 +566,7 @@ export function useDeploymentVersionState(workflowId: string | null, version: nu workflowId && version !== null ? ({ signal }) => fetchDeploymentVersionState(workflowId, version, signal) : skipToken, - staleTime: 5 * 60 * 1000, + staleTime: WORKFLOW_DEPLOYMENT_VERSION_STATE_STALE_TIME, }) } diff --git a/apps/sim/hooks/queries/workspace-file-folders.ts b/apps/sim/hooks/queries/workspace-file-folders.ts index dd0a627a0d4..bad1c52c0ee 100644 --- a/apps/sim/hooks/queries/workspace-file-folders.ts +++ b/apps/sim/hooks/queries/workspace-file-folders.ts @@ -16,6 +16,8 @@ import { workspaceFilesKeys } from '@/hooks/queries/workspace-files' type WorkspaceFileFolderScope = 'active' | 'archived' | 'all' export type { WorkspaceFileFolderApi } +export const WORKSPACE_FILE_FOLDERS_STALE_TIME = 30 * 1000 + export const workspaceFileFolderKeys = { all: ['workspaceFileFolders'] as const, lists: () => [...workspaceFileFolderKeys.all, 'list'] as const, @@ -55,7 +57,7 @@ export function useWorkspaceFileFolders( queryKey: workspaceFileFolderKeys.list(workspaceId, scope), queryFn: ({ signal }) => fetchWorkspaceFileFolders(workspaceId, scope, signal), enabled: Boolean(workspaceId), - staleTime: 30 * 1000, + staleTime: WORKSPACE_FILE_FOLDERS_STALE_TIME, placeholderData: keepPreviousData, }) } diff --git a/apps/sim/hooks/queries/workspace-file-table.ts b/apps/sim/hooks/queries/workspace-file-table.ts index 8538c97d26f..c9f6573b8a9 100644 --- a/apps/sim/hooks/queries/workspace-file-table.ts +++ b/apps/sim/hooks/queries/workspace-file-table.ts @@ -9,6 +9,8 @@ import { * Query keys for the streamed CSV file-viewer preview. `key` (storage object key) and * `version` (the record's `updatedAt`) are folded in so a re-upload or edit busts the cache. */ +export const WORKSPACE_CSV_PREVIEW_STALE_TIME = 30 * 1000 + export const workspaceFileTableKeys = { all: ['workspaceFileTable'] as const, previews: () => [...workspaceFileTableKeys.all, 'preview'] as const, @@ -45,6 +47,6 @@ export function useWorkspaceCsvPreview( queryKey: workspaceFileTableKeys.preview(workspaceId, fileId, key, version), queryFn: ({ signal }) => fetchWorkspaceCsvPreview(workspaceId, fileId, key, version, signal), enabled: !!workspaceId && !!fileId && !!key && (options?.enabled ?? true), - staleTime: 30 * 1000, + staleTime: WORKSPACE_CSV_PREVIEW_STALE_TIME, }) } diff --git a/apps/sim/hooks/queries/workspace-files.ts b/apps/sim/hooks/queries/workspace-files.ts index d03a80c50f2..edd65bb3984 100644 --- a/apps/sim/hooks/queries/workspace-files.ts +++ b/apps/sim/hooks/queries/workspace-files.ts @@ -54,6 +54,11 @@ export const workspaceFilesKeys = { storageInfo: () => [...workspaceFilesKeys.all, 'storageInfo'] as const, } +export const WORKSPACE_FILES_LIST_STALE_TIME = 30 * 1000 +export const WORKSPACE_FILE_CONTENT_STALE_TIME = 30 * 1000 +export const WORKSPACE_FILE_BINARY_STALE_TIME = 30 * 1000 +export const WORKSPACE_STORAGE_INFO_STALE_TIME = 60 * 1000 + /** * Storage info type */ @@ -76,7 +81,7 @@ export function useWorkspaceFileRecord(workspaceId: string, fileId: string) { queryKey: workspaceFilesKeys.list(workspaceId, 'active'), queryFn: ({ signal }) => fetchWorkspaceFiles(workspaceId, 'active', signal), enabled: !!workspaceId && !!fileId, - staleTime: 30 * 1000, + staleTime: WORKSPACE_FILES_LIST_STALE_TIME, select: (files) => files.find((f) => f.id === fileId) ?? null, }) } @@ -109,7 +114,7 @@ export function useWorkspaceFiles( queryKey: workspaceFilesKeys.list(workspaceId, scope), queryFn: ({ signal }) => fetchWorkspaceFiles(workspaceId, scope, signal), enabled: !!workspaceId && (options?.enabled ?? true), - staleTime: 30 * 1000, // 30 seconds - files can change frequently + staleTime: WORKSPACE_FILES_LIST_STALE_TIME, // 30 seconds - files can change frequently placeholderData: keepPreviousData, // Show cached data immediately }) } @@ -145,7 +150,7 @@ export function useWorkspaceFileContent( queryFn: ({ signal }) => fetchWorkspaceFileContent(source.buildUrl(key, { raw, bust: true }), signal), enabled: !!workspaceId && !!fileId && !!key, - staleTime: 30 * 1000, + staleTime: WORKSPACE_FILE_CONTENT_STALE_TIME, refetchOnWindowFocus: 'always', }) } @@ -222,7 +227,7 @@ export function useWorkspaceFileBinary( // compiled artifact hasn't been written yet — the doc is fetched once, when // it's actually ready, instead of hammering the serve URL through generation. enabled: !!workspaceId && !!fileId && !!key && (options?.enabled ?? true), - staleTime: 30 * 1000, + staleTime: WORKSPACE_FILE_BINARY_STALE_TIME, refetchOnWindowFocus: 'always', placeholderData: keepPreviousData, // While a generated doc is still compiling, serve returns 409. Poll (stay in @@ -276,7 +281,7 @@ export function useStorageInfo(enabled = true) { queryFn: ({ signal }) => fetchStorageInfo(signal), enabled, retry: false, // Don't retry on 404 - staleTime: 60 * 1000, // 1 minute - storage info doesn't change often + staleTime: WORKSPACE_STORAGE_INFO_STALE_TIME, // 1 minute - storage info doesn't change often }) } diff --git a/apps/sim/hooks/queries/workspace-fork.ts b/apps/sim/hooks/queries/workspace-fork.ts index 2fdc7be954c..45d026a98f9 100644 --- a/apps/sim/hooks/queries/workspace-fork.ts +++ b/apps/sim/hooks/queries/workspace-fork.ts @@ -35,13 +35,18 @@ export const forkKeys = { resources: (workspaceId?: string) => [...forkKeys.resourcesAll(), workspaceId ?? ''] as const, } +export const WORKSPACE_FORK_RESOURCES_STALE_TIME = 30 * 1000 +export const WORKSPACE_FORK_LINEAGE_STALE_TIME = 30 * 1000 +export const WORKSPACE_FORK_MAPPING_STALE_TIME = 15 * 1000 +export const WORKSPACE_FORK_DIFF_STALE_TIME = 10 * 1000 + export function useForkResources(workspaceId?: string, enabled = true) { return useQuery({ queryKey: forkKeys.resources(workspaceId), queryFn: ({ signal }) => requestJson(getForkResourcesContract, { params: { id: workspaceId as string }, signal }), enabled: Boolean(workspaceId) && enabled, - staleTime: 30 * 1000, + staleTime: WORKSPACE_FORK_RESOURCES_STALE_TIME, }) } @@ -51,7 +56,7 @@ export function useForkLineage(workspaceId?: string, enabled = true) { queryFn: ({ signal }) => requestJson(getForkLineageContract, { params: { id: workspaceId as string }, signal }), enabled: Boolean(workspaceId) && enabled, - staleTime: 30 * 1000, + staleTime: WORKSPACE_FORK_LINEAGE_STALE_TIME, placeholderData: keepPreviousData, }) } @@ -100,7 +105,7 @@ export function useForkMapping(args: { signal, }), enabled: Boolean(args.workspaceId && args.otherWorkspaceId) && (args.enabled ?? true), - staleTime: 15 * 1000, + staleTime: WORKSPACE_FORK_MAPPING_STALE_TIME, placeholderData: keepPreviousData, }) } @@ -132,7 +137,7 @@ export function useForkDiff(args: { signal, }), enabled: Boolean(args.workspaceId && args.otherWorkspaceId) && (args.enabled ?? true), - staleTime: 10 * 1000, + staleTime: WORKSPACE_FORK_DIFF_STALE_TIME, placeholderData: keepPreviousData, }) } diff --git a/apps/sim/hooks/queries/workspace.ts b/apps/sim/hooks/queries/workspace.ts index f91d769d1f2..c73e56aa60f 100644 --- a/apps/sim/hooks/queries/workspace.ts +++ b/apps/sim/hooks/queries/workspace.ts @@ -42,6 +42,12 @@ export const workspaceKeys = { export type { Workspace, WorkspaceCreationPolicy, WorkspaceMember, WorkspacePermissions } +export const WORKSPACE_PERMISSIONS_STALE_TIME = 30 * 1000 +export const WORKSPACE_LIST_STALE_TIME = 30 * 1000 +export const WORKSPACE_SETTINGS_STALE_TIME = 30 * 1000 +export const WORKSPACE_MEMBERS_STALE_TIME = 5 * 60 * 1000 +export const WORKSPACE_ADMIN_LIST_STALE_TIME = 60 * 1000 + async function fetchWorkspaces( scope: WorkspaceQueryScope = 'active', signal?: AbortSignal @@ -83,7 +89,7 @@ export function useWorkspacesQuery(enabled = true, scope: WorkspaceQueryScope = queryFn: ({ signal }) => fetchWorkspaces(scope, signal), select: selectWorkspaces, enabled, - staleTime: 30 * 1000, + staleTime: WORKSPACE_LIST_STALE_TIME, placeholderData: keepPreviousData, }) } @@ -97,7 +103,7 @@ export function useWorkspacesWithMetadata(enabled = true) { queryKey: workspaceKeys.list('active'), queryFn: ({ signal }) => fetchWorkspaces('active', signal), enabled, - staleTime: 30 * 1000, + staleTime: WORKSPACE_LIST_STALE_TIME, }) } @@ -107,7 +113,7 @@ export function useWorkspaceCreationPolicy(enabled = true) { queryFn: ({ signal }) => fetchWorkspaces('active', signal), select: (data) => data.creationPolicy, enabled, - staleTime: 30 * 1000, + staleTime: WORKSPACE_LIST_STALE_TIME, }) } @@ -250,7 +256,7 @@ export function useWorkspacePermissionsQuery(workspaceId: string | null | undefi queryKey: workspaceKeys.permissions(workspaceId ?? ''), queryFn: ({ signal }) => fetchWorkspacePermissions(workspaceId as string, signal), enabled: Boolean(workspaceId), - staleTime: 30 * 1000, + staleTime: WORKSPACE_PERMISSIONS_STALE_TIME, placeholderData: keepPreviousData, }) } @@ -275,7 +281,7 @@ export function useWorkspaceMembersQuery(workspaceId: string | null | undefined) queryKey: workspaceKeys.members(workspaceId ?? ''), queryFn: ({ signal }) => fetchWorkspaceMembers(workspaceId as string, signal), enabled: Boolean(workspaceId), - staleTime: 5 * 60 * 1000, + staleTime: WORKSPACE_MEMBERS_STALE_TIME, }) } @@ -302,7 +308,7 @@ export function prefetchWorkspaceSettings(queryClient: QueryClient, workspaceId: queryClient.prefetchQuery({ queryKey: workspaceKeys.settings(workspaceId), queryFn: ({ signal }) => fetchWorkspaceSettings(workspaceId, signal), - staleTime: 30 * 1000, + staleTime: WORKSPACE_SETTINGS_STALE_TIME, }) } @@ -315,7 +321,7 @@ export function useWorkspaceSettings(workspaceId: string) { queryKey: workspaceKeys.settings(workspaceId), queryFn: ({ signal }) => fetchWorkspaceSettings(workspaceId, signal), enabled: !!workspaceId, - staleTime: 30 * 1000, + staleTime: WORKSPACE_SETTINGS_STALE_TIME, placeholderData: keepPreviousData, }) } @@ -401,7 +407,7 @@ export function useAdminWorkspaces(userId: string | undefined, organizationId?: queryKey: [...workspaceKeys.adminList(userId), organizationId ?? ''] as const, queryFn: ({ signal }) => fetchAdminWorkspaces(userId, organizationId, signal), enabled: Boolean(userId), - staleTime: 60 * 1000, + staleTime: WORKSPACE_ADMIN_LIST_STALE_TIME, placeholderData: keepPreviousData, }) } From 65435f89aafa76b6ba00f5ce7475258cdbd927ad Mon Sep 17 00:00:00 2001 From: Waleed Date: Sun, 5 Jul 2026 10:59:53 -0700 Subject: [PATCH 04/35] improvement(sidebar): memoize workflow/folder rows for faster tab navigation (#5428) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * improvement(sidebar): memoize workflow/folder rows for faster tab navigation Switching between workspace tabs re-rendered every workflow and folder row in the sidebar because the rows (and the shared export hooks they call) subscribed to useParams, which re-renders on every navigation. - Wrap WorkflowItem and FolderItem in React.memo (the only un-memoized leaf rows; every sibling row was already memoized). - Decouple the rows from useParams: thread workspaceId as a stable prop from WorkflowList (matching the FileList convention), and expose the live active workflowId through a stable activeWorkflowIdRef on SidebarListContext, read only in delete callbacks — never during render. - Refactor the three shared export hooks (used only by these two rows) to take workspaceId as a param instead of calling useParams internally. - Stabilize handleWorkflowClick's identity via refs so the shared list context no longer changes identity on navigation. - Lazy-init the drag-drop siblings Map ref. On a tab switch only the two rows whose active state flips now re-render. * fix(sidebar): add workspaceId to render-callback deps renderWorkflowItem/renderFolderSection now pass workspaceId into the rows, so they must list it as a dependency — otherwise a workspace switch that doesn't also change workflowId would leave the callbacks closing over a stale workspaceId (wrong-workspace deletes/exports). --- .../components/folder-item/folder-item.tsx | 28 ++++++++++------ .../workflow-item/workflow-item.tsx | 32 ++++++++++++------- .../workflow-list/workflow-list.tsx | 18 ++++++++--- .../components/sidebar/hooks/use-drag-drop.ts | 15 +++++---- .../sidebar/hooks/use-sidebar-list-context.ts | 22 +++++++++++-- .../sidebar/hooks/use-workflow-selection.ts | 25 +++++++++------ .../w/hooks/use-export-folder.ts | 8 +++-- .../w/hooks/use-export-selection.ts | 10 +++--- .../w/hooks/use-export-workflow.ts | 9 +++--- 9 files changed, 110 insertions(+), 57 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/folder-item/folder-item.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/folder-item/folder-item.tsx index 66c4e8b8517..377e562eadf 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/folder-item/folder-item.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/folder-item/folder-item.tsx @@ -1,13 +1,13 @@ 'use client' -import { useCallback, useMemo, useRef, useState } from 'react' +import { memo, useCallback, useMemo, useRef, useState } from 'react' import { chipVariants, cn } from '@sim/emcn' import { Lock } from '@sim/emcn/icons' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import clsx from 'clsx' import { ChevronRight, Folder, FolderOpen, MoreHorizontal } from 'lucide-react' -import { useParams, useRouter } from 'next/navigation' +import { useRouter } from 'next/navigation' import { SIM_RESOURCES_DRAG_TYPE } from '@/lib/copilot/resource-types' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { ContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu' @@ -49,15 +49,20 @@ import { generateCreativeWorkflowName } from '@/stores/workflows/registry/utils' const logger = createLogger('FolderItem') interface FolderItemProps { + workspaceId: string folder: FolderTreeNode } -export function FolderItem({ folder }: FolderItemProps) { - const { isAnyDragActive, dragDisabled, onFolderClick, onItemDragStart, onItemDragEnd } = - useSidebarListContext() - const params = useParams() +export const FolderItem = memo(function FolderItem({ workspaceId, folder }: FolderItemProps) { + const { + isAnyDragActive, + dragDisabled, + activeWorkflowIdRef, + onFolderClick, + onItemDragStart, + onItemDragEnd, + } = useSidebarListContext() const router = useRouter() - const workspaceId = params.workspaceId as string const updateFolderMutation = useUpdateFolder() const createWorkflowMutation = useCreateWorkflow() const createFolderMutation = useCreateFolder() @@ -95,7 +100,7 @@ export function FolderItem({ folder }: FolderItemProps) { workspaceId, workflowIds: capturedSelectionRef.current?.workflowIds || [], folderIds: capturedSelectionRef.current?.folderIds || [], - isActiveWorkflow: (id) => id === params.workflowId, + isActiveWorkflow: (id) => id === activeWorkflowIdRef.current, onSuccess: () => setIsDeleteModalOpen(false), }) @@ -117,10 +122,13 @@ export function FolderItem({ folder }: FolderItemProps) { hasWorkflows, handleExportFolder: handleExportThisFolder, } = useExportFolder({ + workspaceId, folderId: folder.id, }) - const { isExporting: isExportingSelection, handleExportSelection } = useExportSelection() + const { isExporting: isExportingSelection, handleExportSelection } = useExportSelection({ + workspaceId, + }) const isExporting = isExportingThisFolder || isExportingSelection @@ -606,4 +614,4 @@ export function FolderItem({ folder }: FolderItemProps) { /> ) -} +}) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/workflow-item.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/workflow-item.tsx index 72b5f373482..d0c4b104b4e 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/workflow-item.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/workflow-item.tsx @@ -1,12 +1,11 @@ 'use client' -import { useCallback, useMemo, useRef, useState } from 'react' +import { memo, useCallback, useMemo, useRef, useState } from 'react' import { chipVariants, cn } from '@sim/emcn' import { Lock } from '@sim/emcn/icons' import clsx from 'clsx' import { MoreHorizontal } from 'lucide-react' import Link from 'next/link' -import { useParams } from 'next/navigation' import { SIM_RESOURCES_DRAG_TYPE } from '@/lib/copilot/resource-types' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { ContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu' @@ -43,6 +42,7 @@ import { useFolderStore } from '@/stores/folders/store' import type { WorkflowMetadata } from '@/stores/workflows/registry/types' interface WorkflowItemProps { + workspaceId: string workflow: WorkflowMetadata active: boolean } @@ -55,11 +55,19 @@ interface WorkflowItemProps { * @param props - Component props * @returns Workflow item with drag and selection support */ -export function WorkflowItem({ workflow, active }: WorkflowItemProps) { - const { isAnyDragActive, dragDisabled, onWorkflowClick, onItemDragStart, onItemDragEnd } = - useSidebarListContext() - const params = useParams() - const workspaceId = params.workspaceId as string +export const WorkflowItem = memo(function WorkflowItem({ + workspaceId, + workflow, + active, +}: WorkflowItemProps) { + const { + isAnyDragActive, + dragDisabled, + activeWorkflowIdRef, + onWorkflowClick, + onItemDragStart, + onItemDragEnd, + } = useSidebarListContext() const selectedWorkflows = useFolderStore((state) => state.selectedWorkflows) const updateWorkflowMutation = useUpdateWorkflow() const userPermissions = useUserPermissionsContext() @@ -105,7 +113,7 @@ export function WorkflowItem({ workflow, active }: WorkflowItemProps) { useDeleteWorkflow({ workspaceId, workflowIds: capturedSelectionRef.current?.workflowIds || [], - isActive: (workflowIds) => workflowIds.includes(params.workflowId as string), + isActive: (workflowIds) => workflowIds.includes(activeWorkflowIdRef.current ?? ''), onSuccess: () => setIsDeleteModalOpen(false), }) @@ -113,7 +121,7 @@ export function WorkflowItem({ workflow, active }: WorkflowItemProps) { workspaceId, workflowIds: capturedSelectionRef.current?.workflowIds || [], folderIds: capturedSelectionRef.current?.folderIds || [], - isActiveWorkflow: (id) => id === params.workflowId, + isActiveWorkflow: (id) => id === activeWorkflowIdRef.current, onSuccess: () => setIsDeleteModalOpen(false), }) @@ -136,8 +144,8 @@ export function WorkflowItem({ workflow, active }: WorkflowItemProps) { { workspaceId } ) - const { handleExportWorkflow: handleExportWorkflows } = useExportWorkflow() - const { handleExportSelection } = useExportSelection() + const { handleExportWorkflow: handleExportWorkflows } = useExportWorkflow({ workspaceId }) + const { handleExportSelection } = useExportSelection({ workspaceId }) const handleDuplicate = useCallback(() => { if (!capturedSelectionRef.current) return @@ -507,4 +515,4 @@ export function WorkflowItem({ workflow, active }: WorkflowItemProps) { /> ) -} +}) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/workflow-list.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/workflow-list.tsx index 5ace96b0459..0110c116536 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/workflow-list.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/workflow-list.tsx @@ -1,6 +1,6 @@ 'use client' -import { memo, useCallback, useEffect, useMemo } from 'react' +import { memo, useCallback, useEffect, useMemo, useRef } from 'react' import clsx from 'clsx' import { useShallow } from 'zustand/react/shallow' import { buildFolderTree, getFolderPath } from '@/lib/folders/tree' @@ -341,9 +341,14 @@ export const WorkflowList = memo(function WorkflowList({ folderDescendantIds, }) + /** Mirror `workflowId` into a stable ref so the list context stays referentially stable across navigation. */ + const activeWorkflowIdRef = useRef(workflowId) + activeWorkflowIdRef.current = workflowId + const listContextValue = useSidebarListContextValue({ isAnyDragActive: isDragging, dragDisabled, + activeWorkflowIdRef, onWorkflowClick: handleWorkflowClick, onFolderClick: handleFolderClick, onItemDragStart: handleDragStart, @@ -380,13 +385,17 @@ export const WorkflowList = memo(function WorkflowList({ style={{ paddingLeft: `${level * TREE_SPACING.INDENT_PER_LEVEL}px` }} {...createWorkflowDragHandlers(workflow.id, folderId)} > - + ) }, - [dropIndicator, isWorkflowActive, createWorkflowDragHandlers] + [workspaceId, dropIndicator, isWorkflowActive, createWorkflowDragHandlers] ) const renderFolderSection = useCallback( @@ -445,7 +454,7 @@ export const WorkflowList = memo(function WorkflowList({ style={{ paddingLeft: `${level * TREE_SPACING.INDENT_PER_LEVEL}px` }} {...createFolderDragHandlers(folder.id, parentFolderId)} > - + @@ -471,6 +480,7 @@ export const WorkflowList = memo(function WorkflowList({ ) }, [ + workspaceId, workflowsByFolder, expandedFolders, dropIndicator, diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-drag-drop.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-drag-drop.ts index c741f658930..00e5a57bb29 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-drag-drop.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-drag-drop.ts @@ -65,7 +65,7 @@ export function useDragDrop(options: UseDragDropOptions = {}) { const hoverExpandTimerRef = useRef(null) const lastDragYRef = useRef(0) const draggedSourceFolderRef = useRef(null) - const siblingsCacheRef = useRef>(new Map()) + const siblingsCacheRef = useRef | null>(null) const isDraggingRef = useRef(false) const params = useParams() @@ -152,7 +152,7 @@ export function useDragDrop(options: UseDragDropOptions = {}) { }, [hoverFolderId, isDragging, expandedFolders, setExpanded]) useEffect(() => { - siblingsCacheRef.current.clear() + siblingsCacheRef.current?.clear() }, [workspaceId]) const calculateDropPosition = useCallback( @@ -276,7 +276,7 @@ export function useDragDrop(options: UseDragDropOptions = {}) { (folderId: string | null): SiblingItem[] => { const cacheKey = folderId ?? 'root' if (!isDraggingRef.current) { - const cached = siblingsCacheRef.current.get(cacheKey) + const cached = siblingsCacheRef.current?.get(cacheKey) if (cached) return cached } @@ -302,7 +302,8 @@ export function useDragDrop(options: UseDragDropOptions = {}) { ].sort(compareSiblingItems) if (!isDraggingRef.current) { - siblingsCacheRef.current.set(cacheKey, siblings) + const cache = (siblingsCacheRef.current ??= new Map()) + cache.set(cacheKey, siblings) } return siblings }, @@ -474,7 +475,7 @@ export function useDragDrop(options: UseDragDropOptions = {}) { setDropIndicator(null) isDraggingRef.current = false setIsDragging(false) - siblingsCacheRef.current.clear() + siblingsCacheRef.current?.clear() if (!indicator) return @@ -614,7 +615,7 @@ export function useDragDrop(options: UseDragDropOptions = {}) { const handleDragStart = useCallback((sourceFolderId: string | null) => { draggedSourceFolderRef.current = sourceFolderId - siblingsCacheRef.current.clear() + siblingsCacheRef.current?.clear() isDraggingRef.current = true setIsDragging(true) }, []) @@ -626,7 +627,7 @@ export function useDragDrop(options: UseDragDropOptions = {}) { setDropIndicator(null) draggedSourceFolderRef.current = null setHoverFolderId(null) - siblingsCacheRef.current.clear() + siblingsCacheRef.current?.clear() }, []) useEffect(() => { diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-sidebar-list-context.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-sidebar-list-context.ts index bb296c988ec..fc74e5ebb0f 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-sidebar-list-context.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-sidebar-list-context.ts @@ -1,12 +1,18 @@ 'use client' -import { createContext, useContext, useMemo } from 'react' +import { createContext, type RefObject, useContext, useMemo } from 'react' interface SidebarListContextValue { /** Whether any drag operation is currently in progress */ isAnyDragActive: boolean /** Whether item dragging is disabled (e.g. viewer permissions) */ dragDisabled: boolean + /** + * Live id of the workflow open in the URL, held in a ref so rows read it at + * click/delete time without subscribing to `useParams` — which would re-render + * every row on each tab navigation and defeat their memoization. + */ + activeWorkflowIdRef: RefObject /** Selects a workflow on click (single or shift-range selection) */ onWorkflowClick: (workflowId: string, shiftKey: boolean) => void /** Selects a folder on modifier-click (shift-range or cmd/ctrl-toggle selection) */ @@ -18,6 +24,7 @@ interface SidebarListContextValue { } const noop = () => {} +const noopActiveWorkflowIdRef: RefObject = { current: undefined } /** * Context for sharing list-item interaction handlers and drag state across @@ -27,6 +34,7 @@ const noop = () => {} export const SidebarListContext = createContext({ isAnyDragActive: false, dragDisabled: false, + activeWorkflowIdRef: noopActiveWorkflowIdRef, onWorkflowClick: noop, onFolderClick: noop, onItemDragStart: noop, @@ -55,6 +63,7 @@ export function useSidebarListContextValue( const { isAnyDragActive, dragDisabled, + activeWorkflowIdRef, onWorkflowClick, onFolderClick, onItemDragStart, @@ -65,11 +74,20 @@ export function useSidebarListContextValue( () => ({ isAnyDragActive, dragDisabled, + activeWorkflowIdRef, onWorkflowClick, onFolderClick, onItemDragStart, onItemDragEnd, }), - [isAnyDragActive, dragDisabled, onWorkflowClick, onFolderClick, onItemDragStart, onItemDragEnd] + [ + isAnyDragActive, + dragDisabled, + activeWorkflowIdRef, + onWorkflowClick, + onFolderClick, + onItemDragStart, + onItemDragEnd, + ] ) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-workflow-selection.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-workflow-selection.ts index 2e3d56e4c02..ae9138eb0a7 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-workflow-selection.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-workflow-selection.ts @@ -1,4 +1,4 @@ -import { useCallback } from 'react' +import { useCallback, useRef } from 'react' import { useShallow } from 'zustand/react/shallow' import { useFolderStore } from '@/stores/folders/store' @@ -40,6 +40,17 @@ export function useWorkflowSelection({ })) ) + /** + * Read the click-time anchor values through refs so `handleWorkflowClick` keeps a stable + * identity across tab navigation. `activeWorkflowId` changes on every route change; without + * refs it would churn the shared sidebar list context on each tab switch and defeat the + * memoization of the WorkflowItem/FolderItem rows that consume it. + */ + const workflowIdsRef = useRef(workflowIds) + workflowIdsRef.current = workflowIds + const activeWorkflowIdRef = useRef(activeWorkflowId) + activeWorkflowIdRef.current = activeWorkflowId + /** * After a workflow selection change, deselect any folder that is an ancestor of a selected * workflow to prevent ancestor-descendant co-selection. @@ -68,8 +79,9 @@ export function useWorkflowSelection({ */ const handleWorkflowClick = useCallback( (workflowId: string, shiftKey: boolean) => { + const activeWorkflowId = activeWorkflowIdRef.current if (shiftKey && activeWorkflowId && activeWorkflowId !== workflowId) { - selectRange(workflowIds, activeWorkflowId, workflowId) + selectRange(workflowIdsRef.current, activeWorkflowId, workflowId) deselectConflictingFolders() } else if (shiftKey) { toggleWorkflowSelection(workflowId) @@ -78,14 +90,7 @@ export function useWorkflowSelection({ selectOnly(workflowId) } }, - [ - workflowIds, - activeWorkflowId, - selectOnly, - selectRange, - toggleWorkflowSelection, - deselectConflictingFolders, - ] + [selectOnly, selectRange, toggleWorkflowSelection, deselectConflictingFolders] ) return { diff --git a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-export-folder.ts b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-export-folder.ts index 32062604614..0b02274ff6e 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-export-folder.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-export-folder.ts @@ -1,6 +1,5 @@ import { useCallback, useMemo, useState } from 'react' import { createLogger } from '@sim/logger' -import { useParams } from 'next/navigation' import { getFolderById } from '@/lib/folders/tree' import { downloadFile, @@ -19,6 +18,10 @@ import type { WorkflowMetadata } from '@/stores/workflows/registry/types' const logger = createLogger('useExportFolder') interface UseExportFolderProps { + /** + * Active workspace id + */ + workspaceId: string | undefined /** * The folder ID to export */ @@ -91,8 +94,7 @@ function collectSubfolders( /** * Hook for managing folder export to ZIP. */ -export function useExportFolder({ folderId, onSuccess }: UseExportFolderProps) { - const { workspaceId } = useParams<{ workspaceId: string }>() +export function useExportFolder({ workspaceId, folderId, onSuccess }: UseExportFolderProps) { const { data: workflows = {} } = useWorkflowMap(workspaceId) const { data: folders = {} } = useFolderMap(workspaceId) const [isExporting, setIsExporting] = useState(false) diff --git a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-export-selection.ts b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-export-selection.ts index ea6fb5f6432..0a1bf25b4d7 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-export-selection.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-export-selection.ts @@ -1,6 +1,5 @@ import { useCallback, useRef, useState } from 'react' import { createLogger } from '@sim/logger' -import { useParams } from 'next/navigation' import { downloadFile, exportWorkflowsToZip, @@ -17,6 +16,10 @@ import type { WorkflowMetadata } from '@/stores/workflows/registry/types' const logger = createLogger('useExportSelection') interface UseExportSelectionProps { + /** + * Active workspace id + */ + workspaceId: string | undefined /** * Optional callback after successful export */ @@ -88,11 +91,8 @@ function collectSubfoldersForMultipleFolders( * Handles mixed selection by collecting all workflows from selected folders * and combining with directly selected workflows. */ -export function useExportSelection({ onSuccess }: UseExportSelectionProps = {}) { +export function useExportSelection({ workspaceId, onSuccess }: UseExportSelectionProps) { const [isExporting, setIsExporting] = useState(false) - const params = useParams() - const workspaceId = params.workspaceId as string | undefined - const onSuccessRef = useRef(onSuccess) onSuccessRef.current = onSuccess diff --git a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-export-workflow.ts b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-export-workflow.ts index ded2b2ac8e4..df24cdda8e2 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-export-workflow.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-export-workflow.ts @@ -1,6 +1,5 @@ import { useCallback, useRef, useState } from 'react' import { createLogger } from '@sim/logger' -import { useParams } from 'next/navigation' import { usePostHog } from 'posthog-js/react' import { captureEvent } from '@/lib/posthog/client' import { @@ -16,6 +15,10 @@ import { useFolderStore } from '@/stores/folders/store' const logger = createLogger('useExportWorkflow') interface UseExportWorkflowProps { + /** + * Active workspace id + */ + workspaceId: string | undefined /** * Optional callback after successful export */ @@ -25,10 +28,8 @@ interface UseExportWorkflowProps { /** * Hook for managing workflow export to JSON or ZIP. */ -export function useExportWorkflow({ onSuccess }: UseExportWorkflowProps = {}) { +export function useExportWorkflow({ workspaceId, onSuccess }: UseExportWorkflowProps) { const [isExporting, setIsExporting] = useState(false) - const params = useParams() - const workspaceId = params.workspaceId as string | undefined const posthog = usePostHog() const onSuccessRef = useRef(onSuccess) From 4fa1e045bd5d878d38cd867e134a373077deba52 Mon Sep 17 00:00:00 2001 From: Waleed Date: Sun, 5 Jul 2026 11:05:09 -0700 Subject: [PATCH 05/35] fix(sidebar): prefetch folders and workspace permissions on cold load (#5426) * fix(sidebar): prefetch folders and workspace permissions on cold load * refactor(sidebar): dedupe folder query and permission lookups from /simplify pass * docs(workspaces): trim getWorkspacePermissionsForViewer tsdoc to match repo convention * fix(folders): reference FOLDER_LIST_STALE_TIME constant instead of duplicating literal --- apps/sim/app/api/folders/route.ts | 15 +------- .../api/workspaces/[id]/permissions/route.ts | 34 ++--------------- .../app/workspace/[workspaceId]/prefetch.ts | 37 +++++++++++++++--- apps/sim/lib/folders/queries.ts | 32 ++++++++++++++++ apps/sim/lib/workspaces/permissions/utils.ts | 38 +++++++++++++++++++ 5 files changed, 107 insertions(+), 49 deletions(-) create mode 100644 apps/sim/lib/folders/queries.ts diff --git a/apps/sim/app/api/folders/route.ts b/apps/sim/app/api/folders/route.ts index d9206b0caeb..6310d7f4696 100644 --- a/apps/sim/app/api/folders/route.ts +++ b/apps/sim/app/api/folders/route.ts @@ -1,13 +1,11 @@ -import { db } from '@sim/db' -import { workflowFolder } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { assertFolderMutable, FolderLockedError } from '@sim/platform-authz/workflow' -import { and, asc, eq, isNotNull, isNull } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { createFolderContract, listFoldersContract } from '@/lib/api/contracts' import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { listFoldersForWorkspace } from '@/lib/folders/queries' import { captureServerEvent } from '@/lib/posthog/server' import { performCreateFolder } from '@/lib/workflows/orchestration' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' @@ -44,16 +42,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: 'Access denied to this workspace' }, { status: 403 }) } - const archivedFilter = - scope === 'archived' - ? isNotNull(workflowFolder.archivedAt) - : isNull(workflowFolder.archivedAt) - - const folders = await db - .select() - .from(workflowFolder) - .where(and(eq(workflowFolder.workspaceId, workspaceId), archivedFilter)) - .orderBy(asc(workflowFolder.sortOrder), asc(workflowFolder.createdAt)) + const folders = await listFoldersForWorkspace(workspaceId, scope) return NextResponse.json({ folders }) } catch (error) { diff --git a/apps/sim/app/api/workspaces/[id]/permissions/route.ts b/apps/sim/app/api/workspaces/[id]/permissions/route.ts index 43a450765b5..3086192a6eb 100644 --- a/apps/sim/app/api/workspaces/[id]/permissions/route.ts +++ b/apps/sim/app/api/workspaces/[id]/permissions/route.ts @@ -13,11 +13,9 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { syncWorkspaceEnvCredentials } from '@/lib/credentials/environment' import { captureServerEvent } from '@/lib/posthog/server' import { - checkWorkspaceAccess, - getUserEntityPermissions, getUsersWithPermissions, + getWorkspacePermissionsForViewer, hasWorkspaceAdminAccess, - type PermissionType, } from '@/lib/workspaces/permissions/utils' const logger = createLogger('WorkspacesPermissionsAPI') @@ -41,37 +39,13 @@ export const GET = withRouteHandler( return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) } - const isAdmin = await hasWorkspaceAdminAccess(session.user.id, workspaceId) - const access = await checkWorkspaceAccess(workspaceId, session.user.id) + const result = await getWorkspacePermissionsForViewer(workspaceId, session.user.id) - if (!access.exists) { + if (!result) { return NextResponse.json({ error: 'Workspace not found or access denied' }, { status: 404 }) } - if (!isAdmin && !access.hasAccess) { - return NextResponse.json({ error: 'Workspace not found or access denied' }, { status: 404 }) - } - - const explicitPermission = await getUserEntityPermissions( - session.user.id, - 'workspace', - workspaceId - ) - const viewerPermissionType: PermissionType = isAdmin - ? 'admin' - : (explicitPermission ?? 'read') - - const result = await getUsersWithPermissions(workspaceId) - - return NextResponse.json({ - users: result, - total: result.length, - viewer: { - userId: session.user.id, - isAdmin, - permissionType: viewerPermissionType, - }, - }) + return NextResponse.json(result) } catch (error) { logger.error('Error fetching workspace permissions:', error) return NextResponse.json({ error: 'Failed to fetch workspace permissions' }, { status: 500 }) diff --git a/apps/sim/app/workspace/[workspaceId]/prefetch.ts b/apps/sim/app/workspace/[workspaceId]/prefetch.ts index 9eebb7f56ea..729d2dc0ad8 100644 --- a/apps/sim/app/workspace/[workspaceId]/prefetch.ts +++ b/apps/sim/app/workspace/[workspaceId]/prefetch.ts @@ -1,14 +1,21 @@ import type { QueryClient } from '@tanstack/react-query' import { listMothershipChats } from '@/lib/copilot/chat/list-mothership-chats' +import { listFoldersForWorkspace } from '@/lib/folders/queries' import { listWorkflowsForUser } from '@/lib/workflows/queries' -import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' +import { + checkWorkspaceAccess, + getWorkspacePermissionsForViewer, +} from '@/lib/workspaces/permissions/utils' +import { FOLDER_LIST_STALE_TIME, mapFolder } from '@/hooks/queries/folders' import { MOTHERSHIP_CHAT_LIST_STALE_TIME, mapChat, mothershipChatKeys, } from '@/hooks/queries/mothership-chats' +import { folderKeys } from '@/hooks/queries/utils/folder-keys' import { workflowKeys } from '@/hooks/queries/utils/workflow-keys' import { mapWorkflow, WORKFLOW_LIST_STALE_TIME } from '@/hooks/queries/utils/workflow-list-query' +import { WORKSPACE_PERMISSIONS_STALE_TIME, workspaceKeys } from '@/hooks/queries/workspace' /** Resolves whether the user may access the workspace, swallowing errors to a `false`. */ async function userCanAccessWorkspace(workspaceId: string, userId: string): Promise { @@ -21,11 +28,12 @@ async function userCanAccessWorkspace(workspaceId: string, userId: string): Prom } /** - * Prefetches the sidebar's workflow + chat lists for a workspace and stores them - * under the same query keys + mappers the client hooks use, so the persistent - * sidebar paints populated on the first server render instead of flashing skeletons - * on a cold load (e.g. after the browser discards an idle tab). Calls the data layer - * directly — the same functions the API routes use — with no internal HTTP hop. + * Prefetches the sidebar's workflow, chat, folder, and workspace-permissions lists for + * a workspace and stores them under the same query keys + mappers the client hooks use, + * so the persistent sidebar paints populated on the first server render instead of + * flashing skeletons on a cold load (e.g. after the browser discards an idle tab). Calls + * the data layer directly — the same functions the API routes use — with no internal + * HTTP hop. * * Skips silently when the user can't access the workspace, leaving the client to * fetch and surface the real error instead of caching an empty list. @@ -53,5 +61,22 @@ export async function prefetchWorkspaceSidebar( }, staleTime: MOTHERSHIP_CHAT_LIST_STALE_TIME, }), + queryClient.prefetchQuery({ + queryKey: folderKeys.list(workspaceId, 'active'), + queryFn: async () => { + const rows = await listFoldersForWorkspace(workspaceId, 'active') + return rows.map(mapFolder) + }, + staleTime: FOLDER_LIST_STALE_TIME, + }), + queryClient.prefetchQuery({ + queryKey: workspaceKeys.permissions(workspaceId), + queryFn: async () => { + const result = await getWorkspacePermissionsForViewer(workspaceId, userId) + if (!result) throw new Error('Workspace not found or access denied') + return result + }, + staleTime: WORKSPACE_PERMISSIONS_STALE_TIME, + }), ]) } diff --git a/apps/sim/lib/folders/queries.ts b/apps/sim/lib/folders/queries.ts new file mode 100644 index 00000000000..9518b1672d8 --- /dev/null +++ b/apps/sim/lib/folders/queries.ts @@ -0,0 +1,32 @@ +import { db } from '@sim/db' +import { workflowFolder } from '@sim/db/schema' +import { and, asc, eq, isNotNull, isNull } from 'drizzle-orm' +import type { FolderApi } from '@/lib/api/contracts/folders' +import type { FolderQueryScope } from '@/hooks/queries/utils/folder-keys' + +/** Normalizes timestamp columns to ISO strings to honor the `FolderApi` wire contract. */ +function toFolderApi(row: typeof workflowFolder.$inferSelect): FolderApi { + return { + ...row, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + archivedAt: row.archivedAt ? row.archivedAt.toISOString() : null, + } +} + +/** Shared by `GET /api/folders` and the sidebar prefetch so the query never drifts between them. */ +export async function listFoldersForWorkspace( + workspaceId: string, + scope: FolderQueryScope +): Promise { + const archivedFilter = + scope === 'archived' ? isNotNull(workflowFolder.archivedAt) : isNull(workflowFolder.archivedAt) + + const rows = await db + .select() + .from(workflowFolder) + .where(and(eq(workflowFolder.workspaceId, workspaceId), archivedFilter)) + .orderBy(asc(workflowFolder.sortOrder), asc(workflowFolder.createdAt)) + + return rows.map(toFolderApi) +} diff --git a/apps/sim/lib/workspaces/permissions/utils.ts b/apps/sim/lib/workspaces/permissions/utils.ts index 3ec92b4c131..4892a65d269 100644 --- a/apps/sim/lib/workspaces/permissions/utils.ts +++ b/apps/sim/lib/workspaces/permissions/utils.ts @@ -381,6 +381,44 @@ export async function getWorkspaceMemberProfiles( return rows } +export interface WorkspacePermissionsForViewer { + users: WorkspaceMemberWithRole[] + total: number + viewer: { + userId: string + isAdmin: boolean + permissionType: PermissionType + } +} + +/** + * Builds the workspace permissions payload for a viewer: the full member list plus + * the viewer's own resolved permission. Shared by `GET /api/workspaces/[id]/permissions` + * and the sidebar prefetch so the two never drift. + * + * @param workspaceId - The workspace ID to build permissions for + * @param userId - The viewer's user ID + * @returns The permissions payload, or `null` if the workspace doesn't exist or the viewer lacks access + */ +export async function getWorkspacePermissionsForViewer( + workspaceId: string, + userId: string +): Promise { + const ws = await getWorkspaceWithOwner(workspaceId) + if (!ws) return null + + const permission = await getEffectiveWorkspacePermission(userId, ws) + if (permission === null) return null + + const users = await getUsersWithPermissions(workspaceId) + + return { + users, + total: users.length, + viewer: { userId, isAdmin: permission === 'admin', permissionType: permission }, + } +} + /** * Check if a user has admin access to a specific workspace * From 8bc0d9717cb7dcca8cbd9f18d5fb2c1992ce6ab6 Mon Sep 17 00:00:00 2001 From: Waleed Date: Mon, 6 Jul 2026 10:29:44 -0700 Subject: [PATCH 06/35] fix(mailer): correct AgentMail webhook message schema field requirements (#5434) cc, attachments, and html are omitted from AgentMail's webhook payload when empty rather than sent as empty arrays/null, but the zod schema marked them required. Every inbound email without a CC recipient or attachment (the common case) failed schema validation before a task row was ever created, silently breaking Sim Mailer inbox ingestion since the feature shipped. Renames from_ to from to match AgentMail's documented field name. --- apps/sim/app/api/webhooks/agentmail/route.ts | 12 ++++++------ apps/sim/lib/api/contracts/webhooks.ts | 12 ++++++------ apps/sim/lib/mothership/inbox/types.ts | 12 ++++++------ 3 files changed, 18 insertions(+), 18 deletions(-) diff --git a/apps/sim/app/api/webhooks/agentmail/route.ts b/apps/sim/app/api/webhooks/agentmail/route.ts index b31ae2563a9..0dc99567746 100644 --- a/apps/sim/app/api/webhooks/agentmail/route.ts +++ b/apps/sim/app/api/webhooks/agentmail/route.ts @@ -152,8 +152,8 @@ export const POST = withRouteHandler(async (req: Request) => { return NextResponse.json({ ok: true }) } - const fromEmail = extractSenderEmail(message.from_) || '' - logger.info('Webhook received', { fromEmail, from_raw: message.from_, workspaceId: result.id }) + const fromEmail = extractSenderEmail(message.from) || '' + logger.info('Webhook received', { fromEmail, from_raw: message.from, workspaceId: result.id }) if (result.inboxAddress && fromEmail === result.inboxAddress.toLowerCase()) { logger.info('Skipping email from inbox itself', { workspaceId: result.id }) @@ -212,7 +212,7 @@ export const POST = withRouteHandler(async (req: Request) => { const chatId = parentTaskResult[0]?.chatId ?? null - const fromName = extractDisplayName(message.from_) + const fromName = extractDisplayName(message.from) const taskId = generateId() const bodyText = message.text?.substring(0, 50_000) || null @@ -334,8 +334,8 @@ async function createRejectedTask( await db.insert(mothershipInboxTask).values({ id: generateId(), workspaceId, - fromEmail: extractSenderEmail(message.from_) || 'unknown', - fromName: extractDisplayName(message.from_), + fromEmail: extractSenderEmail(message.from) || 'unknown', + fromName: extractDisplayName(message.from), subject: message.subject || '(no subject)', bodyPreview: (message.text || '').substring(0, 200) || null, emailMessageId: message.message_id, @@ -347,7 +347,7 @@ async function createRejectedTask( } /** - * Extract the raw email address from AgentMail's from_ field. + * Extract the raw email address from AgentMail's from field. * Format: "username@domain.com" or "Display Name " */ function extractSenderEmail(from: string): string { diff --git a/apps/sim/lib/api/contracts/webhooks.ts b/apps/sim/lib/api/contracts/webhooks.ts index b28853b9c6b..ca309c6f44e 100644 --- a/apps/sim/lib/api/contracts/webhooks.ts +++ b/apps/sim/lib/api/contracts/webhooks.ts @@ -102,16 +102,16 @@ export const agentMailMessageSchema = z thread_id: z.string(), inbox_id: z.string(), organization_id: z.string().optional(), - from_: z.string(), + from: z.string(), to: z.array(z.string()), - cc: z.array(z.string()), + cc: z.array(z.string()).optional(), bcc: z.array(z.string()).optional(), reply_to: z.array(z.string()).optional(), - subject: z.string(), + subject: z.string().optional(), preview: z.string().optional(), - text: z.string().nullable(), - html: z.string().nullable(), - attachments: z.array(agentMailAttachmentSchema), + text: z.string().nullable().optional(), + html: z.string().nullable().optional(), + attachments: z.array(agentMailAttachmentSchema).optional(), in_reply_to: z.string().optional(), references: z.array(z.string()).optional(), labels: z.array(z.string()).optional(), diff --git a/apps/sim/lib/mothership/inbox/types.ts b/apps/sim/lib/mothership/inbox/types.ts index 02fab8a1e9a..f79685c9cf6 100644 --- a/apps/sim/lib/mothership/inbox/types.ts +++ b/apps/sim/lib/mothership/inbox/types.ts @@ -57,16 +57,16 @@ export interface AgentMailMessage { thread_id: string inbox_id: string organization_id?: string - from_: string + from: string to: string[] - cc: string[] + cc?: string[] bcc?: string[] reply_to?: string[] - subject: string + subject?: string preview?: string - text: string | null - html: string | null - attachments: AgentMailAttachment[] + text?: string | null + html?: string | null + attachments?: AgentMailAttachment[] in_reply_to?: string references?: string[] labels?: string[] From 89e5a142e2456275c5e909689c044544ed42d03c Mon Sep 17 00:00:00 2001 From: Waleed Date: Mon, 6 Jul 2026 11:49:06 -0700 Subject: [PATCH 07/35] fix(analytics): GTM/GA4 missing on /demo and other landing pages (#5437) * fix(analytics): apply runtime CSP to all landing pages, not just / GTM/GA4 script-src and connect-src allowlist entries are gated behind isHosted, which next.config.ts's build-time CSP bakes in from whatever NEXT_PUBLIC_APP_URL was resolved at build/boot time. Only the homepage route was overridden with the request-time CSP in proxy.ts, so every other landing page (including /demo) silently lost the googletagmanager.com/google-analytics.com allowlist and GTM never fired. * style: drop inline comment from proxy CSP fix * fix(analytics): apply runtime CSP to authenticated /invite pages too handleInvitationRedirects returns NextResponse.next() for authenticated users, bypassing the catch-all block entirely, so it never picked up the runtime CSP fix in the same way. Same class of gap flagged by review. --- apps/sim/proxy.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/apps/sim/proxy.ts b/apps/sim/proxy.ts index 1d5e0581589..48213786dad 100644 --- a/apps/sim/proxy.ts +++ b/apps/sim/proxy.ts @@ -183,7 +183,11 @@ function handleInvitationRedirects( new URL(`/login?callbackUrl=${callbackParam}&invite_flow=true`, request.url) ) } - return NextResponse.next() + const response = NextResponse.next() + response.headers.set('Content-Security-Policy', generateRuntimeCSP()) + response.headers.set('X-Content-Type-Options', 'nosniff') + response.headers.set('X-Frame-Options', 'SAMEORIGIN') + return response } /** @@ -283,11 +287,9 @@ export async function proxy(request: NextRequest) { const response = NextResponse.next() response.headers.set('Vary', 'User-Agent') - if (url.pathname === '/') { - response.headers.set('Content-Security-Policy', generateRuntimeCSP()) - response.headers.set('X-Content-Type-Options', 'nosniff') - response.headers.set('X-Frame-Options', 'SAMEORIGIN') - } + response.headers.set('Content-Security-Policy', generateRuntimeCSP()) + response.headers.set('X-Content-Type-Options', 'nosniff') + response.headers.set('X-Frame-Options', 'SAMEORIGIN') return track(request, response) } From 5acdaeea020154a0d59de2b4864e264141357664 Mon Sep 17 00:00:00 2001 From: Waleed Date: Mon, 6 Jul 2026 12:20:58 -0700 Subject: [PATCH 08/35] feat(rich-md-editor): table row/col toolbar, raw HTML/footnote support, paste fidelity (#5438) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(rich-md-editor): table row/col toolbar, raw HTML/footnote support, paste fidelity - Add a table toolbar (add/delete row, add/delete column, toggle header, delete table) wired to stock @tiptap/extension-table commands - Add verbatim snippet nodes for raw HTML blocks, HTML comments, footnotes (def + ref), and inline raw HTML tags — these no longer force the whole document read-only, and the raw source is directly editable in place - Fix an upstream @tiptap/markdown footgun found while building this: a custom tokenizer with no explicit `start` callback corrupts the shared lexer and silently drops unrelated content elsewhere in the document - Prefer the markdown parser over generic HTML→DOM paste mapping when the plaintext clipboard side looks like markdown, even if an HTML sibling is present - Fix a stale select-all selection surviving a stream-settle content replace, which permanently highlighted every divider/image after a file regeneration * fix(rich-md-editor): address review findings from initial PR round - Collapse the stream-settle selection unconditionally, not only when setContent re-runs — the last streaming tick already syncs lastSyncedBodyRef to the final body, so the fix was previously skipped in the common streamed-content case (Cursor Bugbot) - Support GFM footnote definition continuation lines (>=4-space indented, with blank lines between paragraphs) instead of truncating to just the opening line (Greptile P1) - Fix the inline raw-HTML tokenizer to find the balanced closing tag by tracking nesting depth, instead of matching the first same-name closing tag — fixes corruption on nested same-tag elements like outer inner (Greptile P1) - Stop caching the table toolbar's anchor rect by selection key — the same cell can move on screen from scrolling alone with no selection change, and the cached rect went stale (Greptile P2) * fix(rich-md-editor): fix CI type errors in raw-markdown-snippet.tsx - parseMarkdown callbacks returned null on no-match, which @tiptap/core's MarkdownParseResult type doesn't permit — switch to returning [] (empty array), matching the same no-match convention MarkdownCodeBlock already uses, with identical runtime behavior - Pin NodeViewContent's generic to 'span' (NodeViewContent<'span'> as='span'), since it defaults to 'div' and rejects other `as` values without an explicit type argument Verified with a full `tsc --noEmit` (NODE_OPTIONS=--max-old-space-size=8192, matching CI) — 0 errors, where it previously failed the Next.js build's type-check step. --- .../rich-markdown-editor/editor-extensions.ts | 3 + .../rich-markdown-editor/extensions.ts | 7 + .../markdown-paste.test.ts | 27 +- .../rich-markdown-editor/markdown-paste.ts | 13 +- .../menus/table-menu.test.ts | 131 +++++++ .../rich-markdown-editor/menus/table-menu.tsx | 128 +++++++ .../raw-markdown-snippet-view.test.ts | 123 +++++++ .../raw-markdown-snippet.test.ts | 98 ++++++ .../raw-markdown-snippet.tsx | 329 ++++++++++++++++++ .../rich-markdown-editor.css | 23 ++ .../rich-markdown-editor.tsx | 11 + .../round-trip-safety.test.ts | 28 +- .../rich-markdown-editor/round-trip-safety.ts | 14 +- .../stream-settle-selection.test.ts | 57 +++ 14 files changed, 965 insertions(+), 27 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/table-menu.test.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/table-menu.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/raw-markdown-snippet-view.test.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/raw-markdown-snippet.test.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/raw-markdown-snippet.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/stream-settle-selection.test.ts diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/editor-extensions.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/editor-extensions.ts index 205da75a97d..01a2e2c82dd 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/editor-extensions.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/editor-extensions.ts @@ -9,6 +9,7 @@ import { RichMarkdownKeymap } from './keymap' import { MarkdownPaste } from './markdown-paste' import { Mention } from './mention/mention' import { MentionChip } from './mention/mention-chip' +import { FootnoteDefWithView, RawHtmlBlockWithView } from './raw-markdown-snippet' import { SlashCommand } from './slash-command/slash-command' interface MarkdownEditorExtensionOptions { @@ -36,6 +37,8 @@ export function createMarkdownEditorExtensions({ codeBlock: CodeBlockWithLanguage, image: ResizableImage, mention: MentionChip, + rawHtmlBlock: RawHtmlBlockWithView, + footnoteDef: FootnoteDefWithView, }), CodeBlockHighlight, SlashCommand, diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions.ts index c99abf36c1c..1f29545bebb 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions.ts @@ -15,6 +15,7 @@ import { MarkdownImage } from './image' import { MarkdownLinkInputRule } from './link-input-rule' import { MarkdownMention } from './mention/mention-node' import { SIM_LINK_SCHEME } from './mention/sim-link' +import { FootnoteDef, FootnoteRef, RawHtmlBlock, RawInlineHtml } from './raw-markdown-snippet' /** * The `@`-mention link scheme, registered on the Link mark — without it the schema strips the @@ -66,6 +67,8 @@ export interface ContentNodeViews { codeBlock?: Node image?: Node mention?: Node + rawHtmlBlock?: Node + footnoteDef?: Node } /** @@ -100,6 +103,10 @@ export function createMarkdownContentExtensions(nodeViews: ContentNodeViews = {} TableRow, TableHeader, TableCell, + nodeViews.rawHtmlBlock ?? RawHtmlBlock, + nodeViews.footnoteDef ?? FootnoteDef, + FootnoteRef, + RawInlineHtml, MarkdownLinkInputRule, Markdown, ] diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-paste.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-paste.test.ts index 249f26512f5..3311dd5ea52 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-paste.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-paste.test.ts @@ -58,9 +58,32 @@ describe('markdown paste', () => { expect(paste(editor, 'just a normal sentence with no syntax')).toBe(false) }) - it('does not markdown-parse a paste that carries richer HTML', () => { + it("prefers the markdown parser over DOM mapping when the HTML sibling's plain-text side also looks like markdown", () => { editor = mount() - expect(paste(editor, '# heading', '

heading

')).toBe(false) + expect(paste(editor, '# heading', '

heading

')).toBe(true) + const json = JSON.stringify(editor.getJSON()) + expect(json).toContain('"type":"heading"') + }) + + it('preserves GFM table alignment on a paste that carries both text/plain and text/html', () => { + editor = mount() + const table = '| a | b |\n| :-- | --: |\n| 1 | 2 |' + const html = '
ab
12
' + expect(paste(editor, table, html)).toBe(true) + const json = JSON.stringify(editor.getJSON()) + expect(json).toContain('"align":"left"') + expect(json).toContain('"align":"right"') + }) + + it('still defers to DOM mapping when the HTML sibling has no markdown-shaped plain-text counterpart', () => { + editor = mount() + expect( + paste( + editor, + 'just a normal sentence with no syntax', + '

just a normal sentence with no syntax

' + ) + ).toBe(false) }) it('keeps pasted markdown literal inside a code block', () => { diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-paste.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-paste.ts index 808c0fa377e..48bca04aceb 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-paste.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-paste.ts @@ -24,8 +24,15 @@ function looksLikeMarkdown(text: string): boolean { /** * Parses pasted plain text that looks like markdown into rich content. Pastes inside a code block - * are left untouched (code is meant to stay literal), as are pastes that carry richer HTML — those - * are handled by ProseMirror's own clipboard parsing. + * are left untouched (code is meant to stay literal). + * + * A clipboard entry that also carries `text/html` (copied from a browser, Slack, Notion, GitHub, + * or this editor itself) used to always defer entirely to ProseMirror's generic HTML→DOM mapping, + * even when the `text/plain` sibling was clean markdown our own parser round-trips more faithfully + * (GFM table alignment, escaping, the constructs `./raw-markdown-snippet.ts` now preserves). Only + * defer to DOM mapping when the plain-text sibling *doesn't* look like markdown — an HTML clipboard + * payload with no markdown-shaped plain-text counterpart (a genuinely rich paste from a word + * processor, a web page selection, …) still goes through the DOM path unchanged. */ export const MarkdownPaste = Extension.create({ name: 'markdownPaste', @@ -38,8 +45,6 @@ export const MarkdownPaste = Extension.create({ handlePaste: (_view, event) => { if (!editor.isEditable) return false if (editor.isActive('codeBlock')) return false - const html = event.clipboardData?.getData('text/html') - if (html) return false const text = event.clipboardData?.getData('text/plain') if (!text || !looksLikeMarkdown(text)) return false // Parse through the chunker (linear) so pasting a large markdown blob can't freeze the diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/table-menu.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/table-menu.test.ts new file mode 100644 index 00000000000..8c4346499e5 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/table-menu.test.ts @@ -0,0 +1,131 @@ +/** + * @vitest-environment jsdom + * + * `TableBubbleMenu` (table-menu.tsx) is a thin UI wrapper around `@tiptap/extension-table`'s stock + * commands — the button that matters is the command it calls, not the floating-toolbar chrome. These + * exercise the exact commands the toolbar wires up (`addRowBefore`/`addRowAfter`/`deleteRow`, + * `addColumnBefore`/`addColumnAfter`/`deleteColumn`, `toggleHeaderRow`, `deleteTable`) against a real + * editor and assert the result round-trips through `PipeSafeTable` to clean, correctly-shaped GFM. + */ +import { Editor } from '@tiptap/core' +import { afterEach, describe, expect, it } from 'vitest' +import { createMarkdownContentExtensions } from '../extensions' + +let editor: Editor | null = null +afterEach(() => { + editor?.destroy() + editor = null +}) + +function mount(markdown: string): Editor { + return new Editor({ + extensions: createMarkdownContentExtensions(), + content: markdown, + contentType: 'markdown', + }) +} + +function firstCellPos(ed: Editor): number { + let pos = -1 + ed.state.doc.descendants((node, p) => { + if (pos < 0 && (node.type.name === 'tableCell' || node.type.name === 'tableHeader')) pos = p + 1 + }) + return pos +} + +describe('table toolbar commands', () => { + it('inserts a row after the current row and it round-trips as a clean GFM table', () => { + editor = mount('| a | b |\n| --- | --- |\n| 1 | 2 |') + editor.commands.setTextSelection(firstCellPos(editor)) + expect(editor.commands.addRowAfter()).toBe(true) + + const rows = editor.state.doc.firstChild + expect(rows?.type.name).toBe('table') + expect(rows?.childCount).toBe(3) // header + original row + inserted row + + const md = editor.getMarkdown().trim() + expect(md.split('\n')).toHaveLength(4) + expect(md).toContain('| a') + expect(md).toContain('| --- | --- |') + }) + + it('inserts a row before the current row', () => { + editor = mount('| a | b |\n| --- | --- |\n| 1 | 2 |') + editor.commands.setTextSelection(firstCellPos(editor)) + expect(editor.commands.addRowBefore()).toBe(true) + expect(editor.state.doc.firstChild?.childCount).toBe(3) + }) + + it('deletes the current row', () => { + editor = mount('| a | b |\n| --- | --- |\n| 1 | 2 |\n| 3 | 4 |') + // Select the second body row (skip header). + let pos = -1 + let seen = 0 + editor.state.doc.descendants((node, p) => { + if (node.type.name === 'tableRow') { + seen++ + if (seen === 3) pos = p + 2 + } + }) + editor.commands.setTextSelection(pos) + expect(editor.commands.deleteRow()).toBe(true) + expect(editor.state.doc.firstChild?.childCount).toBe(2) + expect(editor.getMarkdown()).toContain('| 1 | 2 |') + expect(editor.getMarkdown()).not.toContain('3') + }) + + it('inserts and deletes a column', () => { + editor = mount('| a | b |\n| --- | --- |\n| 1 | 2 |') + editor.commands.setTextSelection(firstCellPos(editor)) + expect(editor.commands.addColumnAfter()).toBe(true) + let cols = 0 + editor.state.doc.descendants((node) => { + if (node.type.name === 'tableRow' && cols === 0) cols = node.childCount + }) + expect(cols).toBe(3) + + // The insert shifted positions — the cursor's old cell no longer maps to the same column, so + // re-select the first cell before deleting, exactly as a real user would click a cell first. + editor.commands.setTextSelection(firstCellPos(editor)) + expect(editor.commands.deleteColumn()).toBe(true) + editor.state.doc.descendants((node) => { + if (node.type.name === 'tableRow') cols = node.childCount + }) + expect(cols).toBe(2) + }) + + it('toggles the header row', () => { + editor = mount('| a | b |\n| --- | --- |\n| 1 | 2 |') + editor.commands.setTextSelection(firstCellPos(editor)) + const before = editor.isActive('tableHeader') + expect(editor.commands.toggleHeaderRow()).toBe(true) + expect(editor.isActive('tableHeader')).toBe(!before) + }) + + it('deletes the whole table', () => { + editor = mount('before\n\n| a | b |\n| --- | --- |\n| 1 | 2 |\n\nafter') + editor.commands.setTextSelection(firstCellPos(editor)) + expect(editor.commands.deleteTable()).toBe(true) + const types: string[] = [] + editor.state.doc.forEach((node) => types.push(node.type.name)) + expect(types).not.toContain('table') + expect(editor.getMarkdown()).toContain('before') + expect(editor.getMarkdown()).toContain('after') + }) + + it('a full add-row + add-column + delete-row sequence stays idempotent on re-serialize', () => { + editor = mount('| a | b |\n| --- | --- |\n| 1 | 2 |') + editor.commands.setTextSelection(firstCellPos(editor)) + editor.commands.addRowAfter() + editor.commands.addColumnAfter() + const once = editor.getMarkdown().trim() + const reparsed = new Editor({ + extensions: createMarkdownContentExtensions(), + content: once, + contentType: 'markdown', + }) + const twice = reparsed.getMarkdown().trim() + reparsed.destroy() + expect(twice).toBe(once) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/table-menu.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/table-menu.tsx new file mode 100644 index 00000000000..e62fa8fdcb0 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/table-menu.tsx @@ -0,0 +1,128 @@ +import { useCallback, useState } from 'react' +import { posToDOMRect } from '@tiptap/core' +import { PluginKey } from '@tiptap/pm/state' +import type { Editor } from '@tiptap/react' +import { useEditorState } from '@tiptap/react' +import { BubbleMenu } from '@tiptap/react/menus' +import { + ArrowDown, + ArrowLeft, + ArrowRight, + ArrowUp, + Columns3, + Rows3, + Table as TableIcon, + Trash2, +} from 'lucide-react' +import { ToolbarButton, ToolbarDivider } from './toolbar-button' + +/** Pins the toolbar to the viewport instead of tracking the (often wide) table as it scrolls horizontally. */ +const FLOATING_OPTIONS = { strategy: 'fixed' } as const + +/** Renders into the body so a transformed/clipping ancestor can't reparent the fixed toolbar and shift it. */ +const APPEND_TO_BODY = () => document.body + +interface TableBubbleMenuProps { + editor: Editor + /** The editor's scrollable viewport, used to keep the toolbar on-screen for a table taller than it. */ + scrollContainerRef: React.RefObject +} + +/** + * Floating toolbar shown whenever the selection is inside a table: row/column insert-before/after, + * row/column delete, header-row toggle, and delete-table. `@tiptap/extension-table` already exposes + * all of these as editor commands (`addRowBefore`, `addColumnAfter`, …) — this is UI only, no schema + * or serializer change. + */ +export function TableBubbleMenu({ editor, scrollContainerRef }: TableBubbleMenuProps) { + const [menuKey] = useState(() => new PluginKey('markdownTableMenu')) + + const active = useEditorState({ + editor, + selector: ({ editor: e }) => ({ + headerRow: e.isActive('tableHeader'), + }), + }) + + // Recomputed on every call (not cached by selection key) — the same table cell can land at a + // different screen position purely from scrolling with no selection change, and Floating UI's + // `autoUpdate` re-invokes this on scroll/resize expecting a fresh rect each time. + const resolveAnchor = useCallback(() => { + const { view, state } = editor + if (!view.dom.isConnected) return null + const { from, to } = state.selection + const selection = posToDOMRect(view, from, to) + const viewport = scrollContainerRef.current?.getBoundingClientRect() + const rect = + viewport && selection.top < viewport.top + ? new DOMRect(selection.left, viewport.top, selection.width, 0) + : selection + return { getBoundingClientRect: () => rect, getClientRects: () => [rect] } + }, [editor, scrollContainerRef]) + + return ( + e.isEditable && e.isActive('table')} + className='fade-in-0 z-[var(--z-popover)] flex animate-in items-center gap-0.5 rounded-lg border border-[var(--border)] bg-[var(--bg)] p-1 shadow-sm duration-150 ease-out motion-reduce:animate-none' + > + editor.chain().focus().addRowBefore().run()} + /> + editor.chain().focus().addRowAfter().run()} + /> + editor.chain().focus().deleteRow().run()} + /> + + editor.chain().focus().addColumnBefore().run()} + /> + editor.chain().focus().addColumnAfter().run()} + /> + editor.chain().focus().deleteColumn().run()} + /> + + editor.chain().focus().toggleHeaderRow().run()} + /> + editor.chain().focus().deleteTable().run()} + /> + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/raw-markdown-snippet-view.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/raw-markdown-snippet-view.test.ts new file mode 100644 index 00000000000..18afe06fd51 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/raw-markdown-snippet-view.test.ts @@ -0,0 +1,123 @@ +/** + * @vitest-environment jsdom + * + * Integration coverage for the *live* editor stack (`createMarkdownEditorExtensions` — the same + * extension set the real component mounts, including the React node views): raw HTML/footnote + * content renders with its wrapper class and exact source in the DOM (not just parsing correctly + * headlessly), and — the point of holding it as `content: 'text*'` rather than an opaque blob — the + * text inside is genuinely editable via a normal ProseMirror transaction, surviving serialization + * back to markdown. + */ +import { Editor } from '@tiptap/core' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createMarkdownEditorExtensions } from './editor-extensions' + +let editor: Editor | null = null + +beforeEach(() => { + // The live extension set's placeholder viewport-tracking and suggestion popups use these; jsdom + // lacks them (see keymap.test.ts for the same stub). + vi.stubGlobal( + 'ResizeObserver', + class { + observe() {} + unobserve() {} + disconnect() {} + } + ) + Element.prototype.scrollIntoView = vi.fn() + document.elementFromPoint = vi.fn(() => null) +}) + +afterEach(() => { + editor?.destroy() + editor = null +}) + +function mount(markdown: string): Editor { + return new Editor({ + extensions: createMarkdownEditorExtensions({ placeholder: '' }), + content: markdown, + contentType: 'markdown', + }) +} + +function posOf(ed: Editor, typeName: string): number { + let pos = -1 + ed.state.doc.descendants((node, p) => { + if (pos < 0 && node.type.name === typeName) pos = p + }) + return pos +} + +/** React node views flush on a microtask after mount, so DOM assertions need one tick. */ +function nextTick(): Promise { + return new Promise((resolve) => setTimeout(resolve, 0)) +} + +// The hover "Raw HTML"/"Footnote" badge is rendered by `RawBlockView` through +// `ReactNodeViewRenderer`, which only flushes its React portal once `@tiptap/react`'s +// `contentComponent` is set — that requires mounting through `` (a real React render +// tree), which this repo's tests don't do for this directory (no `@testing-library/react` installed +// here) and constructing a plain `new Editor()` doesn't provide. What IS verifiable and matters more +// at this level — the node renders with the correct wrapper class and holds the exact raw source +// text — is covered below; the badge itself is decorative chrome, checked manually. +describe('raw markdown snippet node views (live editor)', () => { + it('renders a raw HTML block with the correct wrapper class and exact raw source', async () => { + editor = mount('
More\n\nbody\n\n
') + await nextTick() + const el = editor.view.dom + const block = el.querySelector('.raw-markdown-block') + expect(block).not.toBeNull() + expect(block?.textContent).toContain('
More') + }) + + it('renders a footnote definition block with the correct wrapper class and exact raw source', async () => { + editor = mount('a claim[^1]\n\n[^1]: the source') + await nextTick() + const el = editor.view.dom + const block = el.querySelector('.raw-markdown-block') + expect(block).not.toBeNull() + expect(block?.textContent).toContain('[^1]: the source') + }) + + it('renders inline raw HTML as a distinct inline chip, not a plain paragraph', async () => { + editor = mount('a Ctrl b') + await nextTick() + const el = editor.view.dom + const inline = el.querySelector('.raw-markdown-inline') + expect(inline).not.toBeNull() + expect(inline?.textContent).toBe('Ctrl') + }) + + it('the raw HTML block text is genuinely editable — a text edit round-trips into the markdown', () => { + editor = mount('
\n\ncentered\n\n
') + const pos = posOf(editor, 'rawHtmlBlock') + expect(pos).toBeGreaterThanOrEqual(0) + // Insert text right after the opening tag, simulating a user fixing the raw source in place. + const insertAt = pos + '
'.length + 1 + editor.commands.insertContentAt(insertAt, '!') + expect(editor.getMarkdown()).toContain('
!') + }) + + it('the footnote definition text is genuinely editable', () => { + editor = mount('a claim[^1]\n\n[^1]: old text') + const pos = posOf(editor, 'footnoteDef') + expect(pos).toBeGreaterThanOrEqual(0) + const node = editor.state.doc.nodeAt(pos) + const insertAt = pos + (node?.nodeSize ?? 1) - 1 + editor.commands.insertContentAt(insertAt, ' EDITED') + expect(editor.getMarkdown()).toContain('[^1]: old text EDITED') + }) + + it('a table, a raw HTML block, and a code block all coexist with working node views', async () => { + editor = mount( + '\n\n| a | b |\n| --- | --- |\n| 1 | 2 |\n\n```js\nconst x = 1\n```' + ) + await nextTick() + const el = editor.view.dom + expect(el.querySelector('.raw-markdown-block')).not.toBeNull() + expect(el.querySelector('table')).not.toBeNull() + expect(el.querySelector('pre.code-editor-theme')).not.toBeNull() + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/raw-markdown-snippet.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/raw-markdown-snippet.test.ts new file mode 100644 index 00000000000..e086d0dc27e --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/raw-markdown-snippet.test.ts @@ -0,0 +1,98 @@ +/** + * @vitest-environment jsdom + * + * Parse → serialize round-trip fixtures for the verbatim snippet nodes: raw HTML blocks, HTML + * comments, footnotes (def + ref), and inline raw HTML. Each must reproduce its input byte-for-byte + * and reach a fixpoint on a second pass (see `serializeMarkdownDocument` in `./markdown-parse.ts`). + */ +import { describe, expect, it } from 'vitest' +import { serializeMarkdownDocument } from './markdown-parse' + +function roundTrip(input: string): string { + return serializeMarkdownDocument(input).trim() +} + +describe('raw markdown snippet nodes', () => { + it('preserves a standalone HTML comment', () => { + const input = '\n\ntext' + expect(roundTrip(input)).toBe(input) + expect(roundTrip(roundTrip(input))).toBe(roundTrip(input)) + }) + + it('preserves a multi-line raw HTML block spanning blank lines', () => { + const input = '
More\n\nbody\n\n
' + expect(roundTrip(input)).toBe(input) + expect(roundTrip(roundTrip(input))).toBe(roundTrip(input)) + }) + + it('preserves a raw HTML block with attributes', () => { + const input = '
\n\ncentered\n\n
' + expect(roundTrip(input)).toBe(input) + }) + + it('preserves a footnote reference and definition', () => { + const input = 'a claim[^1]\n\n[^1]: the source' + expect(roundTrip(input)).toBe(input) + expect(roundTrip(roundTrip(input))).toBe(roundTrip(input)) + }) + + it('preserves an inline raw HTML tag the schema has no mark/node for', () => { + for (const input of ['a b c', 'press Ctrl now', 'a hit b']) { + expect(roundTrip(input)).toBe(input) + } + }) + + it('leaves recognized inline tags to their real mark (not captured as raw)', () => { + expect(roundTrip('a b c')).toBe('a *b* c') + expect(roundTrip('a b c')).toBe('a **b** c') + }) + + it('leaves a lone /
block tag to the stock image/hard-break handling', () => { + expect(roundTrip('a')).toContain('![a](/x.png)') + }) + + it('preserves a raw HTML block inside a blockquote', () => { + const input = '>
\n>\n> quoted\n>\n>
' + expect(roundTrip(roundTrip(input))).toBe(roundTrip(input)) + }) + + it('preserves a footnote reference inside a list item', () => { + const input = '- a claim[^1]\n- another line\n\n[^1]: the source' + expect(roundTrip(roundTrip(input))).toBe(roundTrip(input)) + }) + + it('does not interfere with an adjacent table or code block', () => { + const input = + '\n\n| a | b |\n| --- | --- |\n| 1 | 2 |\n\n```js\nconst x = 1\n```' + expect(roundTrip(input)).toBe(input) + }) + + it('preserves a footnote definition with an indented continuation line', () => { + const input = 'a claim[^1]\n\n[^1]: the source\n continued here' + expect(roundTrip(input)).toBe(input) + expect(roundTrip(roundTrip(input))).toBe(roundTrip(input)) + }) + + it('preserves a footnote definition with a blank line between continuation paragraphs', () => { + const input = 'a claim[^1]\n\n[^1]: first paragraph\n\n second paragraph' + expect(roundTrip(input)).toBe(input) + }) + + it('does not swallow the next block into a footnote definition without continuation', () => { + const input = 'a claim[^1]\n\n[^1]: the source\n\nafter' + const out = roundTrip(input) + expect(out).toContain('[^1]: the source') + expect(out).toContain('after') + }) + + it('preserves nested same-tag inline HTML (balanced close, not first-match)', () => { + const input = 'a outer inner b' + expect(roundTrip(input)).toBe(input) + expect(roundTrip(roundTrip(input))).toBe(roundTrip(input)) + }) + + it('preserves a self-closing same-name tag nested inside an inline HTML element', () => { + const input = 'a beforeafter b' + expect(roundTrip(input)).toBe(input) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/raw-markdown-snippet.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/raw-markdown-snippet.tsx new file mode 100644 index 00000000000..9081fe3fa7c --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/raw-markdown-snippet.tsx @@ -0,0 +1,329 @@ +import type { JSONContent, MarkdownToken } from '@tiptap/core' +import { mergeAttributes, Node } from '@tiptap/core' +import type { ReactNodeViewProps } from '@tiptap/react' +import { NodeViewContent, NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react' + +/** + * Constructs the schema has no node/mark for: raw HTML blocks (`
`, `
`, …), HTML + * comments, and footnotes. Before this file, all four made the *entire* document open read-only + * (see {@link isRoundTripSafe in ./round-trip-safety}) because the stock pipeline silently drops + * or mangles them. Each node below instead holds the exact source text as its content and + * re-emits it byte-for-byte on serialize — the same "hold raw source, re-render specially" shape + * `MarkdownCodeBlock` uses for Mermaid (see `./code-block.tsx`), just without the diagram render. + * + * Inline tags already covered by a real mark/node — `em`/`i`, `strong`/`b`, `s`/`del`/`strike`, + * `code`, `a`, `br`, `img` — are deliberately excluded from {@link RawInlineHtml} so they keep + * parsing into their proper mark (e.g. `x` → italic) instead of freezing as raw source. + */ +const HANDLED_INLINE_TAGS = new Set([ + 'br', + 'img', + 'em', + 'i', + 'strong', + 'b', + 's', + 'del', + 'strike', + 'code', + 'a', +]) + +const VOID_TAGS = new Set([ + 'area', + 'base', + 'br', + 'col', + 'embed', + 'hr', + 'img', + 'input', + 'link', + 'meta', + 'param', + 'source', + 'track', + 'wbr', +]) + +function verbatimText(node: JSONContent): string { + return (node.content ?? []).map((child) => child.text ?? '').join('') +} + +/** + * Marked's own block tokenizer greedily consumes the blank-line run *after* an HTML block/comment + * or a def line as part of that token's own `raw` (the same behavior `PipeSafeTable` in + * `./extensions.ts` works around for tables) — storing it verbatim would double it up with the + * block joiner's own separator, growing by two newlines on every save. Block-level callers trim it; + * inline callers never carry one (inline tokens can't span a blank line), so trimming is a no-op there. + */ +function verbatimParse(raw: string): JSONContent[] { + const trimmed = raw.replace(/\n+$/, '') + return trimmed ? [{ type: 'text', text: trimmed }] : [] +} + +interface VerbatimNodeOptions { + name: string + /** Whether this node sits among block content (own line) or inline content (mid-paragraph). */ + inline: boolean + badgeLabel: string +} + +/** + * Shared shape for a node that holds a markdown construct's exact source text and re-emits it + * unchanged — parsing and rendering never inspect or transform the text, so there is nothing for + * these constructs to lose. `markdownTokenName`/`parseMarkdown`/`renderMarkdown` are read directly + * off the returned config by `@tiptap/markdown`'s `MarkdownManager` (see + * `node_modules/@tiptap/markdown/src/MarkdownManager.ts`), independent of the node's `name`. + */ +function verbatimNodeConfig({ name, inline, badgeLabel }: VerbatimNodeOptions) { + return { + name, + inline, + group: inline ? 'inline' : 'block', + content: 'text*', + marks: '', + code: true, + defining: !inline, + selectable: true, + atom: false, + parseHTML() { + return [ + { + tag: `${inline ? 'span' : 'div'}[data-raw-markdown="${name}"]`, + preserveWhitespace: 'full' as const, + }, + ] + }, + renderHTML({ HTMLAttributes }: { HTMLAttributes: Record }) { + return [ + inline ? 'span' : 'div', + mergeAttributes(HTMLAttributes, { + 'data-raw-markdown': name, + 'data-raw-markdown-label': badgeLabel, + class: inline ? 'raw-markdown-inline' : 'raw-markdown-block', + }), + 0, + ] as const + }, + renderMarkdown(node: JSONContent) { + return verbatimText(node) + }, + } +} + +/** Block-level raw HTML — `
`, `
`, standalone ``, etc. + * Marked's own block tokenizer already classifies all of these as a single `'html'` token + * (`token.block === true`); `@tiptap/markdown`'s parser registry is checked *before* its built-in + * HTML handling for block tokens (unlike inline, see {@link RawInlineHtml}), so claiming the + * `'html'` token name here needs no custom tokenizer. */ +const SKIP_BLOCK_HTML_TAGS = /^<(img|br)\b[^>]*\/?>\s*$/i + +export const RawHtmlBlock = Node.create({ + ...verbatimNodeConfig({ name: 'rawHtmlBlock', inline: false, badgeLabel: 'Raw HTML' }), + markdownTokenName: 'html', + parseMarkdown(token: MarkdownToken) { + if (!token.block) return [] + const raw = token.raw ?? token.text ?? '' + if (!raw.trim()) return [] + // A lone ``/`
` tag block — leave it to the stock path (Image node / hard break), + // matching the same exclusion `round-trip-safety.ts` used to carve out for these two tags. + if (SKIP_BLOCK_HTML_TAGS.test(raw.trim())) return [] + return { type: 'rawHtmlBlock', content: verbatimParse(raw) } + }, +}) + +const FOOTNOTE_DEF_HEAD_RE = /^ {0,3}\[\^[^\]]+\]:/ +const FOOTNOTE_CONTINUATION_RE = /^ {4,}\S/ + +/** + * Consume a footnote definition's opening line plus any continuation lines GFM allows — indented by + * ≥4 spaces, optionally with blank lines between them (a multi-paragraph definition). Stops at the + * first line that is neither indented nor blank, and never consumes a blank line that isn't followed + * by further continuation (that blank line belongs to whatever block comes next). + */ +function tokenizeFootnoteDef(src: string): MarkdownToken | undefined { + const lines = src.split('\n') + if (!FOOTNOTE_DEF_HEAD_RE.test(lines[0])) return undefined + let lineCount = 1 + while (lineCount < lines.length) { + const line = lines[lineCount] + if (FOOTNOTE_CONTINUATION_RE.test(line)) { + lineCount += 1 + continue + } + if (line === '' && FOOTNOTE_CONTINUATION_RE.test(lines[lineCount + 1] ?? '')) { + lineCount += 2 + continue + } + break + } + const raw = lines.slice(0, lineCount).join('\n') + return { type: 'footnoteDef', raw, text: raw } +} + +/** Footnote definition (`[^id]: the note`, with optional ≥4-space-indented continuation lines) — + * marked has no footnote syntax at all, so without this tokenizer the definition is swallowed as a + * plain paragraph and the reference/definition link is lost. */ +export const FootnoteDef = Node.create({ + ...verbatimNodeConfig({ name: 'footnoteDef', inline: false, badgeLabel: 'Footnote' }), + markdownTokenName: 'footnoteDef', + markdownTokenizer: { + name: 'footnoteDef', + level: 'block' as const, + // Always -1 (never claims an early interrupt point): when `start` is omitted, `@tiptap/markdown` + // auto-generates one that calls `this.createLexer()` on every paragraph-continuation check, which + // corrupts the in-progress lexer's shared state (verified directly — every other construct on the + // page silently loses its content once a tokenizer without an explicit `start` is registered). + // The cost is narrow and safe: a footnote def sharing a line-run with the preceding paragraph (no + // blank line between them) is picked up on the next block boundary instead of interrupting early. + start: () => -1, + tokenize: tokenizeFootnoteDef, + }, + parseMarkdown(token: MarkdownToken) { + const raw = token.raw ?? token.text ?? '' + if (!raw) return [] + return { type: 'footnoteDef', content: verbatimParse(raw) } + }, +}) + +const FOOTNOTE_REF_RE = /^\[\^[^\]]+\]/ + +/** Footnote reference (`text[^id]`) — verbatim passthrough, same rationale as {@link FootnoteDef}. */ +export const FootnoteRef = Node.create({ + ...verbatimNodeConfig({ name: 'footnoteRef', inline: true, badgeLabel: 'Footnote ref' }), + markdownTokenName: 'footnoteRef', + markdownTokenizer: { + name: 'footnoteRef', + level: 'inline' as const, + // See the comment on `FootnoteDef`'s `start` — omitting it corrupts the shared lexer. + start: () => -1, + tokenize(src: string) { + const match = FOOTNOTE_REF_RE.exec(src) + if (!match) return undefined + return { type: 'footnoteRef', raw: match[0], text: match[0] } + }, + }, + parseMarkdown(token: MarkdownToken) { + const raw = token.raw ?? token.text ?? '' + if (!raw) return [] + return { type: 'footnoteRef', content: verbatimParse(raw) } + }, +}) + +const RAW_HTML_COMMENT_RE = /^/ + +const OPEN_TAG_RE = /^<([a-z][\w-]*)\b[^>]*?(\/)?>/i + +/** + * Find the end of the close tag that balances the open tag of `tag` ending at `src[0, fromIndex)`, + * tracking nesting depth from `fromIndex` onward so `outer inner` consumes + * both levels instead of stopping at the first (inner) ``. Returns -1 if unterminated. A + * nested self-closing same-name tag (``) is skipped — it neither opens nor closes a level. + */ +function findBalancedCloseEnd(src: string, tag: string, fromIndex: number): number { + const tagRe = new RegExp(`<(/?)${tag}\\b[^>]*?(/)?>`, 'gi') + tagRe.lastIndex = fromIndex + let depth = 1 + for (let match = tagRe.exec(src); match; match = tagRe.exec(src)) { + const isClose = match[1] === '/' + const isSelfClosing = Boolean(match[2]) + if (isSelfClosing) continue + if (isClose) { + depth -= 1 + if (depth === 0) return match.index + match[0].length + } else { + depth += 1 + } + } + return -1 +} + +/** + * Attempt to consume an inline HTML comment or a tag (with its matching close tag, or as a single + * void/self-closing element) starting at `src[0]`. Returns `undefined` for a tag this schema + * already has a real mark/node for ({@link HANDLED_INLINE_TAGS}) so it keeps parsing normally, and + * for an unterminated open tag (rare/malformed input — falls back to the stock, lossy behavior + * rather than risk mis-consuming the rest of the document). + */ +function tokenizeRawInlineHtml(src: string): MarkdownToken | undefined { + const comment = RAW_HTML_COMMENT_RE.exec(src) + if (comment) return { type: 'rawInlineHtml', raw: comment[0], text: comment[0] } + + const open = OPEN_TAG_RE.exec(src) + if (!open) return undefined + const tag = open[1].toLowerCase() + if (HANDLED_INLINE_TAGS.has(tag)) return undefined + if (open[2] || VOID_TAGS.has(tag)) { + return { type: 'rawInlineHtml', raw: open[0], text: open[0] } + } + + const end = findBalancedCloseEnd(src, tag, open[0].length) + if (end < 0) return undefined + const raw = src.slice(0, end) + return { type: 'rawInlineHtml', raw, text: raw } +} + +/** Inline raw HTML — ``, ``, ``, ``, `` (no Underline mark is registered), + * and any other tag this schema has no mark/node for, plus an inline-position HTML comment. Marked + * classifies inline HTML as its own `'html'` token type, and `@tiptap/markdown`'s inline parser + * hardcodes handling for that type *before* checking its extension registry (unlike block tokens) — + * so claiming it here needs a custom tokenizer, registered under a different token name + * (`rawInlineHtml`) so it's never confused with the stock `'html'` inline path. marked.js runs + * custom extension tokenizers before its own built-ins at both block and inline level (see + * `blockTokens`/`inlineTokens` in `node_modules/marked/lib/marked.esm.js`), so this reliably wins + * the race against marked's default inline HTML/tag tokenizer. */ +export const RawInlineHtml = Node.create({ + ...verbatimNodeConfig({ name: 'rawInlineHtml', inline: true, badgeLabel: 'Raw HTML' }), + markdownTokenName: 'rawInlineHtml', + markdownTokenizer: { + name: 'rawInlineHtml', + level: 'inline' as const, + // See the comment on `FootnoteDef`'s `start` — omitting it corrupts the shared lexer. + start: () => -1, + tokenize: tokenizeRawInlineHtml, + }, + parseMarkdown(token: MarkdownToken) { + const raw = token.raw ?? token.text ?? '' + if (!raw) return [] + return { type: 'rawInlineHtml', content: verbatimParse(raw) } + }, +}) + +const BLOCK_CONTROL_CLASS = + 'pointer-events-none absolute top-1.5 right-2 select-none rounded-md bg-[var(--surface-4)] px-1.5 py-0.5 text-[10px] text-[var(--text-muted)] uppercase tracking-wide opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100' + +/** Badge text per block node type name — kept here rather than threaded through node options since + * {@link NodeViewProps} exposes no options/extension reference to the rendering component. */ +const BLOCK_BADGE_LABEL: Record = { + rawHtmlBlock: 'Raw HTML', + footnoteDef: 'Footnote', +} + +function RawBlockView({ node }: ReactNodeViewProps) { + const label = BLOCK_BADGE_LABEL[node.type.name] ?? 'Raw' + return ( + + + {label} + +
+ as='span' /> +
+
+ ) +} + +/** Live variant of {@link RawHtmlBlock} with a hover "Raw HTML" badge — same schema/serializer. */ +export const RawHtmlBlockWithView = RawHtmlBlock.extend({ + addNodeView() { + return ReactNodeViewRenderer(RawBlockView) + }, +}) + +/** Live variant of {@link FootnoteDef} with a hover "Footnote" badge — same schema/serializer. */ +export const FootnoteDefWithView = FootnoteDef.extend({ + addNodeView() { + return ReactNodeViewRenderer(RawBlockView) + }, +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css index 84d727723b3..febdb83e3aa 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css @@ -277,6 +277,29 @@ line-height: 21px; } +/* Raw, unrendered markdown constructs the schema has no real node/mark for (raw HTML blocks, + comments, footnotes) — held verbatim and re-emitted byte-for-byte on save (./raw-markdown-snippet.ts). + Styled distinctly (monospace, tinted) so it's clear this text isn't interpreted, unlike a code block. */ +.rich-markdown-prose .raw-markdown-block, +.rich-markdown-prose .raw-markdown-inline { + font-family: var(--font-martian-mono, ui-monospace, monospace); + font-size: 0.875em; + color: var(--text-muted); + background: color-mix(in srgb, var(--warning, orange) 8%, var(--surface-5)); + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.rich-markdown-prose .raw-markdown-block { + border-radius: 8px; + padding: 0.75rem 1rem; +} + +.rich-markdown-prose .raw-markdown-inline { + border-radius: 4px; + padding: 0.0625rem 0.3rem; +} + .rich-markdown-prose hr { border: none; border-top: 1px solid var(--divider); diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx index 52f8982c1bd..b141d8bab64 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx @@ -24,6 +24,7 @@ import { parseMarkdownToDoc } from './markdown-parse' import { useEditorMentions } from './mention' import { EditorBubbleMenu } from './menus/bubble-menu' import { LinkHoverCard } from './menus/link-hover-card' +import { TableBubbleMenu } from './menus/table-menu' import { normalizeMarkdownContent } from './normalize-content' import { isRoundTripSafe } from './round-trip-safety' import '@sim/emcn/components/code/code.css' @@ -413,6 +414,15 @@ export function LoadedRichMarkdownEditor({ emitUpdate: false, }) } + // `setContent` maps any pre-existing selection onto the new doc rather than clearing it — a + // select-all survives as "select everything," permanently painting every divider/image with the + // `rich-leaf-in-selection` decoration (keymap.ts) until the user clicks elsewhere. This must run + // on every settle regardless of whether `setContent` ran just above: the last streaming tick + // already syncs `lastSyncedBodyRef` to the final body before settle, so `body` usually already + // equals it here — collapsing only inside that `if` would skip the common streamed-content case + // entirely. `setTextSelection` (not `.focus()`) so this never steals DOM focus from whatever the + // user is doing outside the editor. + editor.commands.setTextSelection(editor.state.doc.content.size) editor.setEditable(canEdit && settledRef.current.verdict) if (isInitialSettle && autoFocus) editor.commands.focus('end') return @@ -433,6 +443,7 @@ export function LoadedRichMarkdownEditor({ className={cn('flex flex-1 flex-col overflow-y-auto', isEditable && 'cursor-text')} > {editor && } + {editor && } {editor && } { expect(isRoundTripSafe('> ```\n> code\n> ```')).toBe(true) }) - it('rejects stable-loss constructs the idempotency probe cannot see', () => { - expect(isRoundTripSafe('text[^1]\n\n[^1]: the note')).toBe(false) - expect(isRoundTripSafe('\n\ntext')).toBe(false) - expect(isRoundTripSafe('
xbody
')).toBe(false) - expect(isRoundTripSafe('a b c')).toBe(false) + it('preserves footnotes, HTML comments, and raw HTML tags via the verbatim snippet nodes', () => { + expect(isRoundTripSafe('text[^1]\n\n[^1]: the note')).toBe(true) + expect(isRoundTripSafe('\n\ntext')).toBe(true) + expect(isRoundTripSafe('
xbody
')).toBe(true) + expect(isRoundTripSafe('a b c')).toBe(true) }) it('rejects a hard break inside a heading (serializer splits the heading)', () => { @@ -263,15 +263,19 @@ describe('editability gate — realistic documents stay editable', () => { }) // The flip side and exact boundary of the gate: constructs the WYSIWYG schema genuinely cannot -// represent open read-only so an edit can't silently corrupt them. +// represent open read-only so an edit can't silently corrupt them. Raw HTML blocks, comments, and +// footnotes used to be the canonical examples here — `./raw-markdown-snippet.ts` now holds each +// verbatim (including a multi-line block spanning blank lines, via the same `NON_CHUNKABLE` +// whole-document parse path `markdown-parse.ts` already uses for these constructs), so they moved +// to the "preserved" test above instead of staying here. describe('editability gate — genuinely lossy constructs open read-only', () => { - it('raw HTML blocks (
,
) open read-only', () => { - expect(isRoundTripSafe('
More\n\nbody\n\n
')).toBe(false) - expect(isRoundTripSafe('
\n\ncentered\n\n
')).toBe(false) + it('raw HTML blocks (
,
) are preserved verbatim, not locked read-only', () => { + expect(isRoundTripSafe('
More\n\nbody\n\n
')).toBe(true) + expect(isRoundTripSafe('
\n\ncentered\n\n
')).toBe(true) }) - it('HTML comments and footnotes open read-only', () => { - expect(isRoundTripSafe('\n\ntext')).toBe(false) - expect(isRoundTripSafe('a claim[^1]\n\n[^1]: the source')).toBe(false) + it('HTML comments and footnotes are preserved verbatim, not locked read-only', () => { + expect(isRoundTripSafe('\n\ntext')).toBe(true) + expect(isRoundTripSafe('a claim[^1]\n\n[^1]: the source')).toBe(true) }) }) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/round-trip-safety.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/round-trip-safety.ts index a3d473b3193..423669de7c8 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/round-trip-safety.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/round-trip-safety.ts @@ -15,12 +15,11 @@ const PROBE_SIZE_LIMIT = 256 * 1024 * (Linked images `[![alt](img)](href)` are handled by the image node and verified separately by * the link-count check in {@link isRoundTripSafe}, not here.) * - * - **Footnote** `[^id]` — not in the schema; the reference and definition serialize to escaped - * literal text, breaking the footnote. - * - **HTML comment** `` — dropped entirely. - * - **Raw HTML tag** `
`, `
`, ``, … — StarterKit has no HTML node, so the tag - * is stripped (content kept, structure lost). `
` and `` are excluded: `
` outside a - * table converts to a hard break, and `` is a first-class (resizable) image node. + * Footnotes, HTML comments, and raw HTML tags (`
`, `
`, ``, …) used to be listed + * here — the schema had no node for any of them, so they were dropped or stripped (content kept, + * structure lost). `./raw-markdown-snippet.ts` now holds each construct's exact source text and + * re-emits it byte-for-byte, so none of them lose data on round-trip and none need a pattern below. + * * - **`
` inside a table cell** — a GFM cell can't hold a real line break, so the serializer * flattens `one
two` to `one two`. Matched on a table-shaped line (≥2 pipes) containing a `
`. * - **Hard break inside a heading** (trailing two spaces or a backslash) — the serializer splits @@ -30,9 +29,6 @@ const PROBE_SIZE_LIMIT = 256 * 1024 * `&` with no `;` is left alone (it re-renders identically, so it's harmless churn). */ const STABLE_LOSS_PATTERNS: ReadonlyArray = [ - /\[\^[^\]]+]/, - /`) inside a whitelisted raw HTML block could still be matched by the balance scan and end the block early, fragmenting it. maskCodeRegions already masked fenced/inline code the same way; extend it to mask comments too. --- .../raw-markdown-snippet.test.ts | 162 ++++++++++- .../raw-markdown-snippet.tsx | 261 +++++++++++++++--- .../rich-markdown-editor.css | 10 +- 3 files changed, 390 insertions(+), 43 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/raw-markdown-snippet.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/raw-markdown-snippet.test.ts index e086d0dc27e..9588b487b2d 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/raw-markdown-snippet.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/raw-markdown-snippet.test.ts @@ -6,12 +6,17 @@ * and reach a fixpoint on a second pass (see `serializeMarkdownDocument` in `./markdown-parse.ts`). */ import { describe, expect, it } from 'vitest' -import { serializeMarkdownDocument } from './markdown-parse' +import { parseMarkdownToDoc, serializeMarkdownDocument } from './markdown-parse' function roundTrip(input: string): string { return serializeMarkdownDocument(input).trim() } +/** Top-level node type names of the parsed doc, for structural (not just string) assertions. */ +function topLevelTypes(input: string): (string | undefined)[] { + return (parseMarkdownToDoc(input).content ?? []).map((n) => n.type) +} + describe('raw markdown snippet nodes', () => { it('preserves a standalone HTML comment', () => { const input = '\n\ntext' @@ -96,3 +101,158 @@ describe('raw markdown snippet nodes', () => { expect(roundTrip(input)).toBe(input) }) }) + +describe('raw HTML block: does not fragment across blank lines', () => { + it('a
block with a blank-line-separated body is ONE node, not three', () => { + const input = + '
\nClick to expand\n\nThis is inside a details/summary block.\n\n
' + expect(topLevelTypes(input)).toEqual(['rawHtmlBlock']) + expect(roundTrip(input)).toBe(input) + expect(roundTrip(roundTrip(input))).toBe(roundTrip(input)) + }) + + it('a
with multiple blank-line-separated paragraphs inside is ONE node', () => { + const input = '
\n\nfirst paragraph\n\nsecond paragraph\n\n
' + expect(topLevelTypes(input)).toEqual(['rawHtmlBlock']) + expect(roundTrip(input)).toBe(input) + }) + + it('nested same-tag block HTML balances depth across blank lines', () => { + const input = '
\nouter\n\n
\n\ninner\n\n
\n\nstill outer\n
' + expect(topLevelTypes(input)).toEqual(['rawHtmlBlock']) + expect(roundTrip(input)).toBe(input) + expect(roundTrip(roundTrip(input))).toBe(roundTrip(input)) + }) + + it('a paragraph starting with a non-block-list inline tag is NOT captured as a raw block', () => { + // `em`/`a` aren't in the CommonMark block-HTML tag whitelist — they can legitimately start an + // ordinary paragraph, and must keep parsing as real marks, not freeze as raw source. + expect(topLevelTypes('hi there, this is a normal paragraph')).toEqual(['paragraph']) + expect(roundTrip('hi there')).toBe('*hi* there') + }) + + it('a stray inline-only tag alone on its own line is left to the stock (non-whitelisted) path', () => { + // `` isn't in the block whitelist, so the new block tokenizer must not claim it — it falls + // through to marked's own (stricter) block-HTML detection, unaffected by this change. + const input = '\n\nnot a block-html tag\n\n' + expect(() => roundTrip(input)).not.toThrow() + }) + + it('an unterminated block tag falls back gracefully (no crash, no infinite loop)', () => { + const input = '
\nnever closed\n\nbody' + expect(() => roundTrip(input)).not.toThrow() + }) + + it('a block comment spanning blank lines still round-trips via the new shared tokenizer path', () => { + const input = '\n\ntext after' + expect(topLevelTypes(input)[0]).toBe('rawHtmlBlock') + expect(roundTrip(input)).toBe(input) + }) + + it('a table and code block adjacent to a fragmenting-prone details block still coexist correctly', () => { + const input = + '
\ns\n\nbody\n\n
\n\n| a | b |\n| --- | --- |\n| 1 | 2 |\n\n```js\nconst x = 1\n```' + expect(roundTrip(input)).toBe(input) + }) + + it('preserves an indented (up to 3 spaces) block-HTML opening line', () => { + // `roundTrip`'s `.trim()` would strip the very leading indent this test verifies, so check the + // parsed node's own text (and the untrimmed serialization) instead of the trimmed helper. + for (const indent of [' ', ' ', ' ']) { + const input = `${indent}
\nx\n\nbody\n\n
` + const doc = parseMarkdownToDoc(input) + expect(doc.content?.map((n) => n.type)).toEqual(['rawHtmlBlock']) + expect(doc.content?.[0].content?.[0].text).toBe(input) + expect(serializeMarkdownDocument(input)).toBe(`${input}\n`) + } + }) + + it('preserves a quoted attribute value containing a literal >', () => { + const input = '
\n\ncontent\n\n
' + expect(topLevelTypes(input)).toEqual(['rawHtmlBlock']) + expect(roundTrip(input)).toBe(input) + expect(roundTrip(roundTrip(input))).toBe(roundTrip(input)) + }) + + it('a quoted attribute containing a nested same-tag mention does not confuse the balance scan', () => { + // Without attribute-aware matching, `
` inside the quoted value below would be miscounted as + // a real nested open tag, throwing off the depth count entirely. + const input = '
\n\ncontent\n\n
' + expect(topLevelTypes(input)).toEqual(['rawHtmlBlock']) + expect(roundTrip(input)).toBe(input) + }) + + it('does not mistake a tag name mentioned inside an inline code span for a real closing tag', () => { + const input = '
\nx\n\nSee `
` in the docs.\n\n
' + expect(topLevelTypes(input)).toEqual(['rawHtmlBlock']) + expect(roundTrip(input)).toBe(input) + }) + + it('does not mistake a tag name mentioned inside a fenced code block for a real closing tag', () => { + const input = '
\n\nExample:\n\n```html\n
example
\n```\n\nmore body\n\n
' + expect(topLevelTypes(input)).toEqual(['rawHtmlBlock']) + expect(roundTrip(input)).toBe(input) + }) + + it('does not mistake a tag name mentioned inside an HTML comment for a real closing tag', () => { + const input = '
\n\n\n\nmore body\n\n
' + expect(topLevelTypes(input)).toEqual(['rawHtmlBlock']) + expect(roundTrip(input)).toBe(input) + }) + + it('a bare (unescaped, un-fenced) tag-name mention never crashes and always converges to a stable save', () => { + // Known, inherent limitation of regex-based (non-DOM) tag matching, shared by any HTML-block + // scanner (and by real HTML parsers given the same ambiguous input) — a bare mention outside + // code can still be misread as the real closer. The bar this file holds itself to is: never + // crash, never lose text, and always settle to a fixpoint after one save (isRoundTripSafe's own + // documented tolerance for single-pass normalization) — not a perfect, DOM-aware parse. + const input = + '
\nx\n\nSee the literal text
in docs.\n\nmore body\n\n
' + expect(() => roundTrip(input)).not.toThrow() + const once = roundTrip(input) + const twice = roundTrip(once) + expect(once).toBe(twice) + // No word from the original is dropped, even though the structure/whitespace may be reflowed. + for (const word of ['See', 'the', 'literal', 'text', 'in', 'docs', 'more', 'body']) { + expect(once).toContain(word) + } + }) + + it('does not mistake a tag name mentioned inside a blockquoted fenced code block for a real closing tag', () => { + const input = + '
\n\n> Example:\n>\n> ```html\n>
example
\n> ```\n\nmore body\n\n
' + expect(topLevelTypes(input)).toEqual(['rawHtmlBlock']) + expect(roundTrip(input)).toBe(input) + }) + + it('does not mistake a tag name mentioned inside an indented (no blockquote) fenced code block for a real closing tag', () => { + const input = + '
\n\nExample:\n\n ```html\n
example
\n ```\n\nmore body\n\n
' + expect(topLevelTypes(input)).toEqual(['rawHtmlBlock']) + expect(roundTrip(input)).toBe(input) + }) + + it('treats a void block tag (no closing tag exists) as complete right after the open tag', () => { + // `link`/`meta`/`base`/`hr` are in the CommonMark block-HTML whitelist but are void elements — + // scanning for a `` that will never legitimately appear would risk grabbing unrelated + // later content (or a stray same-name mention) into the block. + for (const input of [ + '\n\nafter', + '\n\nafter', + '
\n\nafter', + ]) { + const doc = parseMarkdownToDoc(input) + expect(doc.content?.[0].type).toBe('rawHtmlBlock') + expect(roundTrip(input)).toContain('after') + } + }) + + it('a void block tag does not swallow a later, unrelated mention of its own tag name', () => { + const input = '\n\nSee the `` tag in docs.\n\nmore body' + const doc = parseMarkdownToDoc(input) + // The is its own complete block; the later mention (in code) stays in a separate paragraph. + expect(doc.content?.[0].type).toBe('rawHtmlBlock') + expect(doc.content?.[0].content?.[0].text).toBe('') + expect(roundTrip(input)).toContain('more body') + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/raw-markdown-snippet.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/raw-markdown-snippet.tsx index 9081fe3fa7c..f2e27209ce7 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/raw-markdown-snippet.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/raw-markdown-snippet.tsx @@ -50,6 +50,88 @@ function verbatimText(node: JSONContent): string { return (node.content ?? []).map((child) => child.text ?? '').join('') } +const RAW_HTML_COMMENT_RE = /^/ + +/** + * One HTML attribute: `name` or `name="value"`/`name='value'`/`name=bare`. The quoted-value + * alternatives are what matter — `[^"]*`/`[^']*` consume a literal `>` inside the quotes as part of + * the value, so an attribute like `data-example="a > b"` is treated as one unit instead of ending + * the tag match at the internal `>`. + */ +const ATTRS_RE_SOURCE = String.raw`(?:\s+[^\s"'=<>\`]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'=<>\`]+))?)*` + +/** Matches one opening HTML tag, attributes included (see {@link ATTRS_RE_SOURCE}). Group 1 is the + * tag name, group 2 is the self-closing `/` if present — shared by inline and block tokenizing. */ +const OPEN_TAG_RE = new RegExp(`^<([a-z][\\w-]*)\\b${ATTRS_RE_SOURCE}\\s*(/)?>`, 'i') + +/** A fenced block's opening/closing marker may sit inside a blockquote (each line prefixed with + * up to 3 spaces then one or more `>` markers, each optionally followed by a space) and/or be + * independently indented up to 3 spaces with no blockquote at all (CommonMark's own fence-indent + * tolerance — matches `FENCE_OPEN`/`FENCE_CLOSE` in `./markdown-parse.ts`) — matched on both the + * open and close fence line so `> \`\`\`` and ` \`\`\`` both mask correctly. */ +const FENCE_PREFIX_SOURCE = '(?:[ ]{0,3}>[ ]?)*[ ]{0,3}' + +/** Same as {@link RAW_HTML_COMMENT_RE} but not anchored to the start of the string — used by + * {@link maskCodeRegions} to find a comment anywhere in the scanned text, not just at position 0. */ +const HTML_COMMENT_ANYWHERE_RE = //g + +/** + * Mask fenced code blocks, inline code spans, and HTML comments with same-length filler (newlines + * kept, everything else replaced with a space) so a tag-like mention *inside one of these* — + * `` `
` ``, a fenced example showing HTML syntax, or a comment documenting the tag + * (``) — is never mistaken for a real balancing tag while scanning. Mirrors + * the fenced/inline patterns `stripCode` in `./round-trip-safety.ts` matches (extended to also + * tolerate an indented and/or blockquoted fence marker via {@link FENCE_PREFIX_SOURCE}, since a raw + * HTML block can itself be indented or quoted), but preserves length/position (masks in place) + * instead of deleting, so match indices still map onto the original, unmodified `src` the caller + * slices from. + */ +function maskCodeRegions(src: string): string { + const fenceRe = new RegExp( + `^${FENCE_PREFIX_SOURCE}([\`~]{3,})[^\\n]*\\n[\\s\\S]*?^${FENCE_PREFIX_SOURCE}\\1[\`~]*[ \\t]*$`, + 'gm' + ) + return src + .replace(fenceRe, (m) => m.replace(/[^\n]/g, ' ')) + .replace(/`+[^`\n]*`+/g, (m) => ' '.repeat(m.length)) + .replace(HTML_COMMENT_ANYWHERE_RE, (m) => m.replace(/[^\n]/g, ' ')) +} + +/** + * Find the end of the close tag that balances the open tag of `tag` ending at `src[0, fromIndex)`, + * tracking nesting depth from `fromIndex` onward so `outer inner` consumes + * both levels instead of stopping at the first (inner) ``. Returns -1 if unterminated. A + * nested self-closing same-name tag (``) is skipped — it neither opens nor closes a level. + * Shared by the inline tokenizer (single line) and the block tokenizer (spans blank lines). + * + * Scans a {@link maskCodeRegions}-masked copy of `src` so a tag name mentioned inside code doesn't + * count as real markup — this narrows, but can't eliminate, the inherent ambiguity of regex-based + * (non-DOM) tag matching: a *bare, unescaped* mention of the same tag name in plain prose (not in + * code) is indistinguishable from a real closing tag here, exactly as it would be to a real HTML + * parser given the same ambiguous input (there is no valid way to "escape" a literal `` inside + * real HTML content other than an entity or code region). Verified this can't lose data even in that + * case — the result still reaches a stable fixpoint on save, just restructured — matching this file's + * "reject on doubt, but never require doubt-free input" gate (`isRoundTripSafe`). + */ +function findBalancedCloseEnd(src: string, tag: string, fromIndex: number): number { + const masked = maskCodeRegions(src) + const tagRe = new RegExp(`<(/?)${tag}\\b${ATTRS_RE_SOURCE}\\s*(/)?>`, 'gi') + tagRe.lastIndex = fromIndex + let depth = 1 + for (let match = tagRe.exec(masked); match; match = tagRe.exec(masked)) { + const isClose = match[1] === '/' + const isSelfClosing = Boolean(match[2]) + if (isSelfClosing) continue + if (isClose) { + depth -= 1 + if (depth === 0) return match.index + match[0].length + } else { + depth += 1 + } + } + return -1 +} + /** * Marked's own block tokenizer greedily consumes the blank-line run *after* an HTML block/comment * or a def line as part of that token's own `raw` (the same behavior `PipeSafeTable` in @@ -112,16 +194,146 @@ function verbatimNodeConfig({ name, inline, badgeLabel }: VerbatimNodeOptions) { } } -/** Block-level raw HTML — `
`, `
`, standalone ``, etc. - * Marked's own block tokenizer already classifies all of these as a single `'html'` token - * (`token.block === true`); `@tiptap/markdown`'s parser registry is checked *before* its built-in - * HTML handling for block tokens (unlike inline, see {@link RawInlineHtml}), so claiming the - * `'html'` token name here needs no custom tokenizer. */ +/** + * Tag names CommonMark/GFM treat as "block-starting" HTML (marked's own type-6 list — see + * `_tag` in `node_modules/marked/src/rules.ts`, verified against the CommonMark spec): a block + * opening with one of these ends at its *matching closing tag*, not at the first blank line. Tags + * NOT in this list (`em`, `a`, `span`, `code`, `kbd`, …) can legitimately start an ordinary + * paragraph (`hi there`), so they're deliberately left to marked's own stricter, single-line + * block-HTML detection below — claiming them here would risk swallowing a paragraph that merely + * starts with inline HTML. + */ +const BLOCK_HTML_TAG_NAMES = new Set([ + 'address', + 'article', + 'aside', + 'base', + 'basefont', + 'blockquote', + 'body', + 'caption', + 'center', + 'col', + 'colgroup', + 'dd', + 'details', + 'dialog', + 'dir', + 'div', + 'dl', + 'dt', + 'fieldset', + 'figcaption', + 'figure', + 'footer', + 'form', + 'frame', + 'frameset', + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'head', + 'header', + 'hr', + 'html', + 'iframe', + 'legend', + 'li', + 'link', + 'main', + 'menu', + 'menuitem', + 'meta', + 'nav', + 'noframes', + 'ol', + 'optgroup', + 'option', + 'p', + 'param', + 'search', + 'section', + 'summary', + 'table', + 'tbody', + 'td', + 'tfoot', + 'th', + 'thead', + 'title', + 'tr', + 'track', + 'ul', +]) + +/** + * Marked's built-in block-HTML rule ends a `
`/`
`/… block at the *first blank line* + * (CommonMark's HTML-block-type-6 rule) — correct for normal rendering, but wrong for verbatim + * preservation: any real-world `
` with a paragraph inside would fragment into a raw chip, + * an ordinary (rendered) paragraph, and a second raw chip, stranding genuine content in between. + * This tokenizer instead scans to the tag's *matching* close via {@link findBalancedCloseEnd}, blank + * lines included, for tags in {@link BLOCK_HTML_TAG_NAMES}; anything else returns `undefined` and + * falls through to the existing `markdownTokenName: 'html'` handling below (marked's own block + * tokenizer, unchanged). Comments are matched the same way as the inline case — marked's own comment + * rule already spans blank lines correctly, but routing through one path keeps the two tokenizers + * symmetric and independently testable. CommonMark allows up to 3 leading spaces before a block-HTML + * opening line, so the leading indent is split off, matched against separately, and stitched back + * onto `raw` — everything after that first line (including the tag's own body) can be indented + * however the author wrote it, since the balanced scan doesn't care about column position there. + */ +function tokenizeRawHtmlBlockTag(src: string): MarkdownToken | undefined { + const indent = /^ {0,3}/.exec(src)?.[0] ?? '' + const rest = src.slice(indent.length) + + const comment = RAW_HTML_COMMENT_RE.exec(rest) + if (comment) { + const raw = indent + comment[0] + return { type: 'html', raw, text: raw, block: true } + } + + const open = OPEN_TAG_RE.exec(rest) + if (!open) return undefined + const tag = open[1].toLowerCase() + if (!BLOCK_HTML_TAG_NAMES.has(tag)) return undefined + // A handful of BLOCK_HTML_TAG_NAMES entries (link, meta, base, col, …) are void elements with no + // closing tag at all — treat them as complete right after the open tag (like an explicit `/>`), + // same as `tokenizeRawInlineHtml` already does via VOID_TAGS. Without this, scanning for a + // ``/`` that will never legitimately appear risks grabbing unrelated later content + // (or a stray same-name mention) as if it belonged to this block. + if (open[2] || VOID_TAGS.has(tag)) { + const raw = indent + open[0] + return { type: 'html', raw, text: raw, block: true } + } + + const end = findBalancedCloseEnd(rest, tag, open[0].length) + if (end < 0) return undefined + const raw = indent + rest.slice(0, end) + return { type: 'html', raw, text: raw, block: true } +} + const SKIP_BLOCK_HTML_TAGS = /^<(img|br)\b[^>]*\/?>\s*$/i export const RawHtmlBlock = Node.create({ ...verbatimNodeConfig({ name: 'rawHtmlBlock', inline: false, badgeLabel: 'Raw HTML' }), markdownTokenName: 'html', + markdownTokenizer: { + name: 'rawHtmlBlockTag', + level: 'block' as const, + // Always -1 (never claims an early interrupt point): when `start` is omitted, `@tiptap/markdown` + // auto-generates one that calls `this.createLexer()` on every paragraph-continuation check, which + // corrupts the in-progress lexer's shared state (verified directly — every other construct on the + // page silently loses its content once a tokenizer without an explicit `start` is registered). + // The other custom tokenizers below all reference this comment rather than repeating it. + // + // The tokenizer above emits `type: 'html'` explicitly, so its tokens flow into the same + // `markdownTokenName: 'html'` parse registration as marked's own block-HTML tokens below — the + // distinct `name` here only avoids colliding with marked's own built-in `html` extension. + start: () => -1, + tokenize: tokenizeRawHtmlBlockTag, + }, parseMarkdown(token: MarkdownToken) { if (!token.block) return [] const raw = token.raw ?? token.text ?? '' @@ -171,11 +383,8 @@ export const FootnoteDef = Node.create({ markdownTokenizer: { name: 'footnoteDef', level: 'block' as const, - // Always -1 (never claims an early interrupt point): when `start` is omitted, `@tiptap/markdown` - // auto-generates one that calls `this.createLexer()` on every paragraph-continuation check, which - // corrupts the in-progress lexer's shared state (verified directly — every other construct on the - // page silently loses its content once a tokenizer without an explicit `start` is registered). - // The cost is narrow and safe: a footnote def sharing a line-run with the preceding paragraph (no + // See the comment on `RawHtmlBlock`'s `start` — omitting it corrupts the shared lexer. The cost + // here is narrow and safe: a footnote def sharing a line-run with the preceding paragraph (no // blank line between them) is picked up on the next block boundary instead of interrupting early. start: () => -1, tokenize: tokenizeFootnoteDef, @@ -196,7 +405,7 @@ export const FootnoteRef = Node.create({ markdownTokenizer: { name: 'footnoteRef', level: 'inline' as const, - // See the comment on `FootnoteDef`'s `start` — omitting it corrupts the shared lexer. + // See the comment on `RawHtmlBlock`'s `start` — omitting it corrupts the shared lexer. start: () => -1, tokenize(src: string) { const match = FOOTNOTE_REF_RE.exec(src) @@ -211,34 +420,6 @@ export const FootnoteRef = Node.create({ }, }) -const RAW_HTML_COMMENT_RE = /^/ - -const OPEN_TAG_RE = /^<([a-z][\w-]*)\b[^>]*?(\/)?>/i - -/** - * Find the end of the close tag that balances the open tag of `tag` ending at `src[0, fromIndex)`, - * tracking nesting depth from `fromIndex` onward so `outer inner` consumes - * both levels instead of stopping at the first (inner) ``. Returns -1 if unterminated. A - * nested self-closing same-name tag (``) is skipped — it neither opens nor closes a level. - */ -function findBalancedCloseEnd(src: string, tag: string, fromIndex: number): number { - const tagRe = new RegExp(`<(/?)${tag}\\b[^>]*?(/)?>`, 'gi') - tagRe.lastIndex = fromIndex - let depth = 1 - for (let match = tagRe.exec(src); match; match = tagRe.exec(src)) { - const isClose = match[1] === '/' - const isSelfClosing = Boolean(match[2]) - if (isSelfClosing) continue - if (isClose) { - depth -= 1 - if (depth === 0) return match.index + match[0].length - } else { - depth += 1 - } - } - return -1 -} - /** * Attempt to consume an inline HTML comment or a tag (with its matching close tag, or as a single * void/self-closing element) starting at `src[0]`. Returns `undefined` for a tag this schema @@ -279,7 +460,7 @@ export const RawInlineHtml = Node.create({ markdownTokenizer: { name: 'rawInlineHtml', level: 'inline' as const, - // See the comment on `FootnoteDef`'s `start` — omitting it corrupts the shared lexer. + // See the comment on `RawHtmlBlock`'s `start` — omitting it corrupts the shared lexer. start: () => -1, tokenize: tokenizeRawInlineHtml, }, diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css index febdb83e3aa..66a80d470f0 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css @@ -279,17 +279,23 @@ /* Raw, unrendered markdown constructs the schema has no real node/mark for (raw HTML blocks, comments, footnotes) — held verbatim and re-emitted byte-for-byte on save (./raw-markdown-snippet.ts). - Styled distinctly (monospace, tinted) so it's clear this text isn't interpreted, unlike a code block. */ + Same neutral surface as `code`/`pre` below (no color tint — a tint reads as a warning/error state, + which isn't the signal here); the hover "Raw HTML"/"Footnote" badge is what conveys "not interpreted". */ .rich-markdown-prose .raw-markdown-block, .rich-markdown-prose .raw-markdown-inline { font-family: var(--font-martian-mono, ui-monospace, monospace); font-size: 0.875em; color: var(--text-muted); - background: color-mix(in srgb, var(--warning, orange) 8%, var(--surface-5)); + background: var(--surface-5); white-space: pre-wrap; overflow-wrap: anywhere; } +.dark .rich-markdown-prose .raw-markdown-block, +.dark .rich-markdown-prose .raw-markdown-inline { + background: var(--code-bg); +} + .rich-markdown-prose .raw-markdown-block { border-radius: 8px; padding: 0.75rem 1rem; From a342424ffea6da5a87717b33b6f9f5d9678bb2bd Mon Sep 17 00:00:00 2001 From: Waleed Date: Mon, 6 Jul 2026 14:35:56 -0700 Subject: [PATCH 14/35] fix(mcp): scope tools loading state to each server's own query (#5441) Server rows derived "Loading..." from a workspace-wide isLoading/isFetching aggregate instead of that server's own per-server query, so a row could sit stuck on "Loading..." (or flicker back to it) whenever any other server's tool query was fetching, only clearing after a full page reload. --- .../settings/components/mcp/mcp.tsx | 8 ++++--- apps/sim/hooks/queries/mcp.ts | 22 +++++++++++++++---- 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp.tsx index 27158a0b7d2..9bcdb853fad 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp.tsx @@ -157,8 +157,7 @@ export function MCP() { const { data: mcpToolsData = [], error: toolsError, - isLoading: toolsLoading, - isFetching: toolsFetching, + toolsStateByServer, } = useMcpToolsQuery(workspaceId) const { data: storedTools = [], refetch: refetchStoredTools } = useStoredMcpTools(workspaceId) const forceRefreshToolsMutation = useForceRefreshMcpTools() @@ -623,7 +622,10 @@ export function MCP() { {filteredServers.map((server) => { if (!server?.id) return null const tools = toolsByServer[server.id] || [] - const isLoadingTools = toolsLoading || toolsFetching + const serverToolsState = toolsStateByServer.get(server.id) + const isLoadingTools = serverToolsState + ? serverToolsState.isLoading || serverToolsState.isFetching + : false return ( () + for (let index = 0; index < results.length; index++) { + const result = results[index] // Drop stale data from servers whose latest refetch errored. if (result.data && !result.isError) { tools.push(...result.data) @@ -170,16 +175,25 @@ export function useMcpToolsQuery(workspaceId: string) { } if (result.isLoading) anyServerLoading = true if (!firstError && result.error instanceof Error) firstError = result.error + + const serverId = serverIds[index] + if (serverId) { + toolsStateByServer.set(serverId, { + isLoading: result.isLoading, + isFetching: result.isFetching, + error: result.error instanceof Error ? result.error : null, + }) + } } return { data: tools, isLoading: (serversLoading || anyServerLoading) && !hasData, isFetching: serversLoading || results.some((r) => r.isFetching), - // Suppress when any healthy server rendered; per-server errors live in `perServer`. + // Suppress when any healthy server rendered; per-server errors live in `toolsStateByServer`. error: hasData ? null : firstError, - perServer: results, + toolsStateByServer, } - }, [results, serversLoading]) + }, [results, serversLoading, serverIds]) } export function useForceRefreshMcpTools() { From 036345153987cea9b9c0c0e54cf6dcf2a350480e Mon Sep 17 00:00:00 2001 From: Waleed Date: Mon, 6 Jul 2026 15:33:18 -0700 Subject: [PATCH 15/35] improvement(new-relic): validate integration against NerdGraph docs, surface more entity fields (#5443) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * improvement(new-relic): validate integration against NerdGraph docs, surface more entity fields - Audited all 4 New Relic tools (nrql_query, search_entities, get_entity, create_deployment_event) plus the block against live NerdGraph API docs; request/response shapes, auth, region endpoints, and GraphQL injection handling all confirmed correct. - Added domain, reporting, alertSeverity, and tags to get_entity and search_entities outputs — stable Entity schema fields we weren't surfacing. Purely additive, no existing fields changed. - Skipped NerdGraph's aiIssues/incidents API; New Relic marks it "unsafe experimental" and requires an opt-in header, not worth the fragility. * fix(new-relic): normalize search_entities output, share entity normalization search_entities passed raw NerdGraph entities straight through, so tags/ domain/reporting/alertSeverity could arrive as null/undefined at runtime despite the non-nullable NewRelicEntity type contract — a .map() on a null tags array would throw. get_entity already normalized these fields correctly. Extracted the normalization into a shared normalizeNewRelicEntity() in utils.ts and reuse it from both tools, so the two can't drift again. * fix(new-relic): gate alertSeverity behind required GraphQL interface fragments alertSeverity isn't a direct field on New Relic's Entity/EntityOutline types — it only exists on the AlertableEntity/AlertableEntityOutline sub-interfaces, and NerdGraph rejects the query outright without the inline fragment. get_entity.ts (entity(guid), returns Entity) now uses `... on AlertableEntity { alertSeverity }`; search_entities.ts (entitySearch, returns EntityOutline) now uses `... on AlertableEntityOutline { alertSeverity }`. Confirmed against New Relic's live NerdGraph entities API docs. --- apps/sim/blocks/blocks/new_relic.ts | 12 +++++- apps/sim/tools/new_relic/get_entity.ts | 44 ++++++++++++++++----- apps/sim/tools/new_relic/search_entities.ts | 38 ++++++++++++++++-- apps/sim/tools/new_relic/types.ts | 9 +++++ apps/sim/tools/new_relic/utils.ts | 26 +++++++++++- 5 files changed, 114 insertions(+), 15 deletions(-) diff --git a/apps/sim/blocks/blocks/new_relic.ts b/apps/sim/blocks/blocks/new_relic.ts index f8912cd08ca..92640b31aaa 100644 --- a/apps/sim/blocks/blocks/new_relic.ts +++ b/apps/sim/blocks/blocks/new_relic.ts @@ -345,9 +345,17 @@ Return ONLY the numeric timestamp - no explanations, no extra text.`, resultCount: { type: 'number', description: 'Number of NRQL result rows' }, count: { type: 'number', description: 'Number of matching entities' }, query: { type: 'string', description: 'Entity search query New Relic executed' }, - entities: { type: 'json', description: 'Matching New Relic entities (guid, name, entityType)' }, + entities: { + type: 'json', + description: + 'Matching New Relic entities (guid, name, entityType, domain, reporting, alertSeverity, tags)', + }, nextCursor: { type: 'string', description: 'Cursor for the next entity search page' }, - entity: { type: 'json', description: 'New Relic entity details (guid, name, entityType)' }, + entity: { + type: 'json', + description: + 'New Relic entity details (guid, name, entityType, domain, reporting, alertSeverity, tags)', + }, event: { type: 'json', description: 'Created change tracking event metadata' }, messages: { type: 'json', description: 'New Relic change tracking messages' }, }, diff --git a/apps/sim/tools/new_relic/get_entity.ts b/apps/sim/tools/new_relic/get_entity.ts index 4e839a04f3b..52f4ce4c5b9 100644 --- a/apps/sim/tools/new_relic/get_entity.ts +++ b/apps/sim/tools/new_relic/get_entity.ts @@ -2,17 +2,16 @@ import type { NewRelicGetEntityParams, NewRelicGetEntityResponse } from '@/tools import { getNerdGraphEndpoint, gqlString, + type NewRelicRawEntity, newRelicHeaders, + normalizeNewRelicEntity, parseNerdGraphResponse, } from '@/tools/new_relic/utils' import type { ToolConfig } from '@/tools/types' interface GetEntityData { actor?: { - entity?: { - name?: string | null - entityType?: string | null - } | null + entity?: NewRelicRawEntity | null } | null } @@ -54,6 +53,15 @@ export const newRelicGetEntityTool: ToolConfig { data?: TData errors?: GraphQLError[] @@ -40,3 +50,17 @@ export const cleanOptionalString = (value?: string): string | undefined => { const trimmed = value?.trim() return trimmed ? trimmed : undefined } + +export const normalizeNewRelicEntity = (entity: NewRelicRawEntity): NewRelicEntity => ({ + guid: entity.guid ?? null, + name: entity.name ?? null, + entityType: entity.entityType ?? null, + domain: entity.domain ?? null, + reporting: entity.reporting ?? null, + alertSeverity: entity.alertSeverity ?? null, + tags: + entity.tags?.map((tag) => ({ + key: tag?.key ?? null, + values: tag?.values ?? [], + })) ?? [], +}) From 0916b193d95bcec05508dcbd4ca2dc510900abdf Mon Sep 17 00:00:00 2001 From: Waleed Date: Mon, 6 Jul 2026 15:34:00 -0700 Subject: [PATCH 16/35] improvement(instantly): validate integration against live API, add lead/campaign lifecycle tools (#5448) * improvement(instantly): validate integration against live API, add lead/campaign lifecycle tools - Verify all existing Instantly tools against the live API v2 spec - Add instantly_patch_lead, instantly_pause_campaign, instantly_delete_campaign - Fix activate_campaign response mapping to surface the full campaign object - Restore status/ai_sales_agent_id list_campaigns filters * fix(instantly): guard against empty patch_lead request body Follows the same pattern used in tools/langsmith/update_run.ts. --- apps/sim/blocks/blocks/instantly.ts | 58 ++++++-- apps/sim/tools/instantly/activate_campaign.ts | 10 +- apps/sim/tools/instantly/delete_campaign.ts | 54 +++++++ apps/sim/tools/instantly/index.ts | 3 + apps/sim/tools/instantly/list_campaigns.ts | 2 +- apps/sim/tools/instantly/patch_lead.ts | 137 ++++++++++++++++++ apps/sim/tools/instantly/pause_campaign.ts | 54 +++++++ apps/sim/tools/instantly/types.ts | 36 ++++- apps/sim/tools/instantly/utils.ts | 10 ++ apps/sim/tools/registry.ts | 6 + 10 files changed, 350 insertions(+), 20 deletions(-) create mode 100644 apps/sim/tools/instantly/delete_campaign.ts create mode 100644 apps/sim/tools/instantly/patch_lead.ts create mode 100644 apps/sim/tools/instantly/pause_campaign.ts diff --git a/apps/sim/blocks/blocks/instantly.ts b/apps/sim/blocks/blocks/instantly.ts index bdef8922b35..d7bba133a81 100644 --- a/apps/sim/blocks/blocks/instantly.ts +++ b/apps/sim/blocks/blocks/instantly.ts @@ -6,13 +6,23 @@ import type { InstantlyResponse } from '@/tools/instantly/types' import { getTrigger } from '@/triggers' const LEAD_LIST_OPERATIONS = ['list_leads'] as const -const LEAD_ID_OPERATIONS = ['get_lead'] as const +const LEAD_ID_OPERATIONS = ['get_lead', 'patch_lead'] as const const LEAD_CREATE_OPERATIONS = ['create_lead'] as const +const LEAD_PATCH_OPERATIONS = ['patch_lead'] as const +const LEAD_MUTATION_FIELD_OPERATIONS = [ + ...LEAD_CREATE_OPERATIONS, + ...LEAD_PATCH_OPERATIONS, +] as const const LEAD_DELETE_OPERATIONS = ['delete_leads'] as const const LEAD_INTEREST_OPERATIONS = ['update_lead_interest_status'] as const const CAMPAIGN_LIST_OPERATIONS = ['list_campaigns'] as const const CAMPAIGN_MUTATION_OPERATIONS = ['create_campaign', 'patch_campaign'] as const -const CAMPAIGN_ID_OPERATIONS = ['patch_campaign', 'activate_campaign'] as const +const CAMPAIGN_ID_OPERATIONS = [ + 'patch_campaign', + 'activate_campaign', + 'pause_campaign', + 'delete_campaign', +] as const const EMAIL_LIST_OPERATIONS = ['list_emails'] as const const EMAIL_REPLY_OPERATIONS = ['reply_to_email'] as const const LEAD_LIST_LIST_OPERATIONS = ['list_lead_lists'] as const @@ -51,7 +61,7 @@ export const InstantlyBlock: BlockConfig = { name: 'Instantly', description: 'Manage Instantly leads, campaigns, emails, and lead lists', longDescription: - 'Integrate Instantly API V2 into workflows. Create and list leads, manage lead interest status, delete leads in bulk, list and create campaigns, reply to emails, and manage lead lists.', + 'Integrate Instantly API V2 into workflows. Create, update, and list leads, manage lead interest status, delete leads in bulk, list, create, patch, activate, pause, and delete campaigns, reply to emails, and manage lead lists.', docsLink: 'https://docs.sim.ai/integrations/instantly', category: 'tools', integrationType: IntegrationType.Email, @@ -67,12 +77,15 @@ export const InstantlyBlock: BlockConfig = { { label: 'List Leads', id: 'list_leads' }, { label: 'Get Lead', id: 'get_lead' }, { label: 'Create Lead', id: 'create_lead' }, + { label: 'Patch Lead', id: 'patch_lead' }, { label: 'Delete Leads', id: 'delete_leads' }, { label: 'Update Lead Interest Status', id: 'update_lead_interest_status' }, { label: 'List Campaigns', id: 'list_campaigns' }, { label: 'Create Campaign', id: 'create_campaign' }, { label: 'Patch Campaign', id: 'patch_campaign' }, { label: 'Activate Campaign', id: 'activate_campaign' }, + { label: 'Pause Campaign', id: 'pause_campaign' }, + { label: 'Delete Campaign', id: 'delete_campaign' }, { label: 'List Emails', id: 'list_emails' }, { label: 'Reply To Email', id: 'reply_to_email' }, { label: 'List Lead Lists', id: 'list_lead_lists' }, @@ -134,28 +147,28 @@ export const InstantlyBlock: BlockConfig = { title: 'First Name', type: 'short-input', placeholder: 'Jane', - condition: { field: 'operation', value: [...LEAD_CREATE_OPERATIONS] }, + condition: { field: 'operation', value: [...LEAD_MUTATION_FIELD_OPERATIONS] }, }, { id: 'lastName', title: 'Last Name', type: 'short-input', placeholder: 'Doe', - condition: { field: 'operation', value: [...LEAD_CREATE_OPERATIONS] }, + condition: { field: 'operation', value: [...LEAD_MUTATION_FIELD_OPERATIONS] }, }, { id: 'companyName', title: 'Company Name', type: 'short-input', placeholder: 'Acme Inc.', - condition: { field: 'operation', value: [...LEAD_CREATE_OPERATIONS] }, + condition: { field: 'operation', value: [...LEAD_MUTATION_FIELD_OPERATIONS] }, }, { id: 'jobTitle', title: 'Job Title', type: 'short-input', placeholder: 'Head of Growth', - condition: { field: 'operation', value: [...LEAD_CREATE_OPERATIONS] }, + condition: { field: 'operation', value: [...LEAD_MUTATION_FIELD_OPERATIONS] }, mode: 'advanced', }, { @@ -163,7 +176,7 @@ export const InstantlyBlock: BlockConfig = { title: 'Phone', type: 'short-input', placeholder: '+1234567890', - condition: { field: 'operation', value: [...LEAD_CREATE_OPERATIONS] }, + condition: { field: 'operation', value: [...LEAD_MUTATION_FIELD_OPERATIONS] }, mode: 'advanced', }, { @@ -171,7 +184,7 @@ export const InstantlyBlock: BlockConfig = { title: 'Website', type: 'short-input', placeholder: 'https://example.com', - condition: { field: 'operation', value: [...LEAD_CREATE_OPERATIONS] }, + condition: { field: 'operation', value: [...LEAD_MUTATION_FIELD_OPERATIONS] }, mode: 'advanced', }, { @@ -179,7 +192,7 @@ export const InstantlyBlock: BlockConfig = { title: 'Personalization', type: 'long-input', placeholder: 'Personalized opening line', - condition: { field: 'operation', value: [...LEAD_CREATE_OPERATIONS] }, + condition: { field: 'operation', value: [...LEAD_MUTATION_FIELD_OPERATIONS] }, mode: 'advanced', }, { @@ -187,7 +200,7 @@ export const InstantlyBlock: BlockConfig = { title: 'Custom Variables', type: 'long-input', placeholder: '{"past_customer": true, "industry": "SaaS"}', - condition: { field: 'operation', value: [...LEAD_CREATE_OPERATIONS] }, + condition: { field: 'operation', value: [...LEAD_MUTATION_FIELD_OPERATIONS] }, wandConfig: { enabled: true, prompt: @@ -266,7 +279,7 @@ export const InstantlyBlock: BlockConfig = { title: 'Lead Interest Status', type: 'short-input', placeholder: '1', - condition: { field: 'operation', value: [...LEAD_CREATE_OPERATIONS] }, + condition: { field: 'operation', value: [...LEAD_MUTATION_FIELD_OPERATIONS] }, mode: 'advanced', }, { @@ -274,7 +287,7 @@ export const InstantlyBlock: BlockConfig = { title: 'Potential Lead Value', type: 'short-input', placeholder: 'High', - condition: { field: 'operation', value: [...LEAD_CREATE_OPERATIONS] }, + condition: { field: 'operation', value: [...LEAD_MUTATION_FIELD_OPERATIONS] }, mode: 'advanced', }, { @@ -282,7 +295,7 @@ export const InstantlyBlock: BlockConfig = { title: 'Assigned To', type: 'short-input', placeholder: 'Organization user UUID', - condition: { field: 'operation', value: [...LEAD_CREATE_OPERATIONS] }, + condition: { field: 'operation', value: [...LEAD_MUTATION_FIELD_OPERATIONS] }, mode: 'advanced', }, { @@ -842,12 +855,15 @@ export const InstantlyBlock: BlockConfig = { 'instantly_list_leads', 'instantly_get_lead', 'instantly_create_lead', + 'instantly_patch_lead', 'instantly_delete_leads', 'instantly_update_lead_interest_status', 'instantly_list_campaigns', 'instantly_create_campaign', 'instantly_patch_campaign', 'instantly_activate_campaign', + 'instantly_pause_campaign', + 'instantly_delete_campaign', 'instantly_list_emails', 'instantly_reply_to_email', 'instantly_list_lead_lists', @@ -1350,5 +1366,19 @@ export const InstantlyBlockMeta = { content: '# Campaign Performance Snapshot\n\nGive a quick read on how outreach is going across campaigns.\n\n## Steps\n1. List campaigns and note which are active.\n2. For each active campaign, list leads and tally counts by interest status.\n3. Pull recent emails to gauge reply volume.\n\n## Output\nReturn a per-campaign snapshot: total leads, breakdown by interest status, and reply activity, highlighting the top-performing campaign.', }, + { + name: 'clean-up-lead-data', + description: + 'Correct or enrich existing lead records so personalization and CRM sync stay accurate.', + content: + '# Clean Up Lead Data\n\nKeep lead records accurate after enrichment, verification, or manual review.\n\n## Steps\n1. Look up the lead by ID or email.\n2. Compare the current fields against the corrected or enriched data.\n3. Patch the lead with the updated first name, last name, company, job title, phone, website, or custom variables.\n\n## Output\nReturn the updated lead record and a summary of which fields changed.', + }, + { + name: 'pause-underperforming-campaigns', + description: + 'Detect campaigns with poor reply or interest rates and pause them to protect sender reputation.', + content: + '# Pause Underperforming Campaigns\n\nProtect domain and inbox reputation by pausing cold-email campaigns that are not converting.\n\n## Steps\n1. List active campaigns.\n2. For each campaign, list its leads and tally reply and interest counts.\n3. Flag campaigns below a reply-rate threshold.\n4. Pause each flagged campaign so no further emails are sent.\n\n## Output\nReturn which campaigns were paused, their reply rates, and the campaigns left active for comparison.', + }, ], } as const satisfies BlockMeta diff --git a/apps/sim/tools/instantly/activate_campaign.ts b/apps/sim/tools/instantly/activate_campaign.ts index b9755b082ea..2ff9e55b508 100644 --- a/apps/sim/tools/instantly/activate_campaign.ts +++ b/apps/sim/tools/instantly/activate_campaign.ts @@ -1,9 +1,10 @@ import type { InstantlyActivateCampaignParams, - InstantlyCampaignResponse, + InstantlyCampaignActionResponse, } from '@/tools/instantly/types' import { - campaignOutputs, + campaignActionOutputs, + getMessage, instantlyBaseParamFields, instantlyHeaders, instantlyUrl, @@ -14,7 +15,7 @@ import type { ToolConfig } from '@/tools/types' export const activateCampaignTool: ToolConfig< InstantlyActivateCampaignParams, - InstantlyCampaignResponse + InstantlyCampaignActionResponse > = { id: 'instantly_activate_campaign', name: 'Instantly Activate Campaign', @@ -45,8 +46,9 @@ export const activateCampaignTool: ToolConfig< id: campaign.id, name: campaign.name, status: campaign.status, + message: getMessage(data), }, } }, - outputs: campaignOutputs, + outputs: campaignActionOutputs, } diff --git a/apps/sim/tools/instantly/delete_campaign.ts b/apps/sim/tools/instantly/delete_campaign.ts new file mode 100644 index 00000000000..0ae34a49c96 --- /dev/null +++ b/apps/sim/tools/instantly/delete_campaign.ts @@ -0,0 +1,54 @@ +import type { + InstantlyCampaignActionResponse, + InstantlyDeleteCampaignParams, +} from '@/tools/instantly/types' +import { + campaignActionOutputs, + getMessage, + instantlyBaseParamFields, + instantlyHeaders, + instantlyUrl, + mapCampaign, + parseInstantlyResponse, +} from '@/tools/instantly/utils' +import type { ToolConfig } from '@/tools/types' + +export const deleteCampaignTool: ToolConfig< + InstantlyDeleteCampaignParams, + InstantlyCampaignActionResponse +> = { + id: 'instantly_delete_campaign', + name: 'Instantly Delete Campaign', + description: 'Permanently deletes an Instantly V2 campaign.', + version: '1.0.0', + params: { + ...instantlyBaseParamFields, + campaignId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Campaign ID', + }, + }, + request: { + url: (params) => instantlyUrl(`/api/v2/campaigns/${params.campaignId.trim()}`), + method: 'DELETE', + headers: instantlyHeaders, + }, + transformResponse: async (response) => { + const data = await parseInstantlyResponse(response) + const campaign = mapCampaign(data) + + return { + success: true, + output: { + campaign, + id: campaign.id, + name: campaign.name, + status: campaign.status, + message: getMessage(data), + }, + } + }, + outputs: campaignActionOutputs, +} diff --git a/apps/sim/tools/instantly/index.ts b/apps/sim/tools/instantly/index.ts index 6af2ed62141..7516140dc58 100644 --- a/apps/sim/tools/instantly/index.ts +++ b/apps/sim/tools/instantly/index.ts @@ -2,6 +2,7 @@ export { activateCampaignTool as instantlyActivateCampaignTool } from '@/tools/i export { createCampaignTool as instantlyCreateCampaignTool } from '@/tools/instantly/create_campaign' export { createLeadTool as instantlyCreateLeadTool } from '@/tools/instantly/create_lead' export { createLeadListTool as instantlyCreateLeadListTool } from '@/tools/instantly/create_lead_list' +export { deleteCampaignTool as instantlyDeleteCampaignTool } from '@/tools/instantly/delete_campaign' export { deleteLeadsTool as instantlyDeleteLeadsTool } from '@/tools/instantly/delete_leads' export { getLeadTool as instantlyGetLeadTool } from '@/tools/instantly/get_lead' export { listCampaignsTool as instantlyListCampaignsTool } from '@/tools/instantly/list_campaigns' @@ -9,6 +10,8 @@ export { listEmailsTool as instantlyListEmailsTool } from '@/tools/instantly/lis export { listLeadListsTool as instantlyListLeadListsTool } from '@/tools/instantly/list_lead_lists' export { listLeadsTool as instantlyListLeadsTool } from '@/tools/instantly/list_leads' export { patchCampaignTool as instantlyPatchCampaignTool } from '@/tools/instantly/patch_campaign' +export { patchLeadTool as instantlyPatchLeadTool } from '@/tools/instantly/patch_lead' +export { pauseCampaignTool as instantlyPauseCampaignTool } from '@/tools/instantly/pause_campaign' export { replyToEmailTool as instantlyReplyToEmailTool } from '@/tools/instantly/reply_to_email' export * from '@/tools/instantly/types' export { updateLeadInterestStatusTool as instantlyUpdateLeadInterestStatusTool } from '@/tools/instantly/update_lead_interest_status' diff --git a/apps/sim/tools/instantly/list_campaigns.ts b/apps/sim/tools/instantly/list_campaigns.ts index fa51ca23b96..8c4619f63e8 100644 --- a/apps/sim/tools/instantly/list_campaigns.ts +++ b/apps/sim/tools/instantly/list_campaigns.ts @@ -52,7 +52,7 @@ export const listCampaignsTool: ToolConfig< type: 'string', required: false, visibility: 'user-or-llm', - description: 'AI Sales Agent ID filter', + description: 'Filter campaigns by AI Sales Agent ID', }, status: { type: 'number', diff --git a/apps/sim/tools/instantly/patch_lead.ts b/apps/sim/tools/instantly/patch_lead.ts new file mode 100644 index 00000000000..230b5751cf2 --- /dev/null +++ b/apps/sim/tools/instantly/patch_lead.ts @@ -0,0 +1,137 @@ +import type { InstantlyLeadResponse, InstantlyPatchLeadParams } from '@/tools/instantly/types' +import { + compactBody, + instantlyBaseParamFields, + instantlyHeaders, + instantlyUrl, + leadOutputs, + mapLead, + parseInstantlyResponse, +} from '@/tools/instantly/utils' +import type { ToolConfig } from '@/tools/types' + +export const patchLeadTool: ToolConfig = { + id: 'instantly_patch_lead', + name: 'Instantly Patch Lead', + description: 'Updates fields on an existing Instantly V2 lead.', + version: '1.0.0', + params: { + ...instantlyBaseParamFields, + leadId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Lead ID', + }, + first_name: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Lead first name', + }, + last_name: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Lead last name', + }, + company_name: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Lead company name', + }, + job_title: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Lead job title', + }, + phone: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Lead phone number', + }, + website: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Lead website', + }, + personalization: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Lead personalization text', + }, + lt_interest_status: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Lead interest status value', + }, + pl_value_lead: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Potential value of the lead', + }, + assigned_to: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'ID of the user assigned to the lead', + }, + custom_variables: { + type: 'json', + required: false, + visibility: 'user-or-llm', + description: 'Custom variable object with string, number, boolean, or null values', + }, + }, + request: { + url: (params) => instantlyUrl(`/api/v2/leads/${params.leadId.trim()}`), + method: 'PATCH', + headers: instantlyHeaders, + body: (params) => { + const body = compactBody({ + first_name: params.first_name, + last_name: params.last_name, + company_name: params.company_name, + job_title: params.job_title, + phone: params.phone, + website: params.website, + personalization: params.personalization, + lt_interest_status: params.lt_interest_status, + pl_value_lead: params.pl_value_lead, + assigned_to: params.assigned_to, + custom_variables: params.custom_variables, + }) + + if (Object.keys(body).length === 0) { + throw new Error('Provide at least one field to update') + } + + return body + }, + }, + transformResponse: async (response) => { + const data = await parseInstantlyResponse(response) + const lead = mapLead(data) + + return { + success: true, + output: { + lead, + id: lead.id, + email_address: lead.email, + first_name: lead.first_name, + last_name: lead.last_name, + campaign: lead.campaign, + status: lead.status, + }, + } + }, + outputs: leadOutputs, +} diff --git a/apps/sim/tools/instantly/pause_campaign.ts b/apps/sim/tools/instantly/pause_campaign.ts new file mode 100644 index 00000000000..48077fc06bc --- /dev/null +++ b/apps/sim/tools/instantly/pause_campaign.ts @@ -0,0 +1,54 @@ +import type { + InstantlyCampaignActionResponse, + InstantlyPauseCampaignParams, +} from '@/tools/instantly/types' +import { + campaignActionOutputs, + getMessage, + instantlyBaseParamFields, + instantlyHeaders, + instantlyUrl, + mapCampaign, + parseInstantlyResponse, +} from '@/tools/instantly/utils' +import type { ToolConfig } from '@/tools/types' + +export const pauseCampaignTool: ToolConfig< + InstantlyPauseCampaignParams, + InstantlyCampaignActionResponse +> = { + id: 'instantly_pause_campaign', + name: 'Instantly Pause Campaign', + description: 'Pauses a running Instantly V2 campaign, stopping further email sends.', + version: '1.0.0', + params: { + ...instantlyBaseParamFields, + campaignId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Campaign ID', + }, + }, + request: { + url: (params) => instantlyUrl(`/api/v2/campaigns/${params.campaignId.trim()}/pause`), + method: 'POST', + headers: instantlyHeaders, + }, + transformResponse: async (response) => { + const data = await parseInstantlyResponse(response) + const campaign = mapCampaign(data) + + return { + success: true, + output: { + campaign, + id: campaign.id, + name: campaign.name, + status: campaign.status, + message: getMessage(data), + }, + } + }, + outputs: campaignActionOutputs, +} diff --git a/apps/sim/tools/instantly/types.ts b/apps/sim/tools/instantly/types.ts index 4c22923da63..6b30bb67401 100644 --- a/apps/sim/tools/instantly/types.ts +++ b/apps/sim/tools/instantly/types.ts @@ -109,6 +109,21 @@ export interface InstantlyGetLeadParams extends InstantlyBaseParams { leadId: string } +export interface InstantlyPatchLeadParams extends InstantlyBaseParams { + leadId: string + personalization?: string | null + website?: string | null + last_name?: string | null + first_name?: string | null + company_name?: string | null + job_title?: string | null + phone?: string | null + lt_interest_status?: number + pl_value_lead?: string | null + assigned_to?: string | null + custom_variables?: Record +} + export interface InstantlyCreateLeadParams extends InstantlyBaseParams { campaign?: string | null email?: string | null @@ -142,7 +157,7 @@ export interface InstantlyDeleteLeadsParams extends InstantlyBaseParams { export interface InstantlyUpdateLeadInterestStatusParams extends InstantlyBaseParams { lead_email: string - interest_value: number | null + interest_value?: number | null campaign_id?: string ai_interest_value?: number disable_auto_interest?: boolean @@ -182,6 +197,14 @@ export interface InstantlyActivateCampaignParams extends InstantlyBaseParams { campaignId: string } +export interface InstantlyPauseCampaignParams extends InstantlyBaseParams { + campaignId: string +} + +export interface InstantlyDeleteCampaignParams extends InstantlyBaseParams { + campaignId: string +} + export interface InstantlyListEmailsParams extends InstantlyBaseParams { limit?: number starting_after?: string @@ -268,6 +291,16 @@ export interface InstantlyCampaignResponse extends ToolResponse { } } +export interface InstantlyCampaignActionResponse extends ToolResponse { + output: { + campaign: InstantlyCampaign + id: string | null + name: string | null + status: number | null + message: string | null + } +} + export interface InstantlyListEmailsResponse extends ToolResponse { output: { emails: InstantlyEmail[] @@ -308,6 +341,7 @@ export type InstantlyResponse = | InstantlyUpdateLeadInterestStatusResponse | InstantlyListCampaignsResponse | InstantlyCampaignResponse + | InstantlyCampaignActionResponse | InstantlyListEmailsResponse | InstantlyEmailResponse | InstantlyListLeadListsResponse diff --git a/apps/sim/tools/instantly/utils.ts b/apps/sim/tools/instantly/utils.ts index a7d3b62ce99..222f7b06a37 100644 --- a/apps/sim/tools/instantly/utils.ts +++ b/apps/sim/tools/instantly/utils.ts @@ -232,6 +232,11 @@ export const campaignOutputs = { status: { type: 'number', description: 'Campaign status', optional: true }, } satisfies ToolConfig['outputs'] +export const campaignActionOutputs = { + ...campaignOutputs, + message: { type: 'string', description: 'Confirmation message from Instantly', optional: true }, +} satisfies ToolConfig['outputs'] + export const campaignsListOutputs = { campaigns: { type: 'array', @@ -342,6 +347,11 @@ async function parseJsonResponse(response: Response): Promise { } } +export function getMessage(value: unknown): string | null { + const data = asRecord(value) + return typeof data.message === 'string' ? data.message : null +} + function extractInstantlyError(value: unknown, fallback: string): string { const data = asRecord(value) if (typeof data.message === 'string') return data.message diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index c293f27acf2..0e008f5c371 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -1743,6 +1743,7 @@ import { instantlyCreateCampaignTool, instantlyCreateLeadListTool, instantlyCreateLeadTool, + instantlyDeleteCampaignTool, instantlyDeleteLeadsTool, instantlyGetLeadTool, instantlyListCampaignsTool, @@ -1750,6 +1751,8 @@ import { instantlyListLeadListsTool, instantlyListLeadsTool, instantlyPatchCampaignTool, + instantlyPatchLeadTool, + instantlyPauseCampaignTool, instantlyReplyToEmailTool, instantlyUpdateLeadInterestStatusTool, } from '@/tools/instantly' @@ -4672,6 +4675,7 @@ export const tools: Record = { instantly_create_campaign: instantlyCreateCampaignTool, instantly_create_lead: instantlyCreateLeadTool, instantly_create_lead_list: instantlyCreateLeadListTool, + instantly_delete_campaign: instantlyDeleteCampaignTool, instantly_delete_leads: instantlyDeleteLeadsTool, instantly_get_lead: instantlyGetLeadTool, instantly_list_campaigns: instantlyListCampaignsTool, @@ -4679,6 +4683,8 @@ export const tools: Record = { instantly_list_lead_lists: instantlyListLeadListsTool, instantly_list_leads: instantlyListLeadsTool, instantly_patch_campaign: instantlyPatchCampaignTool, + instantly_patch_lead: instantlyPatchLeadTool, + instantly_pause_campaign: instantlyPauseCampaignTool, instantly_reply_to_email: instantlyReplyToEmailTool, instantly_update_lead_interest_status: instantlyUpdateLeadInterestStatusTool, jina_read_url: jinaReadUrlTool, From 6d5ac583bc0b4279b116c7861adc46572d9f1533 Mon Sep 17 00:00:00 2001 From: Waleed Date: Mon, 6 Jul 2026 15:41:14 -0700 Subject: [PATCH 17/35] fix(cloudflare): align integration with live API, fix type-coercion bug (#5444) * fix(cloudflare): align integration with live API, fix type-coercion bug - fix update_zone_setting body builder: was blindly JSON.parse-ing every value, silently coercing scalar settings (e.g. min_tls_version "1.2") to the wrong type; now only parses object/array literals - fix list_dns_records name/content/tag filters to use Cloudflare's exact-match dotted param names (name.exact/content.exact/tag.exact) - remove auto_minify (never a real setting ID) and minify (deprecated and removed from the live zone-settings API in Aug 2024) - remove dead jump_start param from create_zone (not part of the current POST /zones schema) - fix block bgColor from #F5F6FA to Cloudflare's actual brand orange #F38020 - add missing secondary zone type option and plan.id sort option - expand BlockMeta skills/templates * fix(cloudflare): reject empty browser_cache_ttl value instead of coercing to 0 * fix(cloudflare): address Greptile review feedback - use trimmed value consistently in update_zone_setting fallback paths - document that tag_match has no effect with a single exact-match tag filter (Cloudflare's API only combines multiple tag conditions) * fix(cloudflare): coerce non-string setting values before trim Wand-generated or block-referenced values can arrive as a number at runtime despite the declared string param type, which crashed the body builder's .trim() call. * fix(cloudflare): harden null/undefined value handling, fix stale tag description - coerce null/undefined value to empty string instead of literal "null"/"undefined" strings before trimming - fix stale block-level 'tag' input description left over from the comma-separated-tags -> exact-match-tag filter fix * fix(cloudflare): pass through structured array/object values as-is A block-referenced array/object value was being blindly stringified before the JSON-shape check, so String([...]) comma-joined arrays and String({...}) produced the literal "[object Object]" instead of the JSON shape Cloudflare expects (e.g. for ciphers). Already-structured values now pass straight through. * revert(cloudflare): keep original block bgColor (#F5F6FA) --- .../docs/en/integrations/cloudflare.mdx | 19 ++++---- apps/sim/blocks/blocks/cloudflare.ts | 48 ++++++++----------- apps/sim/lib/integrations/integrations.json | 6 +-- apps/sim/tools/cloudflare/create_zone.ts | 7 --- .../sim/tools/cloudflare/get_zone_settings.ts | 4 +- apps/sim/tools/cloudflare/list_dns_records.ts | 11 +++-- apps/sim/tools/cloudflare/list_zones.ts | 2 +- apps/sim/tools/cloudflare/types.ts | 1 - .../tools/cloudflare/update_zone_setting.ts | 41 +++++++++++++--- 9 files changed, 75 insertions(+), 64 deletions(-) diff --git a/apps/docs/content/docs/en/integrations/cloudflare.mdx b/apps/docs/content/docs/en/integrations/cloudflare.mdx index 1876af3dda0..4c9bc7fffdd 100644 --- a/apps/docs/content/docs/en/integrations/cloudflare.mdx +++ b/apps/docs/content/docs/en/integrations/cloudflare.mdx @@ -47,7 +47,7 @@ Lists all zones (domains) in the Cloudflare account. | `page` | number | No | Page number for pagination \(default: 1\) | | `per_page` | number | No | Number of zones per page \(default: 20, max: 50\) | | `accountId` | string | No | Filter zones by account ID | -| `order` | string | No | Sort field \(name, status, account.id, account.name\) | +| `order` | string | No | Sort field \(name, status, account.id, account.name, plan.id\) | | `direction` | string | No | Sort direction \(asc, desc\) | | `match` | string | No | Match logic for filters \(any, all\). Default: all | | `apiKey` | string | Yes | Cloudflare API Token | @@ -158,7 +158,6 @@ Adds a new zone (domain) to the Cloudflare account. | `name` | string | Yes | The domain name to add \(e.g., "example.com"\) | | `accountId` | string | Yes | The Cloudflare account ID | | `type` | string | No | Zone type: "full" \(Cloudflare manages DNS\), "partial" \(CNAME setup\), or "secondary" \(secondary DNS\) | -| `jump_start` | boolean | No | Automatically attempt to fetch existing DNS records when creating the zone | | `apiKey` | string | Yes | Cloudflare API Token | #### Output @@ -238,8 +237,8 @@ Lists DNS records for a specific zone. | `order` | string | No | Sort field \(type, name, content, ttl, proxied\) | | `proxied` | boolean | No | Filter by proxy status | | `search` | string | No | Free-text search across record name, content, and value | -| `tag` | string | No | Filter by tags \(comma-separated\) | -| `tag_match` | string | No | Tag filter match logic: any or all | +| `tag` | string | No | Filter by an exact tag name | +| `tag_match` | string | No | Tag filter match logic: any or all. Only affects results when combined with multiple tag filter conditions; has no effect with the single exact-match Tag Filter above. | | `commentFilter` | string | No | Filter records by comment content \(substring match\) | | `apiKey` | string | Yes | Cloudflare API Token | @@ -441,7 +440,7 @@ Lists SSL/TLS certificate packs for a zone. ### `cloudflare_get_zone_settings` -Gets all settings for a zone including SSL mode, minification, caching level, and security settings. +Gets all settings for a zone including SSL mode, caching level, and security settings. #### Input @@ -455,7 +454,7 @@ Gets all settings for a zone including SSL mode, minification, caching level, an | Parameter | Type | Description | | --------- | ---- | ----------- | | `settings` | array | List of zone settings | -| ↳ `id` | string | Setting identifier \(e.g., ssl, minify, cache_level, security_level, always_use_https\) | +| ↳ `id` | string | Setting identifier \(e.g., ssl, cache_level, security_level, always_use_https\) | | ↳ `value` | string | Setting value as a string. Simple values returned as-is \(e.g., "full", "on"\). Complex values are JSON-stringified \(e.g., \ | | ↳ `editable` | boolean | Whether the setting can be modified for the current zone plan | | ↳ `modified_on` | string | ISO 8601 timestamp when the setting was last modified | @@ -463,22 +462,22 @@ Gets all settings for a zone including SSL mode, minification, caching level, an ### `cloudflare_update_zone_setting` -Updates a specific zone setting such as SSL mode, security level, cache level, minification, or other configuration. +Updates a specific zone setting such as SSL mode, security level, cache level, or other configuration. #### Input | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `zoneId` | string | Yes | The zone ID to update settings for | -| `settingId` | string | Yes | Setting to update \(e.g., "ssl", "security_level", "cache_level", "minify", "always_use_https", "browser_cache_ttl", "http3", "min_tls_version", "ciphers"\) | -| `value` | string | Yes | New value for the setting as a string or JSON string for complex values \(e.g., "full" for SSL, "medium" for security_level, "aggressive" for cache_level, \'\{"css":"on","html":"on","js":"on"\}\' for minify, \'\["ECDHE-RSA-AES128-GCM-SHA256"\]\' for ciphers\) | +| `settingId` | string | Yes | Setting to update \(e.g., "ssl", "security_level", "cache_level", "always_use_https", "browser_cache_ttl", "http3", "min_tls_version", "ciphers"\) | +| `value` | string | Yes | New value for the setting as a string or JSON string for complex values \(e.g., "full" for SSL, "medium" for security_level, "aggressive" for cache_level, \'\["ECDHE-RSA-AES128-GCM-SHA256"\]\' for ciphers\) | | `apiKey` | string | Yes | Cloudflare API Token | #### Output | Parameter | Type | Description | | --------- | ---- | ----------- | -| `id` | string | Setting identifier \(e.g., ssl, minify, cache_level\) | +| `id` | string | Setting identifier \(e.g., ssl, cache_level, security_level\) | | `value` | string | Updated setting value as a string. Simple values returned as-is \(e.g., "full", "on"\). Complex values are JSON-stringified. | | `editable` | boolean | Whether the setting can be modified for the current zone plan | | `modified_on` | string | ISO 8601 timestamp when the setting was last modified | diff --git a/apps/sim/blocks/blocks/cloudflare.ts b/apps/sim/blocks/blocks/cloudflare.ts index db764d92d9f..aa8663f0590 100644 --- a/apps/sim/blocks/blocks/cloudflare.ts +++ b/apps/sim/blocks/blocks/cloudflare.ts @@ -95,6 +95,7 @@ export const CloudflareBlock: BlockConfig = { { label: 'Status', id: 'status' }, { label: 'Account ID', id: 'account.id' }, { label: 'Account Name', id: 'account.name' }, + { label: 'Plan ID', id: 'plan.id' }, ], value: () => '', condition: { field: 'operation', value: 'list_zones' }, @@ -151,24 +152,12 @@ export const CloudflareBlock: BlockConfig = { options: [ { label: 'Full (Cloudflare DNS)', id: 'full' }, { label: 'Partial (CNAME Setup)', id: 'partial' }, + { label: 'Secondary (Secondary DNS)', id: 'secondary' }, ], value: () => 'full', condition: { field: 'operation', value: 'create_zone' }, mode: 'advanced', }, - { - id: 'jump_start', - title: 'Auto-Import DNS', - type: 'dropdown', - options: [ - { label: 'No', id: 'false' }, - { label: 'Yes', id: 'true' }, - ], - value: () => 'false', - condition: { field: 'operation', value: 'create_zone' }, - mode: 'advanced', - }, - // Get Zone inputs { id: 'zoneId', @@ -299,7 +288,7 @@ export const CloudflareBlock: BlockConfig = { id: 'tag', title: 'Tag Filter', type: 'short-input', - placeholder: 'Comma-separated tags to filter by', + placeholder: 'Exact tag name to filter by', condition: { field: 'operation', value: 'list_dns_records' }, mode: 'advanced', }, @@ -623,8 +612,6 @@ export const CloudflareBlock: BlockConfig = { { label: 'Security Level', id: 'security_level' }, { label: 'Cache Level', id: 'cache_level' }, { label: 'Browser Cache TTL', id: 'browser_cache_ttl' }, - { label: 'Minification', id: 'minify' }, - { label: 'Auto Minify', id: 'auto_minify' }, { label: 'Rocket Loader', id: 'rocket_loader' }, { label: 'Email Obfuscation', id: 'email_obfuscation' }, { label: 'Hotlink Protection', id: 'hotlink_protection' }, @@ -655,7 +642,6 @@ Common settings and their valid values: - security_level: "off", "essentially_off", "low", "medium", "high", "under_attack" - cache_level: "aggressive", "basic", "simplified" - browser_cache_ttl: number in seconds (e.g., 14400 for 4 hours, 86400 for 1 day) -- minify: JSON object {"css":"on","html":"off","js":"on"} - rocket_loader: "on", "off" - email_obfuscation: "on", "off" - hotlink_protection: "on", "off" @@ -667,12 +653,11 @@ Common settings and their valid values: - min_tls_version: "1.0", "1.1", "1.2", "1.3" For simple string/boolean settings, return the plain value (e.g., "full", "on"). -For complex settings like minify, return the JSON string (e.g., {"css":"on","html":"on","js":"on"}). For numeric settings like browser_cache_ttl, return the number (e.g., 14400). Return ONLY the value - no explanations, no extra text.`, placeholder: - 'Describe the setting value (e.g., "enable strict SSL", "minify CSS and JS")...', + 'Describe the setting value (e.g., "enable strict SSL", "cache everything")...', }, }, @@ -953,9 +938,6 @@ Return ONLY the comma-separated URLs - no explanations, no extra text.`, if (result.purge_everything === 'true') result.purge_everything = true else if (result.purge_everything === 'false') result.purge_everything = false - if (result.jump_start === 'true') result.jump_start = true - else if (result.jump_start === 'false') result.jump_start = false - if (result.type === '' && result.operation !== 'create_dns_record') { result.type = undefined } @@ -985,11 +967,7 @@ Return ONLY the comma-separated URLs - no explanations, no extra text.`, apiKey: { type: 'string', description: 'Cloudflare API token' }, zoneId: { type: 'string', description: 'Zone ID' }, accountId: { type: 'string', description: 'Cloudflare account ID' }, - zoneType: { type: 'string', description: 'Zone type (full or partial)' }, - jump_start: { - type: 'boolean', - description: 'Automatically import DNS records when creating a zone', - }, + zoneType: { type: 'string', description: 'Zone type (full, partial, or secondary)' }, order: { type: 'string', description: 'Sort field for listing zones' }, direction: { type: 'string', description: 'Sort direction (asc, desc)' }, match: { type: 'string', description: 'Match logic for filters (any, all)' }, @@ -1002,7 +980,7 @@ Return ONLY the comma-separated URLs - no explanations, no extra text.`, priority: { type: 'number', description: 'Record priority (MX/SRV)' }, comment: { type: 'string', description: 'Record comment' }, search: { type: 'string', description: 'Free-text search across record properties' }, - tag: { type: 'string', description: 'Comma-separated tags to filter by' }, + tag: { type: 'string', description: 'Filter by an exact tag name' }, tag_match: { type: 'string', description: 'Tag filter match logic (any, all)' }, commentFilter: { type: 'string', description: 'Filter records by comment content' }, settingId: { type: 'string', description: 'Zone setting ID' }, @@ -1192,5 +1170,19 @@ export const CloudflareBlockMeta = { content: '# Check SSL and Zone Settings\n\nVerify SSL/TLS posture and key security settings across zones.\n\n## Steps\n1. List the target zones.\n2. For each zone read SSL mode, certificate status/expiry, minimum TLS version, and security level.\n3. Compare against the desired baseline (e.g. Full Strict, TLS 1.2+).\n4. Flag expiring certs and any setting weaker than the baseline.\n\n## Output\nA per-zone table of SSL status, settings, and any drift that needs remediation.', }, + { + name: 'provision-new-zone', + description: + 'Onboard a new domain onto Cloudflare: create the zone, add starter DNS records, and return the nameservers to hand off for delegation.', + content: + '# Provision a New Cloudflare Zone\n\nStand up a new domain on Cloudflare so it can be pointed at Cloudflare nameservers.\n\n## Steps\n1. Create the zone for the domain under the target account.\n2. Add the initial DNS records the domain needs (A/AAAA for the apex, CNAME for www, MX/TXT for mail as required).\n3. Read back the assigned Cloudflare name servers from the created zone.\n4. Summarize the zone ID, initial records created, and the name servers the registrar needs to be updated to.\n\n## Output\nThe new zone ID, the records created, and the name servers to hand off for delegation.', + }, + { + name: 'setup-email-authentication-records', + description: + 'Add or update the SPF, DKIM, and DMARC TXT records a zone needs to authenticate outbound email and improve deliverability.', + content: + '# Set Up Email Authentication Records\n\nEmail providers (Google Workspace, Microsoft 365, transactional senders) require SPF, DKIM, and DMARC TXT records to authenticate mail and avoid it being marked as spam.\n\n## Steps\n1. Resolve the zone ID for the sending domain.\n2. List existing TXT records to check for conflicting or duplicate SPF/DMARC entries.\n3. Create or update the SPF TXT record (`v=spf1 ...`), the DKIM selector TXT record, and the DMARC TXT record (`_dmarc` name, `v=DMARC1; ...` policy) with the values the mail provider supplies.\n4. Confirm each record was created with the correct name, type, and content.\n\n## Output\nA confirmation of the SPF, DKIM, and DMARC records now in place, with their record IDs and TTLs.', + }, ], } as const satisfies BlockMeta diff --git a/apps/sim/lib/integrations/integrations.json b/apps/sim/lib/integrations/integrations.json index 449e5c883d8..7790433367d 100644 --- a/apps/sim/lib/integrations/integrations.json +++ b/apps/sim/lib/integrations/integrations.json @@ -1,5 +1,5 @@ { - "updatedAt": "2026-07-02", + "updatedAt": "2026-07-06", "integrations": [ { "type": "onepassword", @@ -3063,11 +3063,11 @@ }, { "name": "Get Zone Settings", - "description": "Gets all settings for a zone including SSL mode, minification, caching level, and security settings." + "description": "Gets all settings for a zone including SSL mode, caching level, and security settings." }, { "name": "Update Zone Setting", - "description": "Updates a specific zone setting such as SSL mode, security level, cache level, minification, or other configuration." + "description": "Updates a specific zone setting such as SSL mode, security level, cache level, or other configuration." }, { "name": "DNS Analytics", diff --git a/apps/sim/tools/cloudflare/create_zone.ts b/apps/sim/tools/cloudflare/create_zone.ts index 81209328bdb..03fda6fb181 100644 --- a/apps/sim/tools/cloudflare/create_zone.ts +++ b/apps/sim/tools/cloudflare/create_zone.ts @@ -31,12 +31,6 @@ export const createZoneTool: ToolConfig { const url = new URL(`https://api.cloudflare.com/client/v4/zones/${params.zoneId}/dns_records`) if (params.type) url.searchParams.append('type', params.type) - if (params.name) url.searchParams.append('name', params.name) - if (params.content) url.searchParams.append('content', params.content) + if (params.name) url.searchParams.append('name.exact', params.name) + if (params.content) url.searchParams.append('content.exact', params.content) if (params.page) url.searchParams.append('page', String(params.page)) if (params.per_page) url.searchParams.append('per_page', String(params.per_page)) if (params.direction) url.searchParams.append('direction', params.direction) @@ -119,7 +120,7 @@ export const listDnsRecordsTool: ToolConfig< if (params.order) url.searchParams.append('order', params.order) if (params.proxied !== undefined) url.searchParams.append('proxied', String(params.proxied)) if (params.search) url.searchParams.append('search', params.search) - if (params.tag) url.searchParams.append('tag', params.tag) + if (params.tag) url.searchParams.append('tag.exact', params.tag) if (params.tag_match) url.searchParams.append('tag_match', params.tag_match) if (params.commentFilter) url.searchParams.append('comment.contains', params.commentFilter) return url.toString() diff --git a/apps/sim/tools/cloudflare/list_zones.ts b/apps/sim/tools/cloudflare/list_zones.ts index ee165c69402..10bfe850b76 100644 --- a/apps/sim/tools/cloudflare/list_zones.ts +++ b/apps/sim/tools/cloudflare/list_zones.ts @@ -45,7 +45,7 @@ export const listZonesTool: ToolConfig { - try { - return { value: JSON.parse(params.value) } - } catch { + // A block reference can pass an already-structured array/object straight + // through despite the declared string param type — send it as-is rather + // than stringifying it (String([...]) comma-joins, String({...}) yields + // "[object Object]", neither of which is the JSON shape Cloudflare expects). + if (params.value !== null && typeof params.value === 'object') { return { value: params.value } } + + // Wand-generated values can also arrive as a non-string primitive + // (e.g. a number, or null/undefined) at runtime despite the declared + // param type — coerce null/undefined to '' rather than the literal + // "null"/"undefined" strings String() would otherwise produce. + const trimmed = (params.value == null ? '' : String(params.value)).trim() + + // browser_cache_ttl is the one setting whose value must be a number. + // Number('') is 0, not NaN, so an empty value must be rejected explicitly. + if (params.settingId === 'browser_cache_ttl') { + const numeric = trimmed === '' ? Number.NaN : Number(trimmed) + return { value: Number.isNaN(numeric) ? trimmed : numeric } + } + + // Only parse JSON object/array literals (e.g. ciphers). Scalar settings like + // min_tls_version ("1.2") or tls_1_3 ("on") must stay strings — a blind JSON.parse would + // silently coerce them to numbers/booleans and the Cloudflare API would reject the type. + if (trimmed.startsWith('{') || trimmed.startsWith('[')) { + try { + return { value: JSON.parse(trimmed) } + } catch { + return { value: trimmed } + } + } + return { value: trimmed } }, }, @@ -92,7 +119,7 @@ export const updateZoneSettingTool: ToolConfig< outputs: { id: { type: 'string', - description: 'Setting identifier (e.g., ssl, minify, cache_level)', + description: 'Setting identifier (e.g., ssl, cache_level, security_level)', }, value: { type: 'string', From d64ad856f191f3f775e626dfaff7d71bea6dc893 Mon Sep 17 00:00:00 2001 From: Waleed Date: Mon, 6 Jul 2026 15:48:43 -0700 Subject: [PATCH 18/35] improvement(microsoft-ad): add pagination support and fill BlockMeta gaps (#5442) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * improvement(microsoft-ad): add pagination support and fill BlockMeta gaps Full validate-integration pass against live Microsoft Graph API docs. No critical bugs found (endpoints, methods, params, and OAuth scopes were already correct). Fixed the real gaps: - List Users/Groups/Group Members had no way to page past the default Graph page size (100, max 999 via $top), which silently truncated results for the block's own audit/sweep templates. Added a nextLink input/output following the same convention as microsoft_dataverse. - Create Group's visibility dropdown was missing HiddenMembership, a valid Microsoft 365-only value that can only be set at creation time. - Rounded out BlockMeta skills (3 -> 5) with two more real, tool-grounded use cases: directory search and ad hoc group membership changes. * fix(microsoft-ad): correct $search syntax and create-user response fields Independent 4-agent re-audit against live Graph docs surfaced two real bugs predating this PR: - list_users/list_groups sent $search="" with no property prefix. Graph requires the "property:value" form for directory-object search (e.g. "displayName:term" OR "mail:term") and 400s on a bare string. - create_user's POST had no $select, so Graph's default create response omits department/accountEnabled even when submitted, making transformResponse report them back as null. Added the same $select used by list/get so the response reflects what was actually set. Also tightened create_group's visibility description: only HiddenMembership is create-only: Private/Public can still be changed after creation via Update Group. * fix(microsoft-ad): validate nextLink origin, allow nextLink-only pagination Greptile and Cursor both flagged the same real issue: nextLink was passed straight to fetch() as the request URL with no origin check, while the OAuth bearer token was always attached. A crafted or prompt-injected nextLink pointing outside graph.microsoft.com would exfiltrate the token. Fix: reuse the existing assertGraphNextPageUrl/getGraphNextPageUrl helpers from tools/sharepoint/utils (already the shared pattern for SharePoint, OneDrive, Teams, Planner, Outlook, Excel Graph pagination) instead of a bespoke unvalidated pass-through. Cursor also caught that list_group_members required groupId even when only nextLink was supplied for a later page. Relaxed groupId to optional at the tool level, matching how sharepoint_get_list treats its analogous listId param — the URL builder still throws a clear error if neither groupId nor nextLink is given. * fix(microsoft-ad): allow $search+$filter combo, escape backslashes, fix pagination UX Second independent 4-agent re-audit of the final state (post security fix) surfaced 3 more real issues: - list_users/list_groups threw an error whenever $search and $filter were both supplied, claiming Graph doesn't support combining them. It does (AND semantics, documented) — the check was blocking valid, documented usage for no reason. Removed it. - The $search term escaping only handled embedded double quotes, not backslashes, which Graph's own escaping rule also requires. Fixed the replace order (backslashes first, then quotes). - list_group_members's Group ID field was still hard-required in the block UI for every operation including list_group_members, undermining the nextLink-only pagination path added earlier (the tool itself no longer requires it). Dropped list_group_members from the UI-required list, matching the tool's own conditional requirement — the runtime "Group ID is required" check still catches a genuinely empty call. --- apps/sim/blocks/blocks/microsoft_ad.ts | 33 +++++++++++++++++-- apps/sim/tools/microsoft_ad/create_group.ts | 3 +- apps/sim/tools/microsoft_ad/create_user.ts | 2 +- .../tools/microsoft_ad/list_group_members.ts | 19 +++++++++-- apps/sim/tools/microsoft_ad/list_groups.ts | 23 ++++++++++--- apps/sim/tools/microsoft_ad/list_users.ts | 21 +++++++++--- apps/sim/tools/microsoft_ad/types.ts | 8 ++++- 7 files changed, 93 insertions(+), 16 deletions(-) diff --git a/apps/sim/blocks/blocks/microsoft_ad.ts b/apps/sim/blocks/blocks/microsoft_ad.ts index f5b662dacac..443a21d19fb 100644 --- a/apps/sim/blocks/blocks/microsoft_ad.ts +++ b/apps/sim/blocks/blocks/microsoft_ad.ts @@ -189,6 +189,17 @@ export const MicrosoftAdBlock: BlockConfig = { condition: { field: 'operation', value: ['list_users', 'list_groups'] }, mode: 'advanced', }, + { + id: 'nextLink', + title: 'Next Page', + type: 'short-input', + placeholder: "Paste the previous response's nextLink to fetch the next page", + condition: { + field: 'operation', + value: ['list_users', 'list_groups', 'list_group_members'], + }, + mode: 'advanced', + }, // Group ID field { id: 'groupId', @@ -212,7 +223,6 @@ export const MicrosoftAdBlock: BlockConfig = { 'get_group', 'update_group', 'delete_group', - 'list_group_members', 'add_group_member', 'remove_group_member', ], @@ -296,6 +306,7 @@ export const MicrosoftAdBlock: BlockConfig = { options: [ { label: 'Private', id: 'Private' }, { label: 'Public', id: 'Public' }, + { label: 'Hidden Membership (Microsoft 365 groups only)', id: 'HiddenMembership' }, ], value: () => 'Private', condition: { field: 'operation', value: 'create_group' }, @@ -334,6 +345,7 @@ export const MicrosoftAdBlock: BlockConfig = { if (params.top) result.top = Number(params.top) if (params.filter) result.filter = params.filter if (params.search) result.search = params.search + if (params.nextLink) result.nextLink = params.nextLink if (params.operation === 'update_user') { if (params.accountEnabled) result.accountEnabled = params.accountEnabled === 'true' } else if (params.operation === 'create_user') { @@ -375,6 +387,7 @@ export const MicrosoftAdBlock: BlockConfig = { top: { type: 'string' }, filter: { type: 'string' }, search: { type: 'string' }, + nextLink: { type: 'string' }, groupId: { type: 'string' }, groupDisplayName: { type: 'string' }, groupMailNickname: { type: 'string' }, @@ -390,7 +403,7 @@ export const MicrosoftAdBlock: BlockConfig = { response: { type: 'json', description: - 'Azure AD operation response. User operations return id, displayName, userPrincipalName, mail, jobTitle, department. Group operations return id, displayName, description, mailEnabled, securityEnabled, groupTypes. Member operations return id, displayName, mail, odataType.', + 'Azure AD operation response. User operations return id, displayName, userPrincipalName, mail, jobTitle, department. Group operations return id, displayName, description, mailEnabled, securityEnabled, groupTypes. Member operations return id, displayName, mail, odataType. List operations also return nextLink for fetching additional pages.', }, }, } @@ -487,7 +500,21 @@ export const MicrosoftAdBlockMeta = { description: 'List the members of an Azure AD group for an access review. Use for periodic attestation of privileged or sensitive groups.', content: - '# Audit Group Membership\n\nProduce a current membership snapshot for a group.\n\n## Steps\n1. Resolve the target group with Get Group or List Groups (filter or search by name).\n2. Call List Group Members for the group id, raising Max Results if the group is large.\n3. For each member, optionally call Get User to enrich with job title, department, and account-enabled status.\n\n## Output\nReturn a table of members with id, display name, email, department, and whether the account is enabled. Highlight disabled or stale accounts that still hold membership and should be reviewed for removal.', + '# Audit Group Membership\n\nProduce a current membership snapshot for a group.\n\n## Steps\n1. Resolve the target group with Get Group or List Groups (filter or search by name).\n2. Call List Group Members for the group id, raising Max Results if the group is large. If the response includes a Next Page link, keep calling List Group Members with that link until it comes back empty to capture every member.\n3. For each member, optionally call Get User to enrich with job title, department, and account-enabled status.\n\n## Output\nReturn a table of members with id, display name, email, department, and whether the account is enabled. Highlight disabled or stale accounts that still hold membership and should be reviewed for removal.', + }, + { + name: 'search-directory-users', + description: + 'Search Azure AD (Entra ID) for users matching a name, department, or other attribute. Use for directory lookups and reporting.', + content: + "# Search Directory Users\n\nFind users in the directory by attribute instead of enumerating everyone.\n\n## Steps\n1. Use List Users with Search set to the name or email fragment, or Filter set to an OData expression (e.g. `department eq 'Sales'`) for attribute-based lookups. Search and Filter cannot be combined in one call.\n2. If the result set is large, follow the Next Page link returned in the response to page through additional results.\n3. Optionally call Get User for a specific match to retrieve full profile detail.\n\n## Output\nReturn the matching users with id, display name, user principal name, department, and account-enabled status. State clearly when Max Results or pagination limits mean the list may be incomplete.", + }, + { + name: 'manage-group-membership', + description: + 'Add or remove specific users from an Azure AD (Entra ID) group on demand, outside of onboarding/offboarding flows. Use for ad hoc access changes and team restructuring.', + content: + "# Manage Group Membership\n\nApply a one-off membership change to a group.\n\n## Steps\n1. Resolve the group with Get Group or List Groups, and resolve each affected user with Get User or List Users.\n2. Call Add Group Member or Remove Group Member with the group id and each user id.\n3. Confirm the change with List Group Members.\n\n## Output\nReturn which users were added or removed and the group's current member count. Report any member that failed to add or remove (e.g. already a member, or not found) instead of silently skipping it.", }, ], } as const satisfies BlockMeta diff --git a/apps/sim/tools/microsoft_ad/create_group.ts b/apps/sim/tools/microsoft_ad/create_group.ts index 28ffe642594..62fcc3f06e9 100644 --- a/apps/sim/tools/microsoft_ad/create_group.ts +++ b/apps/sim/tools/microsoft_ad/create_group.ts @@ -65,7 +65,8 @@ export const createGroupTool: ToolConfig< type: 'string', required: false, visibility: 'user-or-llm', - description: 'Group visibility: "Private" or "Public"', + description: + 'Group visibility: "Private" or "Public" (can be changed later), or "HiddenMembership" (Microsoft 365 groups only; can only be set at creation and never changed afterward)', }, }, request: { diff --git a/apps/sim/tools/microsoft_ad/create_user.ts b/apps/sim/tools/microsoft_ad/create_user.ts index d414583824f..794bedd7941 100644 --- a/apps/sim/tools/microsoft_ad/create_user.ts +++ b/apps/sim/tools/microsoft_ad/create_user.ts @@ -93,7 +93,7 @@ export const createUserTool: ToolConfig< }, }, request: { - url: 'https://graph.microsoft.com/v1.0/users', + url: 'https://graph.microsoft.com/v1.0/users?$select=id,displayName,givenName,surname,userPrincipalName,mail,jobTitle,department,officeLocation,mobilePhone,accountEnabled', method: 'POST', headers: (params) => ({ Authorization: `Bearer ${params.accessToken}`, diff --git a/apps/sim/tools/microsoft_ad/list_group_members.ts b/apps/sim/tools/microsoft_ad/list_group_members.ts index 89d21d98581..701fae23a23 100644 --- a/apps/sim/tools/microsoft_ad/list_group_members.ts +++ b/apps/sim/tools/microsoft_ad/list_group_members.ts @@ -3,6 +3,7 @@ import type { MicrosoftAdListGroupMembersResponse, } from '@/tools/microsoft_ad/types' import { MEMBER_OUTPUT_PROPERTIES } from '@/tools/microsoft_ad/types' +import { assertGraphNextPageUrl, getGraphNextPageUrl } from '@/tools/sharepoint/utils' import type { ToolConfig } from '@/tools/types' export const listGroupMembersTool: ToolConfig< @@ -27,9 +28,9 @@ export const listGroupMembersTool: ToolConfig< }, groupId: { type: 'string', - required: true, + required: false, visibility: 'user-or-llm', - description: 'Group ID', + description: 'Group ID. Not needed when Next Page is provided to fetch a later page.', }, top: { type: 'number', @@ -37,9 +38,17 @@ export const listGroupMembersTool: ToolConfig< visibility: 'user-or-llm', description: 'Maximum number of members to return (default 100, max 999)', }, + nextLink: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Continuation URL from a previous response\'s "nextLink" output, used to fetch the next page of results', + }, }, request: { url: (params) => { + if (params.nextLink) return assertGraphNextPageUrl(params.nextLink) const groupId = params.groupId?.trim() if (!groupId) throw new Error('Group ID is required') const queryParts = ['$select=id,displayName,mail'] @@ -64,6 +73,7 @@ export const listGroupMembersTool: ToolConfig< output: { members, memberCount: members.length, + nextLink: getGraphNextPageUrl(data) ?? null, }, } }, @@ -74,5 +84,10 @@ export const listGroupMembersTool: ToolConfig< properties: MEMBER_OUTPUT_PROPERTIES, }, memberCount: { type: 'number', description: 'Number of members returned' }, + nextLink: { + type: 'string', + description: 'Continuation URL for the next page of results, or null if there are no more', + optional: true, + }, }, } diff --git a/apps/sim/tools/microsoft_ad/list_groups.ts b/apps/sim/tools/microsoft_ad/list_groups.ts index 13ceb572514..57af49ae26b 100644 --- a/apps/sim/tools/microsoft_ad/list_groups.ts +++ b/apps/sim/tools/microsoft_ad/list_groups.ts @@ -3,6 +3,7 @@ import type { MicrosoftAdListGroupsResponse, } from '@/tools/microsoft_ad/types' import { GROUP_OUTPUT_PROPERTIES } from '@/tools/microsoft_ad/types' +import { assertGraphNextPageUrl, getGraphNextPageUrl } from '@/tools/sharepoint/utils' import type { ToolConfig } from '@/tools/types' export const listGroupsTool: ToolConfig< @@ -43,20 +44,28 @@ export const listGroupsTool: ToolConfig< visibility: 'user-or-llm', description: 'Search string to filter groups by displayName or description', }, + nextLink: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Continuation URL from a previous response\'s "nextLink" output, used to fetch the next page of results', + }, }, request: { url: (params) => { + if (params.nextLink) return assertGraphNextPageUrl(params.nextLink) const queryParts: string[] = [] queryParts.push( '$select=id,displayName,description,mail,mailEnabled,mailNickname,securityEnabled,groupTypes,visibility,createdDateTime' ) if (params.top) queryParts.push(`$top=${params.top}`) - if (params.search && params.filter) { - throw new Error('$search and $filter cannot be used together in Microsoft Graph API') - } if (params.filter) queryParts.push(`$filter=${encodeURIComponent(params.filter)}`) if (params.search) { - queryParts.push(`$search="${encodeURIComponent(params.search)}"`) + const term = params.search.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + queryParts.push( + `$search=${encodeURIComponent(`"displayName:${term}" OR "description:${term}"`)}` + ) queryParts.push('$count=true') } return `https://graph.microsoft.com/v1.0/groups?${queryParts.join('&')}` @@ -86,6 +95,7 @@ export const listGroupsTool: ToolConfig< output: { groups, groupCount: groups.length, + nextLink: getGraphNextPageUrl(data) ?? null, }, } }, @@ -96,5 +106,10 @@ export const listGroupsTool: ToolConfig< properties: GROUP_OUTPUT_PROPERTIES, }, groupCount: { type: 'number', description: 'Number of groups returned' }, + nextLink: { + type: 'string', + description: 'Continuation URL for the next page of results, or null if there are no more', + optional: true, + }, }, } diff --git a/apps/sim/tools/microsoft_ad/list_users.ts b/apps/sim/tools/microsoft_ad/list_users.ts index d3a77beea05..ed9901ebc22 100644 --- a/apps/sim/tools/microsoft_ad/list_users.ts +++ b/apps/sim/tools/microsoft_ad/list_users.ts @@ -3,6 +3,7 @@ import type { MicrosoftAdListUsersResponse, } from '@/tools/microsoft_ad/types' import { USER_OUTPUT_PROPERTIES } from '@/tools/microsoft_ad/types' +import { assertGraphNextPageUrl, getGraphNextPageUrl } from '@/tools/sharepoint/utils' import type { ToolConfig } from '@/tools/types' export const listUsersTool: ToolConfig = { @@ -40,20 +41,26 @@ export const listUsersTool: ToolConfig { + if (params.nextLink) return assertGraphNextPageUrl(params.nextLink) const queryParts: string[] = [] queryParts.push( '$select=id,displayName,givenName,surname,userPrincipalName,mail,jobTitle,department,officeLocation,mobilePhone,accountEnabled' ) if (params.top) queryParts.push(`$top=${params.top}`) - if (params.search && params.filter) { - throw new Error('$search and $filter cannot be used together in Microsoft Graph API') - } if (params.filter) queryParts.push(`$filter=${encodeURIComponent(params.filter)}`) if (params.search) { - queryParts.push(`$search="${encodeURIComponent(params.search)}"`) + const term = params.search.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + queryParts.push(`$search=${encodeURIComponent(`"displayName:${term}" OR "mail:${term}"`)}`) queryParts.push('$count=true') } return `https://graph.microsoft.com/v1.0/users?${queryParts.join('&')}` @@ -84,6 +91,7 @@ export const listUsersTool: ToolConfig> userCount: number + nextLink: string | null } } @@ -162,6 +166,7 @@ export interface MicrosoftAdListGroupsResponse extends ToolResponse { output: { groups: Array> groupCount: number + nextLink: string | null } } @@ -195,6 +200,7 @@ export interface MicrosoftAdListGroupMembersResponse extends ToolResponse { output: { members: Array> memberCount: number + nextLink: string | null } } From be6f69365c56b9d29d61ef65f231da3ca0d6465d Mon Sep 17 00:00:00 2001 From: Waleed Date: Mon, 6 Jul 2026 15:50:30 -0700 Subject: [PATCH 19/35] feat(pagerduty): validate integration + add 9 tools for deeper API coverage (#5446) * feat(pagerduty): validate integration + add 9 tools for deeper API coverage - fix list tools' total field to null-default (matches PagerDuty spec, was wrong 0 default) - add pagination offset across all list tools - add incidentKey, resolution, urgencies, triggered status support - add get_incident, get_service, list_escalation_policies, list_incident_alerts, list_schedules, list_users, merge_incidents, snooze_incident tools (REST API v2) - add send_event tool (Events API v2) for trigger/acknowledge/resolve via routing key - add 2 new BlockMeta skills grounded in documented PagerDuty workflows * fix(pagerduty): address review findings on send_event/snooze/merge validation - match required condition to visibility condition for eventSummary/eventSource (trigger-only) - validate trigger payload has summary/source/severity before sending, throw descriptive error - validate snooze duration is finite and within PagerDuty's 1-604800 range - drop empty segments when splitting merge source incident IDs * fix(pagerduty): reject empty merge sources, enforce dedupKey, require integer snooze duration - merge_incidents: throw if source_incidents ends up empty after filtering blanks - send_event: require dedupKey for acknowledge/resolve to match block's required condition - snooze_incident: require an integer duration, not just a finite number * fix(pagerduty): reject resolution note unless status is resolved PagerDuty only accepts an incident's resolution field when status is being set to resolved in the same request; sending it otherwise gets rejected by the API with an opaque error. * docs(pagerduty): mention triggered as a valid update_incident status --- apps/sim/blocks/blocks/pagerduty.ts | 590 +++++++++++++++++- apps/sim/tools/pagerduty/create_incident.ts | 8 + apps/sim/tools/pagerduty/get_incident.ts | 93 +++ apps/sim/tools/pagerduty/get_service.ts | 90 +++ apps/sim/tools/pagerduty/index.ts | 18 + .../pagerduty/list_escalation_policies.ts | 119 ++++ .../tools/pagerduty/list_incident_alerts.ts | 134 ++++ apps/sim/tools/pagerduty/list_incidents.ts | 29 +- apps/sim/tools/pagerduty/list_oncalls.ts | 18 +- apps/sim/tools/pagerduty/list_schedules.ts | 114 ++++ apps/sim/tools/pagerduty/list_services.ts | 18 +- apps/sim/tools/pagerduty/list_users.ts | 111 ++++ apps/sim/tools/pagerduty/merge_incidents.ts | 99 +++ apps/sim/tools/pagerduty/send_event.ts | 132 ++++ apps/sim/tools/pagerduty/snooze_incident.ts | 87 +++ apps/sim/tools/pagerduty/types.ts | 228 ++++++- apps/sim/tools/pagerduty/update_incident.ts | 15 +- apps/sim/tools/registry.ts | 18 + 18 files changed, 1898 insertions(+), 23 deletions(-) create mode 100644 apps/sim/tools/pagerduty/get_incident.ts create mode 100644 apps/sim/tools/pagerduty/get_service.ts create mode 100644 apps/sim/tools/pagerduty/list_escalation_policies.ts create mode 100644 apps/sim/tools/pagerduty/list_incident_alerts.ts create mode 100644 apps/sim/tools/pagerduty/list_schedules.ts create mode 100644 apps/sim/tools/pagerduty/list_users.ts create mode 100644 apps/sim/tools/pagerduty/merge_incidents.ts create mode 100644 apps/sim/tools/pagerduty/send_event.ts create mode 100644 apps/sim/tools/pagerduty/snooze_incident.ts diff --git a/apps/sim/blocks/blocks/pagerduty.ts b/apps/sim/blocks/blocks/pagerduty.ts index 2ed88a7b96c..3faf31345d8 100644 --- a/apps/sim/blocks/blocks/pagerduty.ts +++ b/apps/sim/blocks/blocks/pagerduty.ts @@ -8,7 +8,7 @@ export const PagerDutyBlock: BlockConfig = { description: 'Manage incidents and on-call schedules with PagerDuty', triggerAllowed: true, longDescription: - 'Integrate PagerDuty into your workflow to list, create, and update incidents, add notes, list services, and check on-call schedules.', + 'Integrate PagerDuty into your workflow to list, get, create, update, snooze, and merge incidents, add notes and list alerts, look up services and escalation policies, check on-call schedules, list users, and send monitoring events through the Events API v2.', docsLink: 'https://docs.sim.ai/integrations/pagerduty', category: 'tools', integrationType: IntegrationType.Observability, @@ -24,11 +24,20 @@ export const PagerDutyBlock: BlockConfig = { type: 'dropdown', options: [ { label: 'List Incidents', id: 'list_incidents' }, + { label: 'Get Incident', id: 'get_incident' }, { label: 'Create Incident', id: 'create_incident' }, { label: 'Update Incident', id: 'update_incident' }, + { label: 'Snooze Incident', id: 'snooze_incident' }, + { label: 'Merge Incidents', id: 'merge_incidents' }, { label: 'Add Note', id: 'add_note' }, + { label: 'List Incident Alerts', id: 'list_incident_alerts' }, { label: 'List Services', id: 'list_services' }, + { label: 'Get Service', id: 'get_service' }, { label: 'List On-Calls', id: 'list_oncalls' }, + { label: 'List Escalation Policies', id: 'list_escalation_policies' }, + { label: 'List Schedules', id: 'list_schedules' }, + { label: 'List Users', id: 'list_users' }, + { label: 'Send Event', id: 'send_event' }, ], value: () => 'list_incidents', }, @@ -37,9 +46,20 @@ export const PagerDutyBlock: BlockConfig = { id: 'apiKey', title: 'API Key', type: 'short-input', - required: true, + required: { field: 'operation', value: 'send_event', not: true }, placeholder: 'Enter your PagerDuty REST API Key', password: true, + condition: { field: 'operation', value: 'send_event', not: true }, + }, + + { + id: 'routingKey', + title: 'Integration Key', + type: 'short-input', + required: { field: 'operation', value: 'send_event' }, + placeholder: 'Events API v2 integration key for the target service', + password: true, + condition: { field: 'operation', value: 'send_event' }, }, { @@ -48,12 +68,24 @@ export const PagerDutyBlock: BlockConfig = { type: 'short-input', required: { field: 'operation', - value: ['create_incident', 'update_incident', 'add_note'], + value: [ + 'create_incident', + 'update_incident', + 'add_note', + 'snooze_incident', + 'merge_incidents', + ], }, placeholder: 'Valid PagerDuty user email (required for write operations)', condition: { field: 'operation', - value: ['create_incident', 'update_incident', 'add_note'], + value: [ + 'create_incident', + 'update_incident', + 'add_note', + 'snooze_incident', + 'merge_incidents', + ], }, }, @@ -71,6 +103,19 @@ export const PagerDutyBlock: BlockConfig = { value: () => '', condition: { field: 'operation', value: 'list_incidents' }, }, + { + id: 'listUrgencies', + title: 'Urgencies', + type: 'dropdown', + options: [ + { label: 'All', id: '' }, + { label: 'High', id: 'high' }, + { label: 'Low', id: 'low' }, + ], + value: () => '', + condition: { field: 'operation', value: 'list_incidents' }, + mode: 'advanced', + }, { id: 'listServiceIds', title: 'Service IDs', @@ -127,6 +172,24 @@ export const PagerDutyBlock: BlockConfig = { condition: { field: 'operation', value: 'list_incidents' }, mode: 'advanced', }, + { + id: 'listOffset', + title: 'Offset', + type: 'short-input', + placeholder: '0', + condition: { field: 'operation', value: 'list_incidents' }, + mode: 'advanced', + }, + + // --- Get Incident fields --- + { + id: 'getIncidentId', + title: 'Incident ID', + type: 'short-input', + required: { field: 'operation', value: 'get_incident' }, + placeholder: 'ID of the incident to fetch', + condition: { field: 'operation', value: 'get_incident' }, + }, // --- Create Incident fields --- { @@ -179,6 +242,14 @@ export const PagerDutyBlock: BlockConfig = { condition: { field: 'operation', value: 'create_incident' }, mode: 'advanced', }, + { + id: 'incidentKey', + title: 'De-duplication Key', + type: 'short-input', + placeholder: 'Idempotency key to avoid duplicate incidents (optional)', + condition: { field: 'operation', value: 'create_incident' }, + mode: 'advanced', + }, // --- Update Incident fields --- { @@ -195,12 +266,21 @@ export const PagerDutyBlock: BlockConfig = { type: 'dropdown', options: [ { label: 'No Change', id: '' }, + { label: 'Triggered (reopen)', id: 'triggered' }, { label: 'Acknowledged', id: 'acknowledged' }, { label: 'Resolved', id: 'resolved' }, ], value: () => '', condition: { field: 'operation', value: 'update_incident' }, }, + { + id: 'updateResolution', + title: 'Resolution Note', + type: 'long-input', + placeholder: 'Note describing the resolution (used when status is resolved)', + condition: { field: 'operation', value: 'update_incident' }, + mode: 'advanced', + }, { id: 'updateTitle', title: 'New Title', @@ -230,6 +310,42 @@ export const PagerDutyBlock: BlockConfig = { condition: { field: 'operation', value: 'update_incident' }, mode: 'advanced', }, + // --- Snooze Incident fields --- + { + id: 'snoozeIncidentId', + title: 'Incident ID', + type: 'short-input', + required: { field: 'operation', value: 'snooze_incident' }, + placeholder: 'ID of the incident to snooze', + condition: { field: 'operation', value: 'snooze_incident' }, + }, + { + id: 'snoozeDuration', + title: 'Duration (seconds)', + type: 'short-input', + required: { field: 'operation', value: 'snooze_incident' }, + placeholder: 'e.g., 3600 for 1 hour (max 604800)', + condition: { field: 'operation', value: 'snooze_incident' }, + }, + + // --- Merge Incidents fields --- + { + id: 'mergeTargetIncidentId', + title: 'Target Incident ID', + type: 'short-input', + required: { field: 'operation', value: 'merge_incidents' }, + placeholder: 'Incident that will absorb the source incidents', + condition: { field: 'operation', value: 'merge_incidents' }, + }, + { + id: 'mergeSourceIncidentIds', + title: 'Source Incident IDs', + type: 'short-input', + required: { field: 'operation', value: 'merge_incidents' }, + placeholder: 'Comma-separated IDs of incidents to merge in', + condition: { field: 'operation', value: 'merge_incidents' }, + }, + // --- Add Note fields --- { id: 'noteIncidentId', @@ -248,6 +364,45 @@ export const PagerDutyBlock: BlockConfig = { condition: { field: 'operation', value: 'add_note' }, }, + // --- List Incident Alerts fields --- + { + id: 'alertsIncidentId', + title: 'Incident ID', + type: 'short-input', + required: { field: 'operation', value: 'list_incident_alerts' }, + placeholder: 'ID of the incident whose alerts to list', + condition: { field: 'operation', value: 'list_incident_alerts' }, + }, + { + id: 'alertsStatuses', + title: 'Statuses', + type: 'dropdown', + options: [ + { label: 'All', id: '' }, + { label: 'Triggered', id: 'triggered' }, + { label: 'Resolved', id: 'resolved' }, + ], + value: () => '', + condition: { field: 'operation', value: 'list_incident_alerts' }, + mode: 'advanced', + }, + { + id: 'alertsLimit', + title: 'Limit', + type: 'short-input', + placeholder: '25', + condition: { field: 'operation', value: 'list_incident_alerts' }, + mode: 'advanced', + }, + { + id: 'alertsOffset', + title: 'Offset', + type: 'short-input', + placeholder: '0', + condition: { field: 'operation', value: 'list_incident_alerts' }, + mode: 'advanced', + }, + // --- List Services fields --- { id: 'serviceQuery', @@ -264,6 +419,24 @@ export const PagerDutyBlock: BlockConfig = { condition: { field: 'operation', value: 'list_services' }, mode: 'advanced', }, + { + id: 'serviceOffset', + title: 'Offset', + type: 'short-input', + placeholder: '0', + condition: { field: 'operation', value: 'list_services' }, + mode: 'advanced', + }, + + // --- Get Service fields --- + { + id: 'getServiceId', + title: 'Service ID', + type: 'short-input', + required: { field: 'operation', value: 'get_service' }, + placeholder: 'ID of the service to fetch', + condition: { field: 'operation', value: 'get_service' }, + }, // --- List On-Calls fields --- { @@ -317,6 +490,200 @@ export const PagerDutyBlock: BlockConfig = { generationType: 'timestamp', }, }, + { + id: 'oncallOffset', + title: 'Offset', + type: 'short-input', + placeholder: '0', + condition: { field: 'operation', value: 'list_oncalls' }, + mode: 'advanced', + }, + + // --- List Escalation Policies fields --- + { + id: 'escalationPolicyQuery', + title: 'Search Query', + type: 'short-input', + placeholder: 'Filter escalation policies by name', + condition: { field: 'operation', value: 'list_escalation_policies' }, + }, + { + id: 'escalationPolicyLimit', + title: 'Limit', + type: 'short-input', + placeholder: '25', + condition: { field: 'operation', value: 'list_escalation_policies' }, + mode: 'advanced', + }, + { + id: 'escalationPolicyOffset', + title: 'Offset', + type: 'short-input', + placeholder: '0', + condition: { field: 'operation', value: 'list_escalation_policies' }, + mode: 'advanced', + }, + + // --- List Schedules fields --- + { + id: 'scheduleQuery', + title: 'Search Query', + type: 'short-input', + placeholder: 'Filter schedules by name', + condition: { field: 'operation', value: 'list_schedules' }, + }, + { + id: 'scheduleLimit', + title: 'Limit', + type: 'short-input', + placeholder: '25', + condition: { field: 'operation', value: 'list_schedules' }, + mode: 'advanced', + }, + { + id: 'scheduleOffset', + title: 'Offset', + type: 'short-input', + placeholder: '0', + condition: { field: 'operation', value: 'list_schedules' }, + mode: 'advanced', + }, + + // --- List Users fields --- + { + id: 'userQuery', + title: 'Search Query', + type: 'short-input', + placeholder: 'Filter users by name or email', + condition: { field: 'operation', value: 'list_users' }, + }, + { + id: 'userLimit', + title: 'Limit', + type: 'short-input', + placeholder: '25', + condition: { field: 'operation', value: 'list_users' }, + mode: 'advanced', + }, + { + id: 'userOffset', + title: 'Offset', + type: 'short-input', + placeholder: '0', + condition: { field: 'operation', value: 'list_users' }, + mode: 'advanced', + }, + + // --- Send Event fields --- + { + id: 'eventAction', + title: 'Event Action', + type: 'dropdown', + options: [ + { label: 'Trigger', id: 'trigger' }, + { label: 'Acknowledge', id: 'acknowledge' }, + { label: 'Resolve', id: 'resolve' }, + ], + value: () => 'trigger', + condition: { field: 'operation', value: 'send_event' }, + }, + { + id: 'eventSummary', + title: 'Summary', + type: 'short-input', + required: { + field: 'operation', + value: 'send_event', + and: { field: 'eventAction', value: 'trigger' }, + }, + placeholder: 'Brief summary of the event', + condition: { + field: 'operation', + value: 'send_event', + and: { field: 'eventAction', value: 'trigger' }, + }, + }, + { + id: 'eventSource', + title: 'Source', + type: 'short-input', + required: { + field: 'operation', + value: 'send_event', + and: { field: 'eventAction', value: 'trigger' }, + }, + placeholder: 'Affected system, e.g. a hostname', + condition: { + field: 'operation', + value: 'send_event', + and: { field: 'eventAction', value: 'trigger' }, + }, + }, + { + id: 'eventSeverity', + title: 'Severity', + type: 'dropdown', + options: [ + { label: 'Critical', id: 'critical' }, + { label: 'Warning', id: 'warning' }, + { label: 'Error', id: 'error' }, + { label: 'Info', id: 'info' }, + ], + value: () => 'critical', + condition: { + field: 'operation', + value: 'send_event', + and: { field: 'eventAction', value: 'trigger' }, + }, + }, + { + id: 'eventDedupKey', + title: 'De-duplication Key', + type: 'short-input', + required: { + field: 'operation', + value: 'send_event', + and: { field: 'eventAction', value: ['acknowledge', 'resolve'] }, + }, + placeholder: 'Key identifying the alert (required for acknowledge/resolve)', + condition: { field: 'operation', value: 'send_event' }, + }, + { + id: 'eventComponent', + title: 'Component', + type: 'short-input', + placeholder: 'Component of the source responsible for the event', + condition: { + field: 'operation', + value: 'send_event', + and: { field: 'eventAction', value: 'trigger' }, + }, + mode: 'advanced', + }, + { + id: 'eventGroup', + title: 'Group', + type: 'short-input', + placeholder: 'Logical grouping of components', + condition: { + field: 'operation', + value: 'send_event', + and: { field: 'eventAction', value: 'trigger' }, + }, + mode: 'advanced', + }, + { + id: 'eventClass', + title: 'Class', + type: 'short-input', + placeholder: 'Class/type of the event', + condition: { + field: 'operation', + value: 'send_event', + and: { field: 'eventAction', value: 'trigger' }, + }, + mode: 'advanced', + }, ...getTrigger('pagerduty_incident_triggered').subBlocks, ...getTrigger('pagerduty_incident_acknowledged').subBlocks, ...getTrigger('pagerduty_incident_resolved').subBlocks, @@ -328,11 +695,20 @@ export const PagerDutyBlock: BlockConfig = { tools: { access: [ 'pagerduty_list_incidents', + 'pagerduty_get_incident', 'pagerduty_create_incident', 'pagerduty_update_incident', + 'pagerduty_snooze_incident', + 'pagerduty_merge_incidents', 'pagerduty_add_note', + 'pagerduty_list_incident_alerts', 'pagerduty_list_services', + 'pagerduty_get_service', 'pagerduty_list_oncalls', + 'pagerduty_list_escalation_policies', + 'pagerduty_list_schedules', + 'pagerduty_list_users', + 'pagerduty_send_event', ], config: { tool: (params) => `pagerduty_${params.operation}`, @@ -342,11 +718,17 @@ export const PagerDutyBlock: BlockConfig = { switch (params.operation) { case 'list_incidents': if (params.statuses) result.statuses = params.statuses + if (params.listUrgencies) result.urgencies = params.listUrgencies if (params.listServiceIds) result.serviceIds = params.listServiceIds if (params.listSince) result.since = params.listSince if (params.listUntil) result.until = params.listUntil if (params.listSortBy) result.sortBy = params.listSortBy if (params.listLimit) result.limit = params.listLimit + if (params.listOffset) result.offset = params.listOffset + break + + case 'get_incident': + if (params.getIncidentId) result.incidentId = params.getIncidentId break case 'create_incident': @@ -360,6 +742,18 @@ export const PagerDutyBlock: BlockConfig = { if (params.updateTitle) result.title = params.updateTitle if (params.updateUrgency) result.urgency = params.updateUrgency if (params.updateEscalationLevel) result.escalationLevel = params.updateEscalationLevel + if (params.updateResolution) result.resolution = params.updateResolution + break + + case 'snooze_incident': + if (params.snoozeIncidentId) result.incidentId = params.snoozeIncidentId + if (params.snoozeDuration) result.duration = params.snoozeDuration + break + + case 'merge_incidents': + if (params.mergeTargetIncidentId) result.targetIncidentId = params.mergeTargetIncidentId + if (params.mergeSourceIncidentIds) + result.sourceIncidentIds = params.mergeSourceIncidentIds break case 'add_note': @@ -367,9 +761,21 @@ export const PagerDutyBlock: BlockConfig = { if (params.noteContent) result.content = params.noteContent break + case 'list_incident_alerts': + if (params.alertsIncidentId) result.incidentId = params.alertsIncidentId + if (params.alertsStatuses) result.statuses = params.alertsStatuses + if (params.alertsLimit) result.limit = params.alertsLimit + if (params.alertsOffset) result.offset = params.alertsOffset + break + case 'list_services': if (params.serviceQuery) result.query = params.serviceQuery if (params.serviceLimit) result.limit = params.serviceLimit + if (params.serviceOffset) result.offset = params.serviceOffset + break + + case 'get_service': + if (params.getServiceId) result.serviceId = params.getServiceId break case 'list_oncalls': @@ -379,6 +785,36 @@ export const PagerDutyBlock: BlockConfig = { if (params.oncallSince) result.since = params.oncallSince if (params.oncallUntil) result.until = params.oncallUntil if (params.oncallLimit) result.limit = params.oncallLimit + if (params.oncallOffset) result.offset = params.oncallOffset + break + + case 'list_escalation_policies': + if (params.escalationPolicyQuery) result.query = params.escalationPolicyQuery + if (params.escalationPolicyLimit) result.limit = params.escalationPolicyLimit + if (params.escalationPolicyOffset) result.offset = params.escalationPolicyOffset + break + + case 'list_schedules': + if (params.scheduleQuery) result.query = params.scheduleQuery + if (params.scheduleLimit) result.limit = params.scheduleLimit + if (params.scheduleOffset) result.offset = params.scheduleOffset + break + + case 'list_users': + if (params.userQuery) result.query = params.userQuery + if (params.userLimit) result.limit = params.userLimit + if (params.userOffset) result.offset = params.userOffset + break + + case 'send_event': + if (params.eventAction) result.eventAction = params.eventAction + if (params.eventSummary) result.summary = params.eventSummary + if (params.eventSource) result.source = params.eventSource + if (params.eventSeverity) result.severity = params.eventSeverity + if (params.eventDedupKey) result.dedupKey = params.eventDedupKey + if (params.eventComponent) result.component = params.eventComponent + if (params.eventGroup) result.group = params.eventGroup + if (params.eventClass) result.class = params.eventClass break } @@ -390,51 +826,93 @@ export const PagerDutyBlock: BlockConfig = { inputs: { operation: { type: 'string', description: 'Operation to perform' }, apiKey: { type: 'string', description: 'PagerDuty REST API Key' }, + routingKey: { type: 'string', description: 'Events API v2 integration key' }, fromEmail: { type: 'string', description: 'Valid PagerDuty user email' }, statuses: { type: 'string', description: 'Status filter for incidents' }, + listUrgencies: { type: 'string', description: 'Urgency filter for incidents' }, listServiceIds: { type: 'string', description: 'Service IDs filter' }, listSince: { type: 'string', description: 'Start date filter' }, listUntil: { type: 'string', description: 'End date filter' }, + listSortBy: { type: 'string', description: 'Sort field' }, + listLimit: { type: 'string', description: 'Max results for incidents' }, + listOffset: { type: 'string', description: 'Pagination offset for incidents' }, + getIncidentId: { type: 'string', description: 'Incident ID to fetch' }, title: { type: 'string', description: 'Incident title' }, createServiceId: { type: 'string', description: 'Service ID for new incident' }, createUrgency: { type: 'string', description: 'Urgency level' }, body: { type: 'string', description: 'Incident description' }, + incidentKey: { type: 'string', description: 'De-duplication key for new incident' }, updateIncidentId: { type: 'string', description: 'Incident ID to update' }, updateStatus: { type: 'string', description: 'New status' }, + updateResolution: { type: 'string', description: 'Resolution note' }, + snoozeIncidentId: { type: 'string', description: 'Incident ID to snooze' }, + snoozeDuration: { type: 'string', description: 'Snooze duration in seconds' }, + mergeTargetIncidentId: { type: 'string', description: 'Target incident ID for merge' }, + mergeSourceIncidentIds: { type: 'string', description: 'Source incident IDs to merge in' }, noteIncidentId: { type: 'string', description: 'Incident ID for note' }, noteContent: { type: 'string', description: 'Note content' }, + alertsIncidentId: { type: 'string', description: 'Incident ID whose alerts to list' }, + alertsStatuses: { type: 'string', description: 'Status filter for alerts' }, + alertsLimit: { type: 'string', description: 'Max results for alerts' }, + alertsOffset: { type: 'string', description: 'Pagination offset for alerts' }, escalationPolicyId: { type: 'string', description: 'Escalation policy ID' }, assigneeId: { type: 'string', description: 'Assignee user ID' }, updateTitle: { type: 'string', description: 'New incident title' }, updateUrgency: { type: 'string', description: 'New urgency level' }, updateEscalationLevel: { type: 'string', description: 'Escalation level number' }, - listSortBy: { type: 'string', description: 'Sort field' }, - listLimit: { type: 'string', description: 'Max results for incidents' }, serviceQuery: { type: 'string', description: 'Service name filter' }, serviceLimit: { type: 'string', description: 'Max results for services' }, + serviceOffset: { type: 'string', description: 'Pagination offset for services' }, + getServiceId: { type: 'string', description: 'Service ID to fetch' }, oncallEscalationPolicyIds: { type: 'string', description: 'Escalation policy IDs filter' }, oncallScheduleIds: { type: 'string', description: 'Schedule IDs filter' }, oncallSince: { type: 'string', description: 'On-call start time filter' }, oncallUntil: { type: 'string', description: 'On-call end time filter' }, oncallLimit: { type: 'string', description: 'Max results for on-calls' }, + oncallOffset: { type: 'string', description: 'Pagination offset for on-calls' }, + escalationPolicyQuery: { type: 'string', description: 'Escalation policy name filter' }, + escalationPolicyLimit: { type: 'string', description: 'Max results for escalation policies' }, + escalationPolicyOffset: { + type: 'string', + description: 'Pagination offset for escalation policies', + }, + scheduleQuery: { type: 'string', description: 'Schedule name filter' }, + scheduleLimit: { type: 'string', description: 'Max results for schedules' }, + scheduleOffset: { type: 'string', description: 'Pagination offset for schedules' }, + userQuery: { type: 'string', description: 'User name/email filter' }, + userLimit: { type: 'string', description: 'Max results for users' }, + userOffset: { type: 'string', description: 'Pagination offset for users' }, + eventAction: { type: 'string', description: 'Events API action (trigger/acknowledge/resolve)' }, + eventSummary: { type: 'string', description: 'Event summary' }, + eventSource: { type: 'string', description: 'Event source system' }, + eventSeverity: { type: 'string', description: 'Event severity' }, + eventDedupKey: { type: 'string', description: 'Event de-duplication key' }, + eventComponent: { type: 'string', description: 'Event component' }, + eventGroup: { type: 'string', description: 'Event group' }, + eventClass: { type: 'string', description: 'Event class' }, }, outputs: { incidents: { type: 'json', - description: 'Array of incidents (list_incidents)', + description: + '[{id, incidentNumber, title, status, urgency, createdAt, updatedAt, serviceName, serviceId, assigneeName, assigneeId, escalationPolicyName, htmlUrl}] (list_incidents)', }, total: { type: 'number', - description: 'Total count of results', + description: 'Total count of results, null unless requested (list operations)', }, more: { type: 'boolean', - description: 'Whether more results are available', + description: 'Whether more results are available (list operations)', + }, + offset: { + type: 'number', + description: 'Pagination offset for this page of results (list operations)', }, id: { type: 'string', - description: 'Created/updated resource ID', + description: 'Created/updated/fetched resource ID', }, incidentNumber: { type: 'number', @@ -446,7 +924,7 @@ export const PagerDutyBlock: BlockConfig = { }, status: { type: 'string', - description: 'Incident status', + description: 'Incident/event status', }, urgency: { type: 'string', @@ -460,6 +938,14 @@ export const PagerDutyBlock: BlockConfig = { type: 'string', description: 'Last updated timestamp', }, + resolvedAt: { + type: 'string', + description: 'Resolution timestamp (get_incident)', + }, + incidentKey: { + type: 'string', + description: 'De-duplication key (get_incident)', + }, serviceName: { type: 'string', description: 'Service name', @@ -468,6 +954,22 @@ export const PagerDutyBlock: BlockConfig = { type: 'string', description: 'Service ID', }, + assigneeName: { + type: 'string', + description: 'Assignee name (list_incidents, get_incident)', + }, + assigneeId: { + type: 'string', + description: 'Assignee ID (list_incidents, get_incident)', + }, + escalationPolicyName: { + type: 'string', + description: 'Escalation policy name', + }, + escalationPolicyId: { + type: 'string', + description: 'Escalation policy ID (get_incident, get_service)', + }, htmlUrl: { type: 'string', description: 'PagerDuty web URL', @@ -482,11 +984,59 @@ export const PagerDutyBlock: BlockConfig = { }, services: { type: 'json', - description: 'Array of services (list_services)', + description: + '[{id, name, description, status, escalationPolicyName, escalationPolicyId, createdAt, htmlUrl}] (list_services)', + }, + name: { + type: 'string', + description: 'Resource name (get_service, escalation policies, schedules, users)', + }, + description: { + type: 'string', + description: 'Resource description (get_service, escalation policies, schedules)', + }, + autoResolveTimeout: { + type: 'number', + description: 'Seconds before an open incident auto-resolves (get_service)', + }, + acknowledgementTimeout: { + type: 'number', + description: 'Seconds before an acknowledged incident reverts to triggered (get_service)', + }, + lastIncidentTimestamp: { + type: 'string', + description: 'Timestamp of the most recent incident (get_service)', }, oncalls: { type: 'json', - description: 'Array of on-call entries (list_oncalls)', + description: + '[{userName, userId, escalationLevel, escalationPolicyName, escalationPolicyId, scheduleName, scheduleId, start, end}] (list_oncalls)', + }, + escalationPolicies: { + type: 'json', + description: + '[{id, name, description, numLoops, onCallHandoffNotifications, htmlUrl}] (list_escalation_policies)', + }, + schedules: { + type: 'json', + description: '[{id, name, description, timeZone, htmlUrl}] (list_schedules)', + }, + users: { + type: 'json', + description: '[{id, name, email, role, jobTitle, timeZone, htmlUrl}] (list_users)', + }, + alerts: { + type: 'json', + description: + '[{id, summary, status, severity, createdAt, alertKey, serviceName, serviceId, htmlUrl}] (list_incident_alerts)', + }, + message: { + type: 'string', + description: 'Result message (send_event)', + }, + dedupKey: { + type: 'string', + description: 'De-duplication key returned by the Events API (send_event)', }, }, @@ -605,5 +1155,19 @@ export const PagerDutyBlockMeta = { content: '# Check Who Is On Call\n\nFind the right person to reach right now.\n\n## Steps\n1. Use List On-Calls, optionally scoped by Escalation Policy IDs or Schedule IDs.\n2. Set a Since and Until window to look at the current or an upcoming shift.\n3. Map each on-call entry to its escalation level so primary versus backup responders are clear.\n\n## Output\nA concise roster: who is on call at level 1 (primary) and level 2 (backup) per schedule, with the time window covered.', }, + { + name: 'send-monitoring-event', + description: + 'Trigger, acknowledge, or resolve a PagerDuty alert from a monitoring source using the Events API v2 integration key, without a PagerDuty user account.', + content: + "# Send Monitoring Event\n\nPage PagerDuty directly from a monitoring check or script.\n\n## Steps\n1. Use Send Event with the target service's Integration Key and Event Action set to Trigger.\n2. Provide a Summary, Source (the affected host/system), and Severity describing the problem.\n3. Reuse the same De-duplication Key on later Acknowledge/Resolve events to update the same alert instead of opening a new one.\n\n## Output\nReport the resulting status and the de-duplication key so follow-up events can reference the same alert.", + }, + { + name: 'merge-duplicate-incidents', + description: + 'Merge duplicate PagerDuty incidents from the same event into one target incident to reduce noise.', + content: + '# Merge Duplicate Incidents\n\nCollapse near-duplicate pages into a single incident.\n\n## Steps\n1. Use List Incidents to identify incidents that describe the same underlying problem (same service, overlapping time window, similar title).\n2. Pick the incident responders are already working as the Target Incident ID.\n3. Use Merge Incidents with the Target Incident ID and the comma-separated Source Incident IDs to merge in; the sources are resolved automatically.\n4. Provide a valid From Email since this is a write operation.\n\n## Output\nConfirm the target incident number and status, and how many source incidents were merged into it.', + }, ], } as const satisfies BlockMeta diff --git a/apps/sim/tools/pagerduty/create_incident.ts b/apps/sim/tools/pagerduty/create_incident.ts index 6a4c98854f5..60a11bc8ed0 100644 --- a/apps/sim/tools/pagerduty/create_incident.ts +++ b/apps/sim/tools/pagerduty/create_incident.ts @@ -62,6 +62,13 @@ export const createIncidentTool: ToolConfig< visibility: 'user-or-llm', description: 'User ID to assign the incident to', }, + incidentKey: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'De-duplication key. A subsequent request with the same service and incident key updates the existing open incident instead of creating a new one', + }, }, request: { @@ -106,6 +113,7 @@ export const createIncidentTool: ToolConfig< }, ] } + if (params.incidentKey) incident.incident_key = params.incidentKey return { incident } }, diff --git a/apps/sim/tools/pagerduty/get_incident.ts b/apps/sim/tools/pagerduty/get_incident.ts new file mode 100644 index 00000000000..4d4d303b99d --- /dev/null +++ b/apps/sim/tools/pagerduty/get_incident.ts @@ -0,0 +1,93 @@ +import type { + PagerDutyGetIncidentParams, + PagerDutyGetIncidentResponse, +} from '@/tools/pagerduty/types' +import type { ToolConfig } from '@/tools/types' + +export const getIncidentTool: ToolConfig = + { + id: 'pagerduty_get_incident', + name: 'PagerDuty Get Incident', + description: 'Get a single incident from PagerDuty by ID.', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'PagerDuty REST API Key', + }, + incidentId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the incident to fetch', + }, + }, + + request: { + url: (params) => + `https://api.pagerduty.com/incidents/${params.incidentId.trim()}?include[]=services`, + method: 'GET', + headers: (params) => ({ + Authorization: `Token token=${params.apiKey}`, + Accept: 'application/vnd.pagerduty+json;version=2', + 'Content-Type': 'application/json', + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error?.message || `PagerDuty API error: ${response.status}`) + } + + const inc = data.incident ?? {} + return { + success: true, + output: { + id: inc.id ?? null, + incidentNumber: inc.incident_number ?? null, + title: inc.title ?? null, + status: inc.status ?? null, + urgency: inc.urgency ?? null, + createdAt: inc.created_at ?? null, + updatedAt: inc.updated_at ?? null, + resolvedAt: inc.resolved_at ?? null, + serviceName: inc.service?.summary ?? null, + serviceId: inc.service?.id ?? null, + assigneeName: inc.assignments?.[0]?.assignee?.summary ?? null, + assigneeId: inc.assignments?.[0]?.assignee?.id ?? null, + escalationPolicyName: inc.escalation_policy?.summary ?? null, + escalationPolicyId: inc.escalation_policy?.id ?? null, + incidentKey: inc.incident_key ?? null, + htmlUrl: inc.html_url ?? null, + }, + } + }, + + outputs: { + id: { type: 'string', description: 'Incident ID' }, + incidentNumber: { type: 'number', description: 'Incident number' }, + title: { type: 'string', description: 'Incident title' }, + status: { type: 'string', description: 'Incident status' }, + urgency: { type: 'string', description: 'Incident urgency' }, + createdAt: { type: 'string', description: 'Creation timestamp' }, + updatedAt: { type: 'string', description: 'Last updated timestamp', optional: true }, + resolvedAt: { type: 'string', description: 'Resolution timestamp', optional: true }, + serviceName: { type: 'string', description: 'Service name', optional: true }, + serviceId: { type: 'string', description: 'Service ID', optional: true }, + assigneeName: { type: 'string', description: 'Assignee name', optional: true }, + assigneeId: { type: 'string', description: 'Assignee ID', optional: true }, + escalationPolicyName: { + type: 'string', + description: 'Escalation policy name', + optional: true, + }, + escalationPolicyId: { type: 'string', description: 'Escalation policy ID', optional: true }, + incidentKey: { type: 'string', description: 'De-duplication key', optional: true }, + htmlUrl: { type: 'string', description: 'PagerDuty web URL' }, + }, + } diff --git a/apps/sim/tools/pagerduty/get_service.ts b/apps/sim/tools/pagerduty/get_service.ts new file mode 100644 index 00000000000..e5c3c21ec8f --- /dev/null +++ b/apps/sim/tools/pagerduty/get_service.ts @@ -0,0 +1,90 @@ +import type { + PagerDutyGetServiceParams, + PagerDutyGetServiceResponse, +} from '@/tools/pagerduty/types' +import type { ToolConfig } from '@/tools/types' + +export const getServiceTool: ToolConfig = { + id: 'pagerduty_get_service', + name: 'PagerDuty Get Service', + description: 'Get a single service from PagerDuty by ID.', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'PagerDuty REST API Key', + }, + serviceId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the service to fetch', + }, + }, + + request: { + url: (params) => + `https://api.pagerduty.com/services/${params.serviceId.trim()}?include[]=escalation_policies`, + method: 'GET', + headers: (params) => ({ + Authorization: `Token token=${params.apiKey}`, + Accept: 'application/vnd.pagerduty+json;version=2', + 'Content-Type': 'application/json', + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error?.message || `PagerDuty API error: ${response.status}`) + } + + const svc = data.service ?? {} + return { + success: true, + output: { + id: svc.id ?? null, + name: svc.name ?? null, + description: svc.description ?? null, + status: svc.status ?? null, + autoResolveTimeout: svc.auto_resolve_timeout ?? null, + acknowledgementTimeout: svc.acknowledgement_timeout ?? null, + createdAt: svc.created_at ?? null, + lastIncidentTimestamp: svc.last_incident_timestamp ?? null, + escalationPolicyName: svc.escalation_policy?.summary ?? null, + escalationPolicyId: svc.escalation_policy?.id ?? null, + htmlUrl: svc.html_url ?? null, + }, + } + }, + + outputs: { + id: { type: 'string', description: 'Service ID' }, + name: { type: 'string', description: 'Service name' }, + description: { type: 'string', description: 'Service description', optional: true }, + status: { type: 'string', description: 'Service status' }, + autoResolveTimeout: { + type: 'number', + description: 'Seconds before an open incident auto-resolves', + optional: true, + }, + acknowledgementTimeout: { + type: 'number', + description: 'Seconds before an acknowledged incident reverts to triggered', + optional: true, + }, + createdAt: { type: 'string', description: 'Creation timestamp', optional: true }, + lastIncidentTimestamp: { + type: 'string', + description: 'Timestamp of the most recent incident', + optional: true, + }, + escalationPolicyName: { type: 'string', description: 'Escalation policy name', optional: true }, + escalationPolicyId: { type: 'string', description: 'Escalation policy ID', optional: true }, + htmlUrl: { type: 'string', description: 'PagerDuty web URL' }, + }, +} diff --git a/apps/sim/tools/pagerduty/index.ts b/apps/sim/tools/pagerduty/index.ts index e6ee2bc34b4..4ee66a335a5 100644 --- a/apps/sim/tools/pagerduty/index.ts +++ b/apps/sim/tools/pagerduty/index.ts @@ -1,13 +1,31 @@ import { addNoteTool } from '@/tools/pagerduty/add_note' import { createIncidentTool } from '@/tools/pagerduty/create_incident' +import { getIncidentTool } from '@/tools/pagerduty/get_incident' +import { getServiceTool } from '@/tools/pagerduty/get_service' +import { listEscalationPoliciesTool } from '@/tools/pagerduty/list_escalation_policies' +import { listIncidentAlertsTool } from '@/tools/pagerduty/list_incident_alerts' import { listIncidentsTool } from '@/tools/pagerduty/list_incidents' import { listOncallsTool } from '@/tools/pagerduty/list_oncalls' +import { listSchedulesTool } from '@/tools/pagerduty/list_schedules' import { listServicesTool } from '@/tools/pagerduty/list_services' +import { listUsersTool } from '@/tools/pagerduty/list_users' +import { mergeIncidentsTool } from '@/tools/pagerduty/merge_incidents' +import { sendEventTool } from '@/tools/pagerduty/send_event' +import { snoozeIncidentTool } from '@/tools/pagerduty/snooze_incident' import { updateIncidentTool } from '@/tools/pagerduty/update_incident' export const pagerdutyListIncidentsTool = listIncidentsTool +export const pagerdutyGetIncidentTool = getIncidentTool export const pagerdutyCreateIncidentTool = createIncidentTool export const pagerdutyUpdateIncidentTool = updateIncidentTool +export const pagerdutySnoozeIncidentTool = snoozeIncidentTool +export const pagerdutyMergeIncidentsTool = mergeIncidentsTool export const pagerdutyAddNoteTool = addNoteTool +export const pagerdutyListIncidentAlertsTool = listIncidentAlertsTool export const pagerdutyListServicesTool = listServicesTool +export const pagerdutyGetServiceTool = getServiceTool export const pagerdutyListOncallsTool = listOncallsTool +export const pagerdutyListEscalationPoliciesTool = listEscalationPoliciesTool +export const pagerdutyListSchedulesTool = listSchedulesTool +export const pagerdutyListUsersTool = listUsersTool +export const pagerdutySendEventTool = sendEventTool diff --git a/apps/sim/tools/pagerduty/list_escalation_policies.ts b/apps/sim/tools/pagerduty/list_escalation_policies.ts new file mode 100644 index 00000000000..b57f626cbdd --- /dev/null +++ b/apps/sim/tools/pagerduty/list_escalation_policies.ts @@ -0,0 +1,119 @@ +import type { + PagerDutyListEscalationPoliciesParams, + PagerDutyListEscalationPoliciesResponse, +} from '@/tools/pagerduty/types' +import type { ToolConfig } from '@/tools/types' + +export const listEscalationPoliciesTool: ToolConfig< + PagerDutyListEscalationPoliciesParams, + PagerDutyListEscalationPoliciesResponse +> = { + id: 'pagerduty_list_escalation_policies', + name: 'PagerDuty List Escalation Policies', + description: 'List escalation policies from PagerDuty with an optional name filter.', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'PagerDuty REST API Key', + }, + query: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Filter escalation policies by name', + }, + limit: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Maximum number of results (max 100)', + }, + offset: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Offset to start pagination search results', + }, + }, + + request: { + url: (params) => { + const query = new URLSearchParams() + if (params.query) query.set('query', params.query) + if (params.limit) query.set('limit', params.limit) + if (params.offset) query.set('offset', params.offset) + const qs = query.toString() + return `https://api.pagerduty.com/escalation_policies${qs ? `?${qs}` : ''}` + }, + method: 'GET', + headers: (params) => ({ + Authorization: `Token token=${params.apiKey}`, + Accept: 'application/vnd.pagerduty+json;version=2', + 'Content-Type': 'application/json', + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error?.message || `PagerDuty API error: ${response.status}`) + } + + return { + success: true, + output: { + escalationPolicies: (data.escalation_policies ?? []).map((ep: Record) => ({ + id: ep.id ?? null, + name: ep.name ?? null, + description: ep.description ?? null, + numLoops: ep.num_loops ?? 0, + onCallHandoffNotifications: ep.on_call_handoff_notifications ?? null, + htmlUrl: ep.html_url ?? null, + })), + total: data.total ?? null, + more: data.more ?? false, + offset: data.offset ?? 0, + }, + } + }, + + outputs: { + escalationPolicies: { + type: 'array', + description: 'Array of escalation policies', + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'Escalation policy ID' }, + name: { type: 'string', description: 'Escalation policy name' }, + description: { type: 'string', description: 'Escalation policy description' }, + numLoops: { type: 'number', description: 'Number of times the policy repeats' }, + onCallHandoffNotifications: { + type: 'string', + description: 'Handoff notification setting (if_has_services or always)', + }, + htmlUrl: { type: 'string', description: 'PagerDuty web URL' }, + }, + }, + }, + total: { + type: 'number', + description: + 'Total number of matching escalation policies (null unless explicitly requested by PagerDuty)', + optional: true, + }, + more: { + type: 'boolean', + description: 'Whether more results are available', + }, + offset: { + type: 'number', + description: 'Offset used for this page of results', + }, + }, +} diff --git a/apps/sim/tools/pagerduty/list_incident_alerts.ts b/apps/sim/tools/pagerduty/list_incident_alerts.ts new file mode 100644 index 00000000000..98d03eb54d7 --- /dev/null +++ b/apps/sim/tools/pagerduty/list_incident_alerts.ts @@ -0,0 +1,134 @@ +import type { + PagerDutyListIncidentAlertsParams, + PagerDutyListIncidentAlertsResponse, +} from '@/tools/pagerduty/types' +import type { ToolConfig } from '@/tools/types' + +export const listIncidentAlertsTool: ToolConfig< + PagerDutyListIncidentAlertsParams, + PagerDutyListIncidentAlertsResponse +> = { + id: 'pagerduty_list_incident_alerts', + name: 'PagerDuty List Incident Alerts', + description: 'List the individual alerts attached to a PagerDuty incident.', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'PagerDuty REST API Key', + }, + incidentId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the incident whose alerts to list', + }, + statuses: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Comma-separated statuses to filter (triggered, resolved)', + }, + limit: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Maximum number of results (max 100)', + }, + offset: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Offset to start pagination search results', + }, + }, + + request: { + url: (params) => { + const query = new URLSearchParams() + if (params.statuses) { + for (const s of params.statuses.split(',')) { + query.append('statuses[]', s.trim()) + } + } + if (params.limit) query.set('limit', params.limit) + if (params.offset) query.set('offset', params.offset) + const qs = query.toString() + return `https://api.pagerduty.com/incidents/${params.incidentId.trim()}/alerts${qs ? `?${qs}` : ''}` + }, + method: 'GET', + headers: (params) => ({ + Authorization: `Token token=${params.apiKey}`, + Accept: 'application/vnd.pagerduty+json;version=2', + 'Content-Type': 'application/json', + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error?.message || `PagerDuty API error: ${response.status}`) + } + + return { + success: true, + output: { + alerts: (data.alerts ?? []).map( + (alert: Record & { service?: Record }) => ({ + id: alert.id ?? null, + summary: alert.summary ?? null, + status: alert.status ?? null, + severity: alert.severity ?? null, + createdAt: alert.created_at ?? null, + alertKey: alert.alert_key ?? null, + serviceName: alert.service?.summary ?? null, + serviceId: alert.service?.id ?? null, + htmlUrl: alert.html_url ?? null, + }) + ), + total: data.total ?? null, + more: data.more ?? false, + offset: data.offset ?? 0, + }, + } + }, + + outputs: { + alerts: { + type: 'array', + description: 'Array of alerts attached to the incident', + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'Alert ID' }, + summary: { type: 'string', description: 'Alert summary' }, + status: { type: 'string', description: 'Alert status' }, + severity: { type: 'string', description: 'Alert severity' }, + createdAt: { type: 'string', description: 'Creation timestamp' }, + alertKey: { type: 'string', description: 'De-duplication key' }, + serviceName: { type: 'string', description: 'Service name' }, + serviceId: { type: 'string', description: 'Service ID' }, + htmlUrl: { type: 'string', description: 'PagerDuty web URL' }, + }, + }, + }, + total: { + type: 'number', + description: + 'Total number of matching alerts (null unless explicitly requested by PagerDuty)', + optional: true, + }, + more: { + type: 'boolean', + description: 'Whether more results are available', + }, + offset: { + type: 'number', + description: 'Offset used for this page of results', + }, + }, +} diff --git a/apps/sim/tools/pagerduty/list_incidents.ts b/apps/sim/tools/pagerduty/list_incidents.ts index a2ed3530761..52be2ee1478 100644 --- a/apps/sim/tools/pagerduty/list_incidents.ts +++ b/apps/sim/tools/pagerduty/list_incidents.ts @@ -26,6 +26,12 @@ export const listIncidentsTool: ToolConfig< visibility: 'user-or-llm', description: 'Comma-separated statuses to filter (triggered, acknowledged, resolved)', }, + urgencies: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Comma-separated urgencies to filter (high, low)', + }, serviceIds: { type: 'string', required: false, @@ -56,6 +62,12 @@ export const listIncidentsTool: ToolConfig< visibility: 'user-or-llm', description: 'Maximum number of results (max 100)', }, + offset: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Offset to start pagination search results', + }, }, request: { @@ -66,6 +78,11 @@ export const listIncidentsTool: ToolConfig< query.append('statuses[]', s.trim()) } } + if (params.urgencies) { + for (const u of params.urgencies.split(',')) { + query.append('urgencies[]', u.trim()) + } + } if (params.serviceIds) { for (const id of params.serviceIds.split(',')) { query.append('service_ids[]', id.trim()) @@ -75,6 +92,7 @@ export const listIncidentsTool: ToolConfig< if (params.until) query.set('until', params.until) if (params.sortBy) query.set('sort_by', params.sortBy) if (params.limit) query.set('limit', params.limit) + if (params.offset) query.set('offset', params.offset) query.append('include[]', 'services') const qs = query.toString() return `https://api.pagerduty.com/incidents${qs ? `?${qs}` : ''}` @@ -120,8 +138,9 @@ export const listIncidentsTool: ToolConfig< htmlUrl: inc.html_url ?? null, }) ), - total: data.total ?? 0, + total: data.total ?? null, more: data.more ?? false, + offset: data.offset ?? 0, }, } }, @@ -151,11 +170,17 @@ export const listIncidentsTool: ToolConfig< }, total: { type: 'number', - description: 'Total number of matching incidents', + description: + 'Total number of matching incidents (null unless explicitly requested by PagerDuty)', + optional: true, }, more: { type: 'boolean', description: 'Whether more results are available', }, + offset: { + type: 'number', + description: 'Offset used for this page of results', + }, }, } diff --git a/apps/sim/tools/pagerduty/list_oncalls.ts b/apps/sim/tools/pagerduty/list_oncalls.ts index 92f436b9c39..aeb4aa2c6c5 100644 --- a/apps/sim/tools/pagerduty/list_oncalls.ts +++ b/apps/sim/tools/pagerduty/list_oncalls.ts @@ -48,6 +48,12 @@ export const listOncallsTool: ToolConfig = { + id: 'pagerduty_list_schedules', + name: 'PagerDuty List Schedules', + description: 'List on-call schedules from PagerDuty with an optional name filter.', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'PagerDuty REST API Key', + }, + query: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Filter schedules by name', + }, + limit: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Maximum number of results (max 100)', + }, + offset: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Offset to start pagination search results', + }, + }, + + request: { + url: (params) => { + const query = new URLSearchParams() + if (params.query) query.set('query', params.query) + if (params.limit) query.set('limit', params.limit) + if (params.offset) query.set('offset', params.offset) + const qs = query.toString() + return `https://api.pagerduty.com/schedules${qs ? `?${qs}` : ''}` + }, + method: 'GET', + headers: (params) => ({ + Authorization: `Token token=${params.apiKey}`, + Accept: 'application/vnd.pagerduty+json;version=2', + 'Content-Type': 'application/json', + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error?.message || `PagerDuty API error: ${response.status}`) + } + + return { + success: true, + output: { + schedules: (data.schedules ?? []).map((sched: Record) => ({ + id: sched.id ?? null, + name: sched.name ?? null, + description: sched.description ?? null, + timeZone: sched.time_zone ?? null, + htmlUrl: sched.html_url ?? null, + })), + total: data.total ?? null, + more: data.more ?? false, + offset: data.offset ?? 0, + }, + } + }, + + outputs: { + schedules: { + type: 'array', + description: 'Array of on-call schedules', + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'Schedule ID' }, + name: { type: 'string', description: 'Schedule name' }, + description: { type: 'string', description: 'Schedule description' }, + timeZone: { type: 'string', description: 'Schedule time zone' }, + htmlUrl: { type: 'string', description: 'PagerDuty web URL' }, + }, + }, + }, + total: { + type: 'number', + description: + 'Total number of matching schedules (null unless explicitly requested by PagerDuty)', + optional: true, + }, + more: { + type: 'boolean', + description: 'Whether more results are available', + }, + offset: { + type: 'number', + description: 'Offset used for this page of results', + }, + }, +} diff --git a/apps/sim/tools/pagerduty/list_services.ts b/apps/sim/tools/pagerduty/list_services.ts index af281ffc837..beb1c0cf3d5 100644 --- a/apps/sim/tools/pagerduty/list_services.ts +++ b/apps/sim/tools/pagerduty/list_services.ts @@ -32,6 +32,12 @@ export const listServicesTool: ToolConfig< visibility: 'user-or-llm', description: 'Maximum number of results (max 100)', }, + offset: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Offset to start pagination search results', + }, }, request: { @@ -39,6 +45,7 @@ export const listServicesTool: ToolConfig< const query = new URLSearchParams() if (params.query) query.set('query', params.query) if (params.limit) query.set('limit', params.limit) + if (params.offset) query.set('offset', params.offset) const qs = query.toString() return `https://api.pagerduty.com/services${qs ? `?${qs}` : ''}` }, @@ -72,8 +79,9 @@ export const listServicesTool: ToolConfig< htmlUrl: svc.html_url ?? null, }) ), - total: data.total ?? 0, + total: data.total ?? null, more: data.more ?? false, + offset: data.offset ?? 0, }, } }, @@ -98,11 +106,17 @@ export const listServicesTool: ToolConfig< }, total: { type: 'number', - description: 'Total number of matching services', + description: + 'Total number of matching services (null unless explicitly requested by PagerDuty)', + optional: true, }, more: { type: 'boolean', description: 'Whether more results are available', }, + offset: { + type: 'number', + description: 'Offset used for this page of results', + }, }, } diff --git a/apps/sim/tools/pagerduty/list_users.ts b/apps/sim/tools/pagerduty/list_users.ts new file mode 100644 index 00000000000..48ba74af532 --- /dev/null +++ b/apps/sim/tools/pagerduty/list_users.ts @@ -0,0 +1,111 @@ +import type { PagerDutyListUsersParams, PagerDutyListUsersResponse } from '@/tools/pagerduty/types' +import type { ToolConfig } from '@/tools/types' + +export const listUsersTool: ToolConfig = { + id: 'pagerduty_list_users', + name: 'PagerDuty List Users', + description: 'List users from PagerDuty with an optional name/email filter.', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'PagerDuty REST API Key', + }, + query: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Filter users by name or email', + }, + limit: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Maximum number of results (max 100)', + }, + offset: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Offset to start pagination search results', + }, + }, + + request: { + url: (params) => { + const query = new URLSearchParams() + if (params.query) query.set('query', params.query) + if (params.limit) query.set('limit', params.limit) + if (params.offset) query.set('offset', params.offset) + const qs = query.toString() + return `https://api.pagerduty.com/users${qs ? `?${qs}` : ''}` + }, + method: 'GET', + headers: (params) => ({ + Authorization: `Token token=${params.apiKey}`, + Accept: 'application/vnd.pagerduty+json;version=2', + 'Content-Type': 'application/json', + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error?.message || `PagerDuty API error: ${response.status}`) + } + + return { + success: true, + output: { + users: (data.users ?? []).map((u: Record) => ({ + id: u.id ?? null, + name: u.name ?? null, + email: u.email ?? null, + role: u.role ?? null, + jobTitle: u.job_title ?? null, + timeZone: u.time_zone ?? null, + htmlUrl: u.html_url ?? null, + })), + total: data.total ?? null, + more: data.more ?? false, + offset: data.offset ?? 0, + }, + } + }, + + outputs: { + users: { + type: 'array', + description: 'Array of users', + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'User ID' }, + name: { type: 'string', description: 'User name' }, + email: { type: 'string', description: 'User email' }, + role: { type: 'string', description: 'User role' }, + jobTitle: { type: 'string', description: 'User job title' }, + timeZone: { type: 'string', description: 'User preferred time zone' }, + htmlUrl: { type: 'string', description: 'PagerDuty web URL' }, + }, + }, + }, + total: { + type: 'number', + description: 'Total number of matching users (null unless explicitly requested by PagerDuty)', + optional: true, + }, + more: { + type: 'boolean', + description: 'Whether more results are available', + }, + offset: { + type: 'number', + description: 'Offset used for this page of results', + }, + }, +} diff --git a/apps/sim/tools/pagerduty/merge_incidents.ts b/apps/sim/tools/pagerduty/merge_incidents.ts new file mode 100644 index 00000000000..9de264c1a11 --- /dev/null +++ b/apps/sim/tools/pagerduty/merge_incidents.ts @@ -0,0 +1,99 @@ +import type { + PagerDutyMergeIncidentsParams, + PagerDutyMergeIncidentsResponse, +} from '@/tools/pagerduty/types' +import type { ToolConfig } from '@/tools/types' + +export const mergeIncidentsTool: ToolConfig< + PagerDutyMergeIncidentsParams, + PagerDutyMergeIncidentsResponse +> = { + id: 'pagerduty_merge_incidents', + name: 'PagerDuty Merge Incidents', + description: + 'Merge one or more source incidents into a target incident. Source incidents are resolved and their alerts move to the target.', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'PagerDuty REST API Key', + }, + fromEmail: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Email address of a valid PagerDuty user', + }, + targetIncidentId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the incident that will absorb the source incidents', + }, + sourceIncidentIds: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Comma-separated IDs of the incidents to merge into the target incident', + }, + }, + + request: { + url: (params) => `https://api.pagerduty.com/incidents/${params.targetIncidentId.trim()}/merge`, + method: 'PUT', + headers: (params) => ({ + Authorization: `Token token=${params.apiKey}`, + Accept: 'application/vnd.pagerduty+json;version=2', + 'Content-Type': 'application/json', + From: params.fromEmail, + }), + body: (params) => { + const sourceIds = params.sourceIncidentIds + .split(',') + .map((id) => id.trim()) + .filter((id) => id.length > 0) + + if (sourceIds.length === 0) { + throw new Error('sourceIncidentIds must contain at least one non-empty incident ID') + } + + return { + source_incidents: sourceIds.map((id) => ({ + id, + type: 'incident_reference', + })), + } + }, + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error?.message || `PagerDuty API error: ${response.status}`) + } + + const inc = data.incident ?? {} + return { + success: true, + output: { + id: inc.id ?? null, + incidentNumber: inc.incident_number ?? null, + title: inc.title ?? null, + status: inc.status ?? null, + htmlUrl: inc.html_url ?? null, + }, + } + }, + + outputs: { + id: { type: 'string', description: 'Target incident ID' }, + incidentNumber: { type: 'number', description: 'Target incident number' }, + title: { type: 'string', description: 'Target incident title' }, + status: { type: 'string', description: 'Target incident status' }, + htmlUrl: { type: 'string', description: 'PagerDuty web URL' }, + }, +} diff --git a/apps/sim/tools/pagerduty/send_event.ts b/apps/sim/tools/pagerduty/send_event.ts new file mode 100644 index 00000000000..2b4e7a1c2d2 --- /dev/null +++ b/apps/sim/tools/pagerduty/send_event.ts @@ -0,0 +1,132 @@ +import type { PagerDutySendEventParams, PagerDutySendEventResponse } from '@/tools/pagerduty/types' +import type { ToolConfig } from '@/tools/types' + +export const sendEventTool: ToolConfig = { + id: 'pagerduty_send_event', + name: 'PagerDuty Send Event', + description: + 'Send a trigger, acknowledge, or resolve event to PagerDuty Events API v2 using a service integration key. Used to page from monitoring/alerting sources without a PagerDuty user account.', + version: '1.0.0', + + params: { + routingKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'The Events API v2 integration key (routing key) for the target service', + }, + eventAction: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Event action: trigger, acknowledge, or resolve', + }, + summary: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Brief summary of the event. Required when eventAction is trigger', + }, + source: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Unique location of the affected system (e.g. hostname). Required when eventAction is trigger', + }, + severity: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Perceived severity: critical, warning, error, or info. Required when eventAction is trigger', + }, + dedupKey: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'De-duplication key identifying the alert. Required when eventAction is acknowledge or resolve; optional on trigger', + }, + component: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Component of the source machine responsible for the event', + }, + group: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Logical grouping of components of a service', + }, + class: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'The class/type of the event', + }, + }, + + request: { + url: 'https://events.pagerduty.com/v2/enqueue', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => { + const body: Record = { + routing_key: params.routingKey, + event_action: params.eventAction, + } + + if (params.eventAction === 'acknowledge' || params.eventAction === 'resolve') { + if (!params.dedupKey) { + throw new Error('dedupKey is required when eventAction is acknowledge or resolve') + } + } + + if (params.dedupKey) body.dedup_key = params.dedupKey + + if (params.eventAction === 'trigger') { + if (!params.summary || !params.source || !params.severity) { + throw new Error('summary, source, and severity are required when eventAction is trigger') + } + + body.payload = { + summary: params.summary, + source: params.source, + severity: params.severity, + ...(params.component && { component: params.component }), + ...(params.group && { group: params.group }), + ...(params.class && { class: params.class }), + } + } + + return body + }, + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.message || `PagerDuty Events API error: ${response.status}`) + } + + return { + success: true, + output: { + status: data.status ?? null, + message: data.message ?? null, + dedupKey: data.dedup_key ?? null, + }, + } + }, + + outputs: { + status: { type: 'string', description: 'Result status ("success" if accepted)' }, + message: { type: 'string', description: 'Description of the result', optional: true }, + dedupKey: { type: 'string', description: 'De-duplication key for the alert', optional: true }, + }, +} diff --git a/apps/sim/tools/pagerduty/snooze_incident.ts b/apps/sim/tools/pagerduty/snooze_incident.ts new file mode 100644 index 00000000000..d4bbac018c1 --- /dev/null +++ b/apps/sim/tools/pagerduty/snooze_incident.ts @@ -0,0 +1,87 @@ +import type { + PagerDutySnoozeIncidentParams, + PagerDutySnoozeIncidentResponse, +} from '@/tools/pagerduty/types' +import type { ToolConfig } from '@/tools/types' + +export const snoozeIncidentTool: ToolConfig< + PagerDutySnoozeIncidentParams, + PagerDutySnoozeIncidentResponse +> = { + id: 'pagerduty_snooze_incident', + name: 'PagerDuty Snooze Incident', + description: + 'Snooze a triggered PagerDuty incident for a number of seconds, after which it returns to triggered.', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'PagerDuty REST API Key', + }, + fromEmail: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Email address of a valid PagerDuty user', + }, + incidentId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ID of the incident to snooze', + }, + duration: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Number of seconds to snooze the incident for (1 to 604800)', + }, + }, + + request: { + url: (params) => `https://api.pagerduty.com/incidents/${params.incidentId.trim()}/snooze`, + method: 'POST', + headers: (params) => ({ + Authorization: `Token token=${params.apiKey}`, + Accept: 'application/vnd.pagerduty+json;version=2', + 'Content-Type': 'application/json', + From: params.fromEmail, + }), + body: (params) => { + const duration = Number(params.duration) + if (!Number.isInteger(duration) || duration < 1 || duration > 604800) { + throw new Error('duration must be a whole number of seconds between 1 and 604800') + } + return { duration } + }, + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error?.message || `PagerDuty API error: ${response.status}`) + } + + const inc = data.incident ?? {} + return { + success: true, + output: { + id: inc.id ?? null, + incidentNumber: inc.incident_number ?? null, + status: inc.status ?? null, + htmlUrl: inc.html_url ?? null, + }, + } + }, + + outputs: { + id: { type: 'string', description: 'Incident ID' }, + incidentNumber: { type: 'number', description: 'Incident number' }, + status: { type: 'string', description: 'Incident status after snoozing' }, + htmlUrl: { type: 'string', description: 'PagerDuty web URL' }, + }, +} diff --git a/apps/sim/tools/pagerduty/types.ts b/apps/sim/tools/pagerduty/types.ts index 827821c6186..a03ff404e63 100644 --- a/apps/sim/tools/pagerduty/types.ts +++ b/apps/sim/tools/pagerduty/types.ts @@ -19,11 +19,13 @@ interface PagerDutyWriteParams extends PagerDutyBaseParams { */ export interface PagerDutyListIncidentsParams extends PagerDutyBaseParams { statuses?: string + urgencies?: string serviceIds?: string since?: string until?: string sortBy?: string limit?: string + offset?: string } export interface PagerDutyListIncidentsResponse extends ToolResponse { @@ -43,8 +45,37 @@ export interface PagerDutyListIncidentsResponse extends ToolResponse { escalationPolicyName: string | null htmlUrl: string | null }> - total: number + total: number | null more: boolean + offset: number + } +} + +/** + * Get Incident params. + */ +export interface PagerDutyGetIncidentParams extends PagerDutyBaseParams { + incidentId: string +} + +export interface PagerDutyGetIncidentResponse extends ToolResponse { + output: { + id: string + incidentNumber: number + title: string + status: string + urgency: string + createdAt: string + updatedAt: string | null + resolvedAt: string | null + serviceName: string | null + serviceId: string | null + assigneeName: string | null + assigneeId: string | null + escalationPolicyName: string | null + escalationPolicyId: string | null + incidentKey: string | null + htmlUrl: string | null } } @@ -58,6 +89,7 @@ export interface PagerDutyCreateIncidentParams extends PagerDutyWriteParams { body?: string escalationPolicyId?: string assigneeId?: string + incidentKey?: string } export interface PagerDutyCreateIncidentResponse extends ToolResponse { @@ -83,6 +115,7 @@ export interface PagerDutyUpdateIncidentParams extends PagerDutyWriteParams { title?: string urgency?: string escalationLevel?: string + resolution?: string } export interface PagerDutyUpdateIncidentResponse extends ToolResponse { @@ -120,6 +153,7 @@ export interface PagerDutyAddNoteResponse extends ToolResponse { export interface PagerDutyListServicesParams extends PagerDutyBaseParams { query?: string limit?: string + offset?: string } export interface PagerDutyListServicesResponse extends ToolResponse { @@ -134,8 +168,32 @@ export interface PagerDutyListServicesResponse extends ToolResponse { createdAt: string htmlUrl: string | null }> - total: number + total: number | null more: boolean + offset: number + } +} + +/** + * Get Service params. + */ +export interface PagerDutyGetServiceParams extends PagerDutyBaseParams { + serviceId: string +} + +export interface PagerDutyGetServiceResponse extends ToolResponse { + output: { + id: string + name: string + description: string | null + status: string + autoResolveTimeout: number | null + acknowledgementTimeout: number | null + createdAt: string | null + lastIncidentTimestamp: string | null + escalationPolicyName: string | null + escalationPolicyId: string | null + htmlUrl: string | null } } @@ -148,6 +206,7 @@ export interface PagerDutyListOncallsParams extends PagerDutyBaseParams { since?: string until?: string limit?: string + offset?: string } export interface PagerDutyListOncallsResponse extends ToolResponse { @@ -163,7 +222,170 @@ export interface PagerDutyListOncallsResponse extends ToolResponse { start: string | null end: string | null }> - total: number + total: number | null + more: boolean + offset: number + } +} + +/** + * List Escalation Policies params. + */ +export interface PagerDutyListEscalationPoliciesParams extends PagerDutyBaseParams { + query?: string + limit?: string + offset?: string +} + +export interface PagerDutyListEscalationPoliciesResponse extends ToolResponse { + output: { + escalationPolicies: Array<{ + id: string + name: string + description: string | null + numLoops: number + onCallHandoffNotifications: string | null + htmlUrl: string | null + }> + total: number | null + more: boolean + offset: number + } +} + +/** + * List Schedules params. + */ +export interface PagerDutyListSchedulesParams extends PagerDutyBaseParams { + query?: string + limit?: string + offset?: string +} + +export interface PagerDutyListSchedulesResponse extends ToolResponse { + output: { + schedules: Array<{ + id: string + name: string + description: string | null + timeZone: string | null + htmlUrl: string | null + }> + total: number | null + more: boolean + offset: number + } +} + +/** + * List Users params. + */ +export interface PagerDutyListUsersParams extends PagerDutyBaseParams { + query?: string + limit?: string + offset?: string +} + +export interface PagerDutyListUsersResponse extends ToolResponse { + output: { + users: Array<{ + id: string + name: string + email: string + role: string | null + jobTitle: string | null + timeZone: string | null + htmlUrl: string | null + }> + total: number | null + more: boolean + offset: number + } +} + +/** + * Snooze Incident params. + */ +export interface PagerDutySnoozeIncidentParams extends PagerDutyWriteParams { + incidentId: string + duration: string +} + +export interface PagerDutySnoozeIncidentResponse extends ToolResponse { + output: { + id: string + incidentNumber: number + status: string + htmlUrl: string | null + } +} + +/** + * Merge Incidents params. + */ +export interface PagerDutyMergeIncidentsParams extends PagerDutyWriteParams { + targetIncidentId: string + sourceIncidentIds: string +} + +export interface PagerDutyMergeIncidentsResponse extends ToolResponse { + output: { + id: string + incidentNumber: number + title: string + status: string + htmlUrl: string | null + } +} + +/** + * List Incident Alerts params. + */ +export interface PagerDutyListIncidentAlertsParams extends PagerDutyBaseParams { + incidentId: string + statuses?: string + limit?: string + offset?: string +} + +export interface PagerDutyListIncidentAlertsResponse extends ToolResponse { + output: { + alerts: Array<{ + id: string + summary: string | null + status: string + severity: string | null + createdAt: string + alertKey: string | null + serviceName: string | null + serviceId: string | null + htmlUrl: string | null + }> + total: number | null more: boolean + offset: number + } +} + +/** + * Send Event (Events API v2) params. + */ +export interface PagerDutySendEventParams { + routingKey: string + eventAction: string + summary?: string + source?: string + severity?: string + dedupKey?: string + component?: string + group?: string + class?: string +} + +export interface PagerDutySendEventResponse extends ToolResponse { + output: { + status: string + message: string | null + dedupKey: string | null } } diff --git a/apps/sim/tools/pagerduty/update_incident.ts b/apps/sim/tools/pagerduty/update_incident.ts index 156b5a1ad57..803690decac 100644 --- a/apps/sim/tools/pagerduty/update_incident.ts +++ b/apps/sim/tools/pagerduty/update_incident.ts @@ -36,7 +36,7 @@ export const updateIncidentTool: ToolConfig< type: 'string', required: false, visibility: 'user-or-llm', - description: 'New status (acknowledged or resolved)', + description: 'New status (triggered, acknowledged, or resolved)', }, title: { type: 'string', @@ -56,6 +56,13 @@ export const updateIncidentTool: ToolConfig< visibility: 'user-or-llm', description: 'Escalation level to escalate to', }, + resolution: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + "Resolution note added to the incident's log entry. Only used when status is set to resolved", + }, }, request: { @@ -79,6 +86,12 @@ export const updateIncidentTool: ToolConfig< if (params.escalationLevel) { incident.escalation_level = Number(params.escalationLevel) } + if (params.resolution) { + if (params.status !== 'resolved') { + throw new Error('resolution can only be set when status is resolved') + } + incident.resolution = params.resolution + } return { incident } }, }, diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index 0e008f5c371..fc052e9a56d 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -2482,9 +2482,18 @@ import { import { pagerdutyAddNoteTool, pagerdutyCreateIncidentTool, + pagerdutyGetIncidentTool, + pagerdutyGetServiceTool, + pagerdutyListEscalationPoliciesTool, + pagerdutyListIncidentAlertsTool, pagerdutyListIncidentsTool, pagerdutyListOncallsTool, + pagerdutyListSchedulesTool, pagerdutyListServicesTool, + pagerdutyListUsersTool, + pagerdutyMergeIncidentsTool, + pagerdutySendEventTool, + pagerdutySnoozeIncidentTool, pagerdutyUpdateIncidentTool, } from '@/tools/pagerduty' import { parallelDeepResearchTool, parallelExtractTool, parallelSearchTool } from '@/tools/parallel' @@ -7285,11 +7294,20 @@ export const tools: Record = { outlook_search: outlookSearchTool, outlook_update_message: outlookUpdateMessageTool, pagerduty_list_incidents: pagerdutyListIncidentsTool, + pagerduty_get_incident: pagerdutyGetIncidentTool, pagerduty_create_incident: pagerdutyCreateIncidentTool, pagerduty_update_incident: pagerdutyUpdateIncidentTool, + pagerduty_snooze_incident: pagerdutySnoozeIncidentTool, + pagerduty_merge_incidents: pagerdutyMergeIncidentsTool, pagerduty_add_note: pagerdutyAddNoteTool, + pagerduty_list_incident_alerts: pagerdutyListIncidentAlertsTool, pagerduty_list_services: pagerdutyListServicesTool, + pagerduty_get_service: pagerdutyGetServiceTool, pagerduty_list_oncalls: pagerdutyListOncallsTool, + pagerduty_list_escalation_policies: pagerdutyListEscalationPoliciesTool, + pagerduty_list_schedules: pagerdutyListSchedulesTool, + pagerduty_list_users: pagerdutyListUsersTool, + pagerduty_send_event: pagerdutySendEventTool, linear_read_issues: linearReadIssuesTool, linear_create_issue: linearCreateIssueTool, linear_get_issue: linearGetIssueTool, From 918ba8a86ac0b130d359e34639cdc40a3026dfbb Mon Sep 17 00:00:00 2001 From: Waleed Date: Mon, 6 Jul 2026 16:07:03 -0700 Subject: [PATCH 20/35] feat(ahrefs): validate integration, fix cents/column bugs, add 13 v3 endpoints (#5447) * feat(ahrefs): validate integration, fix cents/column bugs, add 13 v3 endpoints Audited the existing Ahrefs integration against live API v3 docs and fixed real bugs: broken_backlinks selected the wrong column (http_code_target instead of http_code), and keyword_overview/metrics/metrics_history returned CPC/cost fields in USD cents without converting to USD. Also fixed the top-pages mode dropdown missing the "exact" option. Added 13 new tools covering previously-unsupported Ahrefs v3 endpoints: Rank Tracker (overview, SERP overview, competitors overview, competitors stats), Batch Analysis, Site Audit page explorer, four history/trend endpoints (domain rating, metrics, referring domains, keywords), Related Terms, Anchors, and Paid Pages. Note: the CPC/cost unit fix silently shifts existing organicCost/paidCost/cpc values by 100x for any workflow already consuming them (the old values were wrong, in cents instead of dollars). * fix(ahrefs): convert remaining cents-to-USD fields, drop unneeded fallback key Rank Tracker SERP Overview's value field, Competitors Stats' trafficValue, and Competitors Overview's nested competitor value were all left in USD cents while every other monetary field in the integration converts to USD - fixed for consistency with the rest of the integration. Also drops the unverified `data.results` fallback in Batch Analysis; the Ahrefs v3 docs confirm the response key is always `targets`. * docs(ahrefs): regenerate docs to reflect cents-to-USD conversion fix The generated docs page was stale after the previous commit converted rank_tracker_serp_overview.value, rank_tracker_competitors_stats.trafficValue, and rank_tracker_competitors_overview's nested value from cents to USD - regenerating picks up the corrected field descriptions. * fix(ahrefs): revert incorrect broken_backlinks column, fix missed top_pages conversion, revert unverified competitor value conversions Independent re-verification against live Ahrefs v3 docs surfaced two real regressions from the earlier fix rounds and one missed conversion: - broken_backlinks: the earlier fix changed the selected column from http_code_target to http_code, but the docs say http_code is the *referring page's* status and http_code_target is the *broken target page's* status - the tool needs the latter (matches its own output description). Reverted to http_code_target. - top_pages: value field was never divided by 100 despite the docs stating it's in USD cents and the output already claiming USD - fixed. - rank_tracker_competitors_stats.trafficValue and the nested competitor.value in rank_tracker_competitors_overview were converted from cents to USD last round on a "match every other monetary field" assumption, but the live docs do not document these two fields as cents (unlike every field that was correctly converted). Reverted to passthrough and dropped the "(USD)" claim from their descriptions until Ahrefs documents the unit. Also added the missing "exact" mode option to top_pages' mode param description, matching every sibling tool. * fix(ahrefs): convert rank tracker competitor value/trafficValue to USD Both bots independently flagged these two fields as inconsistent with every other monetary field in the integration, all 7 of which are explicitly documented as USD cents. Neither field has explicit unit documentation (one is undocumented as cents, the other's schema isn't statically retrievable at all), but given the unanimous pattern across every other verified field and no contrary evidence, converting for consistency is the better bet than leaving them as an outlier. * style(ahrefs): drop non-TSDoc inline comments introduced in this PR Repo convention disallows non-TSDoc comments; removed the three explanatory // comments this PR added next to cents-to-USD conversions (keyword_overview, paid_pages, related_terms) - pre-existing comments elsewhere in the file are untouched, out of scope for this PR. * fix(ahrefs): default optional country to us consistently across all tools paid_pages, metrics_history, keywords_history, and batch_analysis were the only 4 of the 11 tools with an optional country param that didn't fall back to "us" when omitted, unlike domain_rating, metrics, keyword_overview, organic_keywords, organic_competitors, top_pages, and related_terms - all of which default client-side. Aligned all four to the same convention so direct tool/agent calls without an explicit country get the same behavior regardless of which operation is used. * fix(ahrefs): split shared date subBlock id for datetime-format operations site_audit_page_explorer and rank_tracker_serp_overview both reused the generic 'date' subBlock id with YYYY-MM-DDThh:mm:ss semantics, while every other operation using that same id expects YYYY-MM-DD. Switching operations without clearing the field could carry a stale wrong-format value into the new operation's request. Split into distinct ids (crawlDate, asOfDate) mapped back to each tool's date param in tools.config.params. --- .../content/docs/en/integrations/ahrefs.mdx | 376 +++++++- apps/sim/blocks/blocks/ahrefs.ts | 855 +++++++++++++++++- apps/sim/lib/integrations/integrations.json | 54 +- apps/sim/tools/ahrefs/anchors.ts | 120 +++ apps/sim/tools/ahrefs/batch_analysis.ts | 165 ++++ .../sim/tools/ahrefs/domain_rating_history.ts | 100 ++ apps/sim/tools/ahrefs/index.ts | 26 + apps/sim/tools/ahrefs/keyword_overview.ts | 2 +- apps/sim/tools/ahrefs/keywords_history.ts | 126 +++ apps/sim/tools/ahrefs/metrics.ts | 4 +- apps/sim/tools/ahrefs/metrics_history.ts | 136 +++ apps/sim/tools/ahrefs/paid_pages.ts | 134 +++ .../rank_tracker_competitors_overview.ts | 167 ++++ .../ahrefs/rank_tracker_competitors_stats.ts | 132 +++ .../sim/tools/ahrefs/rank_tracker_overview.ts | 149 +++ .../ahrefs/rank_tracker_serp_overview.ts | 166 ++++ apps/sim/tools/ahrefs/refdomains_history.ts | 113 +++ apps/sim/tools/ahrefs/related_terms.ts | 139 +++ .../tools/ahrefs/site_audit_page_explorer.ts | 142 +++ apps/sim/tools/ahrefs/top_pages.ts | 4 +- apps/sim/tools/ahrefs/types.ts | 353 ++++++++ apps/sim/tools/registry.ts | 26 + 22 files changed, 3475 insertions(+), 14 deletions(-) create mode 100644 apps/sim/tools/ahrefs/anchors.ts create mode 100644 apps/sim/tools/ahrefs/batch_analysis.ts create mode 100644 apps/sim/tools/ahrefs/domain_rating_history.ts create mode 100644 apps/sim/tools/ahrefs/keywords_history.ts create mode 100644 apps/sim/tools/ahrefs/metrics_history.ts create mode 100644 apps/sim/tools/ahrefs/paid_pages.ts create mode 100644 apps/sim/tools/ahrefs/rank_tracker_competitors_overview.ts create mode 100644 apps/sim/tools/ahrefs/rank_tracker_competitors_stats.ts create mode 100644 apps/sim/tools/ahrefs/rank_tracker_overview.ts create mode 100644 apps/sim/tools/ahrefs/rank_tracker_serp_overview.ts create mode 100644 apps/sim/tools/ahrefs/refdomains_history.ts create mode 100644 apps/sim/tools/ahrefs/related_terms.ts create mode 100644 apps/sim/tools/ahrefs/site_audit_page_explorer.ts diff --git a/apps/docs/content/docs/en/integrations/ahrefs.mdx b/apps/docs/content/docs/en/integrations/ahrefs.mdx index 92d278e9fab..329f4428fa1 100644 --- a/apps/docs/content/docs/en/integrations/ahrefs.mdx +++ b/apps/docs/content/docs/en/integrations/ahrefs.mdx @@ -18,8 +18,8 @@ With the Ahrefs integration in Sim, you can: - **Analyze Domain Rating & Authority**: Instantly check the Domain Rating (DR) and Ahrefs Rank of any website to gauge its authority. - **Fetch Backlinks**: Retrieve a list of backlinks pointing to a site or specific URL, with details like anchor text, referring page DR, and more. - **Get Backlink Statistics**: Access metrics on backlink types (dofollow, nofollow, text, image, redirect, etc.) for a domain or URL. -- **Explore Organic Keywords** *(planned)*: View keywords a domain ranks for and their positions in Google search results. -- **Discover Top Pages** *(planned)*: Identify the highest-performing pages by organic traffic and links. +- **Explore Organic Keywords**: View keywords a domain ranks for and their positions in Google search results. +- **Discover Top Pages**: Identify the highest-performing pages by organic traffic and links. These tools let your agents automate SEO research, monitor competitors, and generate reports—all as part of your workflow automations. To use the Ahrefs integration, you’ll need an Ahrefs Enterprise subscription with API access. {/* MANUAL-CONTENT-END */} @@ -244,7 +244,7 @@ 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, default\). Example: "domain" | +| `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 | @@ -293,4 +293,374 @@ Get detailed metrics for a keyword including search volume, keyword difficulty, | ↳ `branded` | boolean | Query references a specific brand | | ↳ `local` | boolean | Query seeks local results | +### `ahrefs_paid_pages` + +Get a target domain's pages that receive paid search traffic, sorted by estimated paid traffic. Returns page URLs with their paid traffic, keyword counts, and estimated spend. + +#### 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" \(default: "us"\) | +| `mode` | string | No | Analysis mode: domain \(entire domain\), prefix \(URL prefix\), subdomains \(include all subdomains, default\), exact \(exact URL match\) | +| `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 | +| --------- | ---- | ----------- | +| `paidPages` | array | List of pages receiving paid search traffic | +| ↳ `url` | string | The page URL | +| ↳ `traffic` | number | Estimated monthly paid search traffic | +| ↳ `keywords` | number | Number of paid keywords the page ranks for | +| ↳ `topKeyword` | string | The top keyword driving paid traffic to this page | +| ↳ `value` | number | Estimated monthly paid traffic cost in USD | +| ↳ `adsCount` | number | Number of unique ads shown for this page | + +### `ahrefs_anchors` + +Get the anchor text distribution for a target domain or URL's backlinks, showing how many links and referring domains use each anchor text. + +#### 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\) | +| `history` | string | No | Historical scope: "live" \(currently live\), "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 + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `anchors` | array | Anchor text distribution for the backlink profile | +| ↳ `anchor` | string | The anchor text | +| ↳ `backlinks` | number | Total backlinks using this anchor text | +| ↳ `dofollowBacklinks` | number | Number of dofollow backlinks using this anchor text | +| ↳ `referringDomains` | number | Number of unique referring domains using this anchor text | +| ↳ `firstSeen` | string | When a link with this anchor was first found | +| ↳ `lastSeen` | string | When a backlink with this anchor was last seen \(null if still live\) | + +### `ahrefs_related_terms` + +Get keyword ideas related to a seed keyword: terms the same top-ranking pages also rank for ("also rank for") or also discuss ("also talk about"), with volume, difficulty, and CPC. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `keyword` | string | Yes | The seed keyword to find related terms for | +| `country` | string | No | Country code for keyword data. Example: "us", "gb", "de" \(default: "us"\) | +| `terms` | string | No | Type of related keywords to return: "also_rank_for", "also_talk_about", or "all" \(default: "all"\) | +| `viewFor` | string | No | Whether to derive related terms from the top 10 or top 100 ranking pages \(default: "top_10"\) | +| `limit` | number | No | Maximum number of results to return. Example: 50 \(default: 1000\) | +| `apiKey` | string | Yes | Ahrefs API Key | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `relatedTerms` | array | Related keyword ideas for the seed keyword | +| ↳ `keyword` | string | The related keyword | +| ↳ `volume` | number | Average monthly search volume | +| ↳ `keywordDifficulty` | number | Keyword difficulty score \(0-100\) | +| ↳ `cpc` | number | Cost per click in USD | +| ↳ `parentTopic` | string | The parent topic for this keyword | +| ↳ `trafficPotential` | number | Estimated traffic potential if ranking #1 | +| ↳ `intents` | object | Search intent flags \(informational, navigational, commercial, transactional, branded, local\) | +| ↳ `serpFeatures` | array | SERP features present in the results | + +### `ahrefs_domain_rating_history` + +Get the historical Domain Rating (DR) trend for a target domain or URL over a date range, grouped daily, weekly, or monthly. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `target` | string | Yes | The target domain or URL to analyze. Example: "example.com" | +| `dateFrom` | string | Yes | Start date of the historical period, in YYYY-MM-DD format | +| `dateTo` | string | No | End date of the historical period, in YYYY-MM-DD format \(defaults to today\) | +| `historyGrouping` | string | No | Time interval for grouping data points: "daily", "weekly", or "monthly" \(default: "monthly"\) | +| `apiKey` | string | Yes | Ahrefs API Key | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `domainRatings` | array | Historical Domain Rating data points | +| ↳ `date` | string | The date of the measurement | +| ↳ `domainRating` | number | Domain Rating score \(0-100\) on this date | + +### `ahrefs_metrics_history` + +Get the historical organic and paid traffic trend for a target domain or URL over a date range: organic traffic/cost and paid traffic/cost at each point in time. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `target` | string | Yes | The target domain or URL to analyze. Example: "example.com" | +| `dateFrom` | string | Yes | Start date of the historical period, in YYYY-MM-DD format | +| `dateTo` | string | No | End date of the historical period, in YYYY-MM-DD format \(defaults to today\) | +| `volumeMode` | string | No | Search volume calculation: "monthly" or "average" \(default: "monthly"\) | +| `historyGrouping` | string | No | Time interval for grouping data points: "daily", "weekly", or "monthly" \(default: "monthly"\) | +| `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, default\), exact \(exact URL match\) | +| `apiKey` | string | Yes | Ahrefs API Key | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `metricsHistory` | array | Historical organic and paid traffic data points | +| ↳ `date` | string | Date of the metric entry | +| ↳ `organicTraffic` | number | Estimated monthly organic visits | +| ↳ `organicCost` | number | Estimated monthly cost to replicate organic traffic via ads \(USD\) | +| ↳ `paidTraffic` | number | Estimated monthly paid search visits | +| ↳ `paidCost` | number | Estimated monthly paid search spend \(USD\) | + +### `ahrefs_refdomains_history` + +Get the historical referring domains trend for a target domain or URL over a date range, grouped daily, weekly, or monthly. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `target` | string | Yes | The target domain or URL to analyze. Example: "example.com" | +| `dateFrom` | string | Yes | Start date of the historical period, in YYYY-MM-DD format | +| `dateTo` | string | No | End date of the historical period, in YYYY-MM-DD format \(defaults to today\) | +| `historyGrouping` | string | No | Time interval for grouping data points: "daily", "weekly", or "monthly" \(default: "monthly"\) | +| `mode` | string | No | Analysis mode: domain \(entire domain\), prefix \(URL prefix\), subdomains \(include all subdomains, default\), exact \(exact URL match\) | +| `apiKey` | string | Yes | Ahrefs API Key | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `referringDomainsHistory` | array | Historical referring domains count data points | +| ↳ `date` | string | The date of the data point | +| ↳ `referringDomains` | number | Total number of unique domains linking to the target on this date | + +### `ahrefs_keywords_history` + +Get the historical organic keyword ranking distribution for a target domain or URL over a date range: how many keywords rank in each position bucket at each point in time. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `target` | string | Yes | The target domain or URL to analyze. Example: "example.com" | +| `dateFrom` | string | Yes | Start date of the historical period, in YYYY-MM-DD format | +| `dateTo` | string | No | End date of the historical period, in YYYY-MM-DD format \(defaults to today\) | +| `historyGrouping` | string | No | Time interval for grouping data points: "daily", "weekly", or "monthly" \(default: "monthly"\) | +| `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\) | +| `apiKey` | string | Yes | Ahrefs API Key | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `keywordsHistory` | array | Historical organic keyword ranking distribution | +| ↳ `date` | string | Date of the record | +| ↳ `top3` | number | Keywords ranking in top 3 organic results | +| ↳ `top4To10` | number | Keywords ranking in positions 4-10 | +| ↳ `top11To20` | number | Keywords ranking in positions 11-20 | +| ↳ `top21To50` | number | Keywords ranking in positions 21-50 | +| ↳ `top51Plus` | number | Keywords ranking in position 51 and beyond | + +### `ahrefs_batch_analysis` + +Get bulk SEO metrics (Domain Rating, backlinks, referring domains, organic traffic, and more) for multiple domains or URLs in a single request. Useful for comparing many competitors at once. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `targets` | string | Yes | Comma-separated list of domains or URLs to analyze. Example: "example.com,competitor.com" | +| `mode` | string | No | Analysis mode applied to every target: domain \(entire domain\), prefix \(URL prefix\), subdomains \(include all subdomains, default\), exact \(exact URL match\) | +| `protocol` | string | No | Protocol applied to every target: "both" \(default\), "http", or "https" | +| `country` | string | No | Country code for traffic data. Example: "us", "gb", "de" \(default: "us"\) | +| `volumeMode` | string | No | Search volume calculation: "monthly" or "average" \(default: "monthly"\) | +| `apiKey` | string | Yes | Ahrefs API Key | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `results` | array | Bulk metrics for each analyzed target, in submission order | +| ↳ `url` | string | The analyzed target URL or domain | +| ↳ `index` | number | Index of the target in the submitted list | +| ↳ `domainRating` | number | Domain Rating score \(0-100\) | +| ↳ `ahrefsRank` | number | Ahrefs Rank \(global ranking\) | +| ↳ `backlinks` | number | Total backlinks to the target | +| ↳ `referringDomains` | number | Unique domains linking to the target | +| ↳ `organicTraffic` | number | Estimated monthly organic traffic | +| ↳ `organicKeywords` | number | Number of organic keywords ranked \(top 100\) | +| ↳ `paidTraffic` | number | Estimated monthly paid search traffic | +| ↳ `error` | string | Error message if this target could not be analyzed | + +### `ahrefs_site_audit_page_explorer` + +Get crawled pages from an Ahrefs Site Audit project with health and SEO metrics: HTTP status, title, link counts, backlinks, indexability, and traffic. Optionally filter to pages affected by a specific issue. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `projectId` | number | Yes | The Site Audit project ID \(found in the project URL in Ahrefs\) | +| `date` | string | No | Crawl date in YYYY-MM-DDThh:mm:ss format \(defaults to the most recent crawl\) | +| `limit` | number | No | Maximum number of results to return. Example: 50 \(default: 1000\) | +| `offset` | number | No | Number of results to skip, for pagination | +| `issueId` | string | No | Only return pages affected by this issue ID | +| `apiKey` | string | Yes | Ahrefs API Key | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `auditPages` | array | List of crawled pages with health and SEO metrics | +| ↳ `url` | string | The crawled page URL | +| ↳ `httpCode` | number | HTTP status code returned by the URL | +| ↳ `title` | array | Page title tag\(s\) | +| ↳ `internalLinks` | number | Number of internal outgoing links | +| ↳ `externalLinks` | number | Number of external outgoing links | +| ↳ `backlinks` | number | Number of incoming external links to the page | +| ↳ `compliant` | boolean | Whether the page is indexable \(200 status, no canonical/noindex\) | +| ↳ `traffic` | number | Estimated monthly organic traffic to the page | + +### `ahrefs_rank_tracker_overview` + +Get ranking overview metrics for the keywords tracked in an Ahrefs Rank Tracker project: position, search volume, keyword difficulty, and estimated traffic. This endpoint is free and does not consume API units. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `projectId` | number | Yes | The Rank Tracker project ID \(found in the project URL in Ahrefs\) | +| `date` | string | Yes | Date to report rankings for, in YYYY-MM-DD format | +| `device` | string | Yes | Rankings device type: "desktop" or "mobile" | +| `dateCompared` | string | No | Comparison date in YYYY-MM-DD format, to compute position/traffic deltas | +| `volumeMode` | string | No | Search volume calculation: "monthly" or "average" \(default: "monthly"\) | +| `limit` | number | No | Maximum number of results to return. Example: 50 \(default: 1000\) | +| `apiKey` | string | Yes | Ahrefs API Key | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `overviews` | array | Ranking overview for each tracked keyword | +| ↳ `keyword` | string | The tracked keyword | +| ↳ `position` | number | Top organic search position | +| ↳ `volume` | number | Average monthly search volume | +| ↳ `keywordDifficulty` | number | Keyword difficulty score \(0-100\) | +| ↳ `url` | string | Top-ranking URL | +| ↳ `traffic` | number | Estimated monthly organic visits | +| ↳ `serpFeatures` | array | SERP features present in the results | +| ↳ `bestPositionKind` | string | Type of the top position \(organic, paid, or SERP feature\) | + +### `ahrefs_rank_tracker_serp_overview` + +Get the full SERP (search engine results page) for a keyword tracked in an Ahrefs Rank Tracker project, including every ranking URL with its position, title, and authority metrics. This endpoint is free and does not consume API units. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `projectId` | number | Yes | The Rank Tracker project ID \(found in the project URL in Ahrefs\) | +| `keyword` | string | Yes | The tracked keyword to retrieve SERP data for | +| `country` | string | Yes | Country code for the tracked keyword. Example: "us", "gb", "de" | +| `device` | string | Yes | Rankings device type: "desktop" or "mobile" | +| `topPositions` | number | No | Number of top organic positions to return \(defaults to all available\) | +| `date` | string | No | Timestamp to return the last available SERP Overview at, in YYYY-MM-DDThh:mm:ss format | +| `locationId` | number | No | Location ID of the tracked keyword, if tracked at a specific location | +| `languageCode` | string | No | Language code of the tracked keyword | +| `apiKey` | string | Yes | Ahrefs API Key | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `positions` | array | Every ranking result on the SERP for the tracked keyword | +| ↳ `position` | number | Position of the result in the SERP | +| ↳ `url` | string | URL of the ranking page | +| ↳ `title` | string | Page title | +| ↳ `type` | array | The kind of the position: organic, paid, or a SERP feature | +| ↳ `domainRating` | number | Domain Rating of the ranking domain | +| ↳ `urlRating` | number | URL Rating of the ranking page | +| ↳ `backlinks` | number | Total backlinks to the ranking domain | +| ↳ `refdomains` | number | Unique referring domains | +| ↳ `traffic` | number | Estimated monthly organic search traffic | +| ↳ `value` | number | Estimated monthly traffic value \(USD\) | +| ↳ `topKeyword` | string | Highest-traffic keyword ranking for this page | +| ↳ `topKeywordVolume` | number | Monthly search volume for the top keyword | +| ↳ `updateDate` | string | Date the SERP was last checked | + +### `ahrefs_rank_tracker_competitors_overview` + +Get competitor rankings for the keywords tracked in an Ahrefs Rank Tracker project: each tracked keyword's volume and difficulty alongside every competitor's position, traffic, and traffic value. This endpoint is free and does not consume API units. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `projectId` | number | Yes | The Rank Tracker project ID \(found in the project URL in Ahrefs\) | +| `date` | string | Yes | Date to report rankings for, in YYYY-MM-DD format | +| `device` | string | Yes | Rankings device type: "desktop" or "mobile" | +| `dateCompared` | string | No | Comparison date in YYYY-MM-DD format, to compute position/traffic deltas | +| `volumeMode` | string | No | Search volume calculation: "monthly" or "average" \(default: "monthly"\) | +| `limit` | number | No | Maximum number of results to return. Example: 50 \(default: 1000\) | +| `apiKey` | string | Yes | Ahrefs API Key | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `competitorKeywords` | array | Tracked keywords with competitor ranking data | +| ↳ `keyword` | string | The tracked keyword | +| ↳ `volume` | number | Average monthly search volume | +| ↳ `keywordDifficulty` | number | Keyword difficulty score \(0-100\) | +| ↳ `serpFeatures` | array | SERP features present in the results | +| ↳ `competitorsList` | array | Ranking data for each tracked competitor on this keyword | +| ↳ `url` | string | The competitor's ranking URL | +| ↳ `position` | number | Current ranking position | +| ↳ `bestPositionKind` | string | Type of the best position achieved | +| ↳ `traffic` | number | Estimated traffic to the competitor | +| ↳ `value` | number | Estimated traffic value \(USD\) | + +### `ahrefs_rank_tracker_competitors_stats` + +Get aggregate competitor stats for an Ahrefs Rank Tracker project: each competitor's traffic, traffic value, average position, and share of voice across all tracked keywords. This endpoint is free and does not consume API units. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `projectId` | number | Yes | The Rank Tracker project ID \(found in the project URL in Ahrefs\) | +| `date` | string | Yes | Date to report metrics for, in YYYY-MM-DD format | +| `device` | string | Yes | Rankings device type: "desktop" or "mobile" | +| `volumeMode` | string | No | Search volume calculation: "monthly" or "average" \(default: "monthly"\) | +| `apiKey` | string | Yes | Ahrefs API Key | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `competitorsStats` | array | Aggregate stats for each tracked competitor | +| ↳ `competitor` | string | The competitor's URL | +| ↳ `traffic` | number | Estimated monthly organic visits | +| ↳ `trafficValue` | number | Estimated monthly organic traffic value \(USD\) | +| ↳ `averagePosition` | number | Average top organic position across tracked keywords | +| ↳ `pos1To3` | number | Keywords ranking in top 3 positions | +| ↳ `pos4To10` | number | Keywords ranking in positions 4-10 | +| ↳ `shareOfVoice` | number | Organic traffic share percentage | +| ↳ `shareOfTrafficValue` | number | Organic traffic value share percentage | + diff --git a/apps/sim/blocks/blocks/ahrefs.ts b/apps/sim/blocks/blocks/ahrefs.ts index cd5b5e22347..b27de4a0d66 100644 --- a/apps/sim/blocks/blocks/ahrefs.ts +++ b/apps/sim/blocks/blocks/ahrefs.ts @@ -28,6 +28,52 @@ const MODE_OPTIONS = [ { label: 'Exact (exact URL)', id: 'exact' }, ] +const DEVICE_OPTIONS = [ + { label: 'Desktop', id: 'desktop' }, + { label: 'Mobile', id: 'mobile' }, +] + +const VOLUME_MODE_OPTIONS = [ + { label: 'Monthly', id: 'monthly' }, + { label: 'Average', id: 'average' }, +] + +const HISTORY_GROUPING_OPTIONS = [ + { label: 'Monthly', id: 'monthly' }, + { label: 'Weekly', id: 'weekly' }, + { label: 'Daily', id: 'daily' }, +] + +const PROTOCOL_OPTIONS = [ + { label: 'Both', id: 'both' }, + { label: 'HTTP', id: 'http' }, + { label: 'HTTPS', id: 'https' }, +] + +const RELATED_TERMS_OPTIONS = [ + { label: 'All', id: 'all' }, + { label: 'Also rank for', id: 'also_rank_for' }, + { label: 'Also talk about', id: 'also_talk_about' }, +] + +const VIEW_FOR_OPTIONS = [ + { label: 'Top 10 results', id: 'top_10' }, + { label: 'Top 100 results', id: 'top_100' }, +] + +const DATE_TIME_WAND_CONFIG = { + enabled: true, + prompt: `Generate a timestamp in YYYY-MM-DDThh:mm:ss format based on the user's description. +Examples: +- "today" -> Current date at 00:00:00 in YYYY-MM-DDThh:mm:ss format +- "yesterday" -> Yesterday's date at 00:00:00 in YYYY-MM-DDThh:mm:ss format +- "last week" -> Date 7 days ago at 00:00:00 in YYYY-MM-DDThh:mm:ss format + +Return ONLY the timestamp string in YYYY-MM-DDThh:mm:ss format - no explanations, no quotes, no extra text.`, + placeholder: 'Describe the timestamp (e.g., "today", "yesterday")...', + generationType: 'timestamp' as const, +} + const DATE_WAND_CONFIG = { enabled: true, prompt: `Generate a date in YYYY-MM-DD format based on the user's description. @@ -69,7 +115,23 @@ export const AhrefsBlock: BlockConfig = { { label: 'Organic Keywords', id: 'ahrefs_organic_keywords' }, { label: 'Organic Competitors', id: 'ahrefs_organic_competitors' }, { label: 'Top Pages', id: 'ahrefs_top_pages' }, + { label: 'Paid Pages', id: 'ahrefs_paid_pages' }, + { label: 'Anchors', id: 'ahrefs_anchors' }, { label: 'Keyword Overview', id: 'ahrefs_keyword_overview' }, + { label: 'Related Terms', id: 'ahrefs_related_terms' }, + { label: 'Domain Rating History', id: 'ahrefs_domain_rating_history' }, + { label: 'Metrics History', id: 'ahrefs_metrics_history' }, + { label: 'Referring Domains History', id: 'ahrefs_refdomains_history' }, + { label: 'Keywords History', id: 'ahrefs_keywords_history' }, + { label: 'Batch Analysis', id: 'ahrefs_batch_analysis' }, + { label: 'Site Audit Page Explorer', id: 'ahrefs_site_audit_page_explorer' }, + { label: 'Rank Tracker Overview', id: 'ahrefs_rank_tracker_overview' }, + { label: 'Rank Tracker SERP Overview', id: 'ahrefs_rank_tracker_serp_overview' }, + { + label: 'Rank Tracker Competitors Overview', + id: 'ahrefs_rank_tracker_competitors_overview', + }, + { label: 'Rank Tracker Competitors Stats', id: 'ahrefs_rank_tracker_competitors_stats' }, ], value: () => 'ahrefs_domain_rating', }, @@ -366,11 +428,7 @@ export const AhrefsBlock: BlockConfig = { 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' }, mode: 'advanced', @@ -410,6 +468,627 @@ export const AhrefsBlock: BlockConfig = { condition: { field: 'operation', value: 'ahrefs_keyword_overview' }, mode: 'advanced', }, + // Paid Pages operation inputs + { + id: 'target', + title: 'Target Domain', + type: 'short-input', + placeholder: 'example.com', + condition: { field: 'operation', value: 'ahrefs_paid_pages' }, + required: true, + }, + { + id: 'country', + title: 'Country', + type: 'dropdown', + options: COUNTRY_OPTIONS, + value: () => 'us', + condition: { field: 'operation', value: 'ahrefs_paid_pages' }, + mode: 'advanced', + }, + { + id: 'mode', + title: 'Analysis Mode', + type: 'dropdown', + options: MODE_OPTIONS, + value: () => 'domain', + condition: { field: 'operation', value: 'ahrefs_paid_pages' }, + mode: 'advanced', + }, + { + id: 'limit', + title: 'Limit', + type: 'short-input', + placeholder: '1000', + condition: { field: 'operation', value: 'ahrefs_paid_pages' }, + mode: 'advanced', + }, + { + id: 'date', + title: 'Date', + type: 'short-input', + placeholder: 'YYYY-MM-DD (defaults to today)', + condition: { field: 'operation', value: 'ahrefs_paid_pages' }, + mode: 'advanced', + wandConfig: DATE_WAND_CONFIG, + }, + // Anchors operation inputs + { + id: 'target', + title: 'Target Domain/URL', + type: 'short-input', + placeholder: 'example.com', + condition: { field: 'operation', value: 'ahrefs_anchors' }, + required: true, + }, + { + id: 'mode', + title: 'Analysis Mode', + type: 'dropdown', + options: MODE_OPTIONS, + value: () => 'domain', + condition: { field: 'operation', value: 'ahrefs_anchors' }, + mode: 'advanced', + }, + { + 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_anchors' }, + mode: 'advanced', + }, + { + id: 'limit', + title: 'Limit', + type: 'short-input', + placeholder: '1000', + condition: { field: 'operation', value: 'ahrefs_anchors' }, + mode: 'advanced', + }, + // Related Terms operation inputs + { + id: 'keyword', + title: 'Keyword', + type: 'short-input', + placeholder: 'Enter seed keyword', + condition: { field: 'operation', value: 'ahrefs_related_terms' }, + required: true, + }, + { + id: 'country', + title: 'Country', + type: 'dropdown', + options: COUNTRY_OPTIONS, + value: () => 'us', + condition: { field: 'operation', value: 'ahrefs_related_terms' }, + mode: 'advanced', + }, + { + id: 'terms', + title: 'Related Terms Type', + type: 'dropdown', + options: RELATED_TERMS_OPTIONS, + value: () => 'all', + condition: { field: 'operation', value: 'ahrefs_related_terms' }, + mode: 'advanced', + }, + { + id: 'viewFor', + title: 'Derive From', + type: 'dropdown', + options: VIEW_FOR_OPTIONS, + value: () => 'top_10', + condition: { field: 'operation', value: 'ahrefs_related_terms' }, + mode: 'advanced', + }, + { + id: 'limit', + title: 'Limit', + type: 'short-input', + placeholder: '1000', + condition: { field: 'operation', value: 'ahrefs_related_terms' }, + mode: 'advanced', + }, + // Domain Rating History operation inputs + { + id: 'target', + title: 'Target Domain', + type: 'short-input', + placeholder: 'example.com', + condition: { field: 'operation', value: 'ahrefs_domain_rating_history' }, + required: true, + }, + { + id: 'dateFrom', + title: 'Start Date', + type: 'short-input', + placeholder: 'YYYY-MM-DD', + condition: { field: 'operation', value: 'ahrefs_domain_rating_history' }, + required: true, + wandConfig: DATE_WAND_CONFIG, + }, + { + id: 'dateTo', + title: 'End Date', + type: 'short-input', + placeholder: 'YYYY-MM-DD (defaults to today)', + condition: { field: 'operation', value: 'ahrefs_domain_rating_history' }, + mode: 'advanced', + wandConfig: DATE_WAND_CONFIG, + }, + { + id: 'historyGrouping', + title: 'Grouping', + type: 'dropdown', + options: HISTORY_GROUPING_OPTIONS, + value: () => 'monthly', + condition: { field: 'operation', value: 'ahrefs_domain_rating_history' }, + mode: 'advanced', + }, + // Metrics History operation inputs + { + id: 'target', + title: 'Target Domain/URL', + type: 'short-input', + placeholder: 'example.com', + condition: { field: 'operation', value: 'ahrefs_metrics_history' }, + required: true, + }, + { + id: 'dateFrom', + title: 'Start Date', + type: 'short-input', + placeholder: 'YYYY-MM-DD', + condition: { field: 'operation', value: 'ahrefs_metrics_history' }, + required: true, + wandConfig: DATE_WAND_CONFIG, + }, + { + id: 'dateTo', + title: 'End Date', + type: 'short-input', + placeholder: 'YYYY-MM-DD (defaults to today)', + condition: { field: 'operation', value: 'ahrefs_metrics_history' }, + mode: 'advanced', + wandConfig: DATE_WAND_CONFIG, + }, + { + id: 'country', + title: 'Country', + type: 'dropdown', + options: COUNTRY_OPTIONS, + value: () => 'us', + condition: { field: 'operation', value: 'ahrefs_metrics_history' }, + mode: 'advanced', + }, + { + id: 'mode', + title: 'Analysis Mode', + type: 'dropdown', + options: MODE_OPTIONS, + value: () => 'domain', + condition: { field: 'operation', value: 'ahrefs_metrics_history' }, + mode: 'advanced', + }, + { + id: 'volumeMode', + title: 'Volume Mode', + type: 'dropdown', + options: VOLUME_MODE_OPTIONS, + value: () => 'monthly', + condition: { field: 'operation', value: 'ahrefs_metrics_history' }, + mode: 'advanced', + }, + { + id: 'historyGrouping', + title: 'Grouping', + type: 'dropdown', + options: HISTORY_GROUPING_OPTIONS, + value: () => 'monthly', + condition: { field: 'operation', value: 'ahrefs_metrics_history' }, + mode: 'advanced', + }, + // Referring Domains History operation inputs + { + id: 'target', + title: 'Target Domain/URL', + type: 'short-input', + placeholder: 'example.com', + condition: { field: 'operation', value: 'ahrefs_refdomains_history' }, + required: true, + }, + { + id: 'dateFrom', + title: 'Start Date', + type: 'short-input', + placeholder: 'YYYY-MM-DD', + condition: { field: 'operation', value: 'ahrefs_refdomains_history' }, + required: true, + wandConfig: DATE_WAND_CONFIG, + }, + { + id: 'dateTo', + title: 'End Date', + type: 'short-input', + placeholder: 'YYYY-MM-DD (defaults to today)', + condition: { field: 'operation', value: 'ahrefs_refdomains_history' }, + mode: 'advanced', + wandConfig: DATE_WAND_CONFIG, + }, + { + id: 'mode', + title: 'Analysis Mode', + type: 'dropdown', + options: MODE_OPTIONS, + value: () => 'domain', + condition: { field: 'operation', value: 'ahrefs_refdomains_history' }, + mode: 'advanced', + }, + { + id: 'historyGrouping', + title: 'Grouping', + type: 'dropdown', + options: HISTORY_GROUPING_OPTIONS, + value: () => 'monthly', + condition: { field: 'operation', value: 'ahrefs_refdomains_history' }, + mode: 'advanced', + }, + // Keywords History operation inputs + { + id: 'target', + title: 'Target Domain/URL', + type: 'short-input', + placeholder: 'example.com', + condition: { field: 'operation', value: 'ahrefs_keywords_history' }, + required: true, + }, + { + id: 'dateFrom', + title: 'Start Date', + type: 'short-input', + placeholder: 'YYYY-MM-DD', + condition: { field: 'operation', value: 'ahrefs_keywords_history' }, + required: true, + wandConfig: DATE_WAND_CONFIG, + }, + { + id: 'dateTo', + title: 'End Date', + type: 'short-input', + placeholder: 'YYYY-MM-DD (defaults to today)', + condition: { field: 'operation', value: 'ahrefs_keywords_history' }, + mode: 'advanced', + wandConfig: DATE_WAND_CONFIG, + }, + { + id: 'country', + title: 'Country', + type: 'dropdown', + options: COUNTRY_OPTIONS, + value: () => 'us', + condition: { field: 'operation', value: 'ahrefs_keywords_history' }, + mode: 'advanced', + }, + { + id: 'mode', + title: 'Analysis Mode', + type: 'dropdown', + options: MODE_OPTIONS, + value: () => 'domain', + condition: { field: 'operation', value: 'ahrefs_keywords_history' }, + mode: 'advanced', + }, + { + id: 'historyGrouping', + title: 'Grouping', + type: 'dropdown', + options: HISTORY_GROUPING_OPTIONS, + value: () => 'monthly', + condition: { field: 'operation', value: 'ahrefs_keywords_history' }, + mode: 'advanced', + }, + // Batch Analysis operation inputs + { + id: 'targets', + title: 'Targets', + type: 'long-input', + placeholder: 'example.com, competitor.com, another-site.com', + condition: { field: 'operation', value: 'ahrefs_batch_analysis' }, + required: true, + }, + { + id: 'mode', + title: 'Analysis Mode', + type: 'dropdown', + options: MODE_OPTIONS, + value: () => 'subdomains', + condition: { field: 'operation', value: 'ahrefs_batch_analysis' }, + mode: 'advanced', + }, + { + id: 'protocol', + title: 'Protocol', + type: 'dropdown', + options: PROTOCOL_OPTIONS, + value: () => 'both', + condition: { field: 'operation', value: 'ahrefs_batch_analysis' }, + mode: 'advanced', + }, + { + id: 'country', + title: 'Country', + type: 'dropdown', + options: COUNTRY_OPTIONS, + value: () => 'us', + condition: { field: 'operation', value: 'ahrefs_batch_analysis' }, + mode: 'advanced', + }, + { + id: 'volumeMode', + title: 'Volume Mode', + type: 'dropdown', + options: VOLUME_MODE_OPTIONS, + value: () => 'monthly', + condition: { field: 'operation', value: 'ahrefs_batch_analysis' }, + mode: 'advanced', + }, + // Site Audit Page Explorer operation inputs + { + id: 'projectId', + title: 'Site Audit Project ID', + type: 'short-input', + placeholder: '12345', + condition: { field: 'operation', value: 'ahrefs_site_audit_page_explorer' }, + required: true, + }, + { + id: 'issueId', + title: 'Issue ID', + type: 'short-input', + placeholder: 'Only show pages affected by this issue', + condition: { field: 'operation', value: 'ahrefs_site_audit_page_explorer' }, + mode: 'advanced', + }, + { + id: 'crawlDate', + title: 'Crawl Date', + type: 'short-input', + placeholder: 'YYYY-MM-DDThh:mm:ss (defaults to most recent crawl)', + condition: { field: 'operation', value: 'ahrefs_site_audit_page_explorer' }, + mode: 'advanced', + wandConfig: DATE_TIME_WAND_CONFIG, + }, + { + id: 'limit', + title: 'Limit', + type: 'short-input', + placeholder: '1000', + condition: { field: 'operation', value: 'ahrefs_site_audit_page_explorer' }, + mode: 'advanced', + }, + { + id: 'offset', + title: 'Offset', + type: 'short-input', + placeholder: '0', + condition: { field: 'operation', value: 'ahrefs_site_audit_page_explorer' }, + mode: 'advanced', + }, + // Rank Tracker Overview operation inputs + { + id: 'projectId', + title: 'Rank Tracker Project ID', + type: 'short-input', + placeholder: '12345', + condition: { field: 'operation', value: 'ahrefs_rank_tracker_overview' }, + required: true, + }, + { + id: 'date', + title: 'Date', + type: 'short-input', + placeholder: 'YYYY-MM-DD', + condition: { field: 'operation', value: 'ahrefs_rank_tracker_overview' }, + required: true, + wandConfig: DATE_WAND_CONFIG, + }, + { + id: 'device', + title: 'Device', + type: 'dropdown', + options: DEVICE_OPTIONS, + value: () => 'desktop', + condition: { field: 'operation', value: 'ahrefs_rank_tracker_overview' }, + required: true, + }, + { + id: 'dateCompared', + title: 'Compare To Date', + type: 'short-input', + placeholder: 'YYYY-MM-DD', + condition: { field: 'operation', value: 'ahrefs_rank_tracker_overview' }, + mode: 'advanced', + wandConfig: DATE_WAND_CONFIG, + }, + { + id: 'volumeMode', + title: 'Volume Mode', + type: 'dropdown', + options: VOLUME_MODE_OPTIONS, + value: () => 'monthly', + condition: { field: 'operation', value: 'ahrefs_rank_tracker_overview' }, + mode: 'advanced', + }, + { + id: 'limit', + title: 'Limit', + type: 'short-input', + placeholder: '1000', + condition: { field: 'operation', value: 'ahrefs_rank_tracker_overview' }, + mode: 'advanced', + }, + // Rank Tracker SERP Overview operation inputs + { + id: 'projectId', + title: 'Rank Tracker Project ID', + type: 'short-input', + placeholder: '12345', + condition: { field: 'operation', value: 'ahrefs_rank_tracker_serp_overview' }, + required: true, + }, + { + id: 'keyword', + title: 'Keyword', + type: 'short-input', + placeholder: 'Enter tracked keyword', + condition: { field: 'operation', value: 'ahrefs_rank_tracker_serp_overview' }, + required: true, + }, + { + id: 'country', + title: 'Country', + type: 'dropdown', + options: COUNTRY_OPTIONS, + value: () => 'us', + condition: { field: 'operation', value: 'ahrefs_rank_tracker_serp_overview' }, + required: true, + }, + { + id: 'device', + title: 'Device', + type: 'dropdown', + options: DEVICE_OPTIONS, + value: () => 'desktop', + condition: { field: 'operation', value: 'ahrefs_rank_tracker_serp_overview' }, + required: true, + }, + { + id: 'topPositions', + title: 'Top Positions', + type: 'short-input', + placeholder: 'All available positions', + condition: { field: 'operation', value: 'ahrefs_rank_tracker_serp_overview' }, + mode: 'advanced', + }, + { + id: 'asOfDate', + title: 'As Of', + type: 'short-input', + placeholder: 'YYYY-MM-DDThh:mm:ss (defaults to latest)', + condition: { field: 'operation', value: 'ahrefs_rank_tracker_serp_overview' }, + mode: 'advanced', + wandConfig: DATE_TIME_WAND_CONFIG, + }, + { + id: 'locationId', + title: 'Location ID', + type: 'short-input', + placeholder: 'Optional tracked location ID', + condition: { field: 'operation', value: 'ahrefs_rank_tracker_serp_overview' }, + mode: 'advanced', + }, + { + id: 'languageCode', + title: 'Language Code', + type: 'short-input', + placeholder: 'Optional tracked language code', + condition: { field: 'operation', value: 'ahrefs_rank_tracker_serp_overview' }, + mode: 'advanced', + }, + // Rank Tracker Competitors Overview operation inputs + { + id: 'projectId', + title: 'Rank Tracker Project ID', + type: 'short-input', + placeholder: '12345', + condition: { field: 'operation', value: 'ahrefs_rank_tracker_competitors_overview' }, + required: true, + }, + { + id: 'date', + title: 'Date', + type: 'short-input', + placeholder: 'YYYY-MM-DD', + condition: { field: 'operation', value: 'ahrefs_rank_tracker_competitors_overview' }, + required: true, + wandConfig: DATE_WAND_CONFIG, + }, + { + id: 'device', + title: 'Device', + type: 'dropdown', + options: DEVICE_OPTIONS, + value: () => 'desktop', + condition: { field: 'operation', value: 'ahrefs_rank_tracker_competitors_overview' }, + required: true, + }, + { + id: 'dateCompared', + title: 'Compare To Date', + type: 'short-input', + placeholder: 'YYYY-MM-DD', + condition: { field: 'operation', value: 'ahrefs_rank_tracker_competitors_overview' }, + mode: 'advanced', + wandConfig: DATE_WAND_CONFIG, + }, + { + id: 'volumeMode', + title: 'Volume Mode', + type: 'dropdown', + options: VOLUME_MODE_OPTIONS, + value: () => 'monthly', + condition: { field: 'operation', value: 'ahrefs_rank_tracker_competitors_overview' }, + mode: 'advanced', + }, + { + id: 'limit', + title: 'Limit', + type: 'short-input', + placeholder: '1000', + condition: { field: 'operation', value: 'ahrefs_rank_tracker_competitors_overview' }, + mode: 'advanced', + }, + // Rank Tracker Competitors Stats operation inputs + { + id: 'projectId', + title: 'Rank Tracker Project ID', + type: 'short-input', + placeholder: '12345', + condition: { field: 'operation', value: 'ahrefs_rank_tracker_competitors_stats' }, + required: true, + }, + { + id: 'date', + title: 'Date', + type: 'short-input', + placeholder: 'YYYY-MM-DD', + condition: { field: 'operation', value: 'ahrefs_rank_tracker_competitors_stats' }, + required: true, + wandConfig: DATE_WAND_CONFIG, + }, + { + id: 'device', + title: 'Device', + type: 'dropdown', + options: DEVICE_OPTIONS, + value: () => 'desktop', + condition: { field: 'operation', value: 'ahrefs_rank_tracker_competitors_stats' }, + required: true, + }, + { + id: 'volumeMode', + title: 'Volume Mode', + type: 'dropdown', + options: VOLUME_MODE_OPTIONS, + value: () => 'monthly', + condition: { field: 'operation', value: 'ahrefs_rank_tracker_competitors_stats' }, + mode: 'advanced', + }, // API Key (common to all operations) { id: 'apiKey', @@ -432,6 +1111,19 @@ export const AhrefsBlock: BlockConfig = { 'ahrefs_organic_competitors', 'ahrefs_top_pages', 'ahrefs_keyword_overview', + 'ahrefs_paid_pages', + 'ahrefs_anchors', + 'ahrefs_related_terms', + 'ahrefs_domain_rating_history', + 'ahrefs_metrics_history', + 'ahrefs_refdomains_history', + 'ahrefs_keywords_history', + 'ahrefs_batch_analysis', + 'ahrefs_site_audit_page_explorer', + 'ahrefs_rank_tracker_overview', + 'ahrefs_rank_tracker_serp_overview', + 'ahrefs_rank_tracker_competitors_overview', + 'ahrefs_rank_tracker_competitors_stats', ], config: { tool: (params) => { @@ -456,6 +1148,32 @@ export const AhrefsBlock: BlockConfig = { return 'ahrefs_top_pages' case 'ahrefs_keyword_overview': return 'ahrefs_keyword_overview' + case 'ahrefs_paid_pages': + return 'ahrefs_paid_pages' + case 'ahrefs_anchors': + return 'ahrefs_anchors' + case 'ahrefs_related_terms': + return 'ahrefs_related_terms' + case 'ahrefs_domain_rating_history': + return 'ahrefs_domain_rating_history' + case 'ahrefs_metrics_history': + return 'ahrefs_metrics_history' + case 'ahrefs_refdomains_history': + return 'ahrefs_refdomains_history' + case 'ahrefs_keywords_history': + return 'ahrefs_keywords_history' + case 'ahrefs_batch_analysis': + return 'ahrefs_batch_analysis' + case 'ahrefs_site_audit_page_explorer': + return 'ahrefs_site_audit_page_explorer' + case 'ahrefs_rank_tracker_overview': + return 'ahrefs_rank_tracker_overview' + case 'ahrefs_rank_tracker_serp_overview': + return 'ahrefs_rank_tracker_serp_overview' + case 'ahrefs_rank_tracker_competitors_overview': + return 'ahrefs_rank_tracker_competitors_overview' + case 'ahrefs_rank_tracker_competitors_stats': + return 'ahrefs_rank_tracker_competitors_stats' default: return 'ahrefs_domain_rating' } @@ -463,6 +1181,12 @@ export const AhrefsBlock: BlockConfig = { params: (params) => { const result: Record = {} if (params.limit) result.limit = Number(params.limit) + if (params.projectId) result.projectId = Number(params.projectId) + if (params.topPositions) result.topPositions = Number(params.topPositions) + if (params.locationId) result.locationId = Number(params.locationId) + if (params.offset) result.offset = Number(params.offset) + if (params.crawlDate) result.date = params.crawlDate + if (params.asOfDate) result.date = params.asOfDate return result }, }, @@ -480,6 +1204,45 @@ export const AhrefsBlock: BlockConfig = { description: 'Historical scope for backlink-profile endpoints (all_time, live)', }, limit: { type: 'number', description: 'Maximum number of results to return' }, + targets: { + type: 'string', + description: 'Comma-separated list of domains or URLs for batch analysis', + }, + protocol: { type: 'string', description: 'Protocol filter (both, http, https)' }, + volumeMode: { + type: 'string', + description: 'Search volume calculation mode (monthly, average)', + }, + projectId: { type: 'number', description: 'Ahrefs Rank Tracker or Site Audit project ID' }, + device: { type: 'string', description: 'Rankings device type (desktop, mobile)' }, + dateCompared: { type: 'string', description: 'Comparison date in YYYY-MM-DD format' }, + topPositions: { type: 'number', description: 'Number of top organic positions to return' }, + locationId: { type: 'number', description: 'Tracked keyword location ID' }, + languageCode: { type: 'string', description: 'Tracked keyword language code' }, + dateFrom: { type: 'string', description: 'Start date of a historical period (YYYY-MM-DD)' }, + dateTo: { type: 'string', description: 'End date of a historical period (YYYY-MM-DD)' }, + historyGrouping: { + type: 'string', + description: 'Time interval for grouping historical data (daily, weekly, monthly)', + }, + terms: { + type: 'string', + description: 'Type of related keywords to return (also_rank_for, also_talk_about, all)', + }, + viewFor: { + type: 'string', + description: 'Whether to derive related terms from top 10 or top 100 ranking pages', + }, + issueId: { type: 'string', description: 'Site Audit issue ID to filter affected pages' }, + offset: { type: 'number', description: 'Number of results to skip, for pagination' }, + crawlDate: { + type: 'string', + description: 'Site Audit crawl date/time in YYYY-MM-DDThh:mm:ss format', + }, + asOfDate: { + type: 'string', + description: 'Rank Tracker SERP snapshot date/time in YYYY-MM-DDThh:mm:ss format', + }, }, outputs: { // Domain Rating output @@ -515,6 +1278,59 @@ export const AhrefsBlock: BlockConfig = { description: 'Keyword metrics overview, including search intent flags (informational, navigational, commercial, transactional, branded, local)', }, + // Paid Pages output + paidPages: { type: 'json', description: 'List of pages receiving paid search traffic' }, + // Anchors output + anchors: { type: 'json', description: 'Anchor text distribution for the backlink profile' }, + // Related Terms output + relatedTerms: { type: 'json', description: 'Related keyword ideas for the seed keyword' }, + // Domain Rating History output + domainRatings: { type: 'json', description: 'Historical Domain Rating data points' }, + // Metrics History output + metricsHistory: { + type: 'json', + description: 'Historical organic and paid traffic data points', + }, + // Referring Domains History output + referringDomainsHistory: { + type: 'json', + description: 'Historical referring domains count data points', + }, + // Keywords History output + keywordsHistory: { + type: 'json', + description: 'Historical organic keyword ranking distribution', + }, + // Batch Analysis output + results: { + type: 'json', + description: 'Bulk SEO metrics for each analyzed target, in submission order', + }, + // Site Audit Page Explorer output + auditPages: { + type: 'json', + description: 'Crawled pages with health and SEO metrics from a Site Audit project', + }, + // Rank Tracker Overview output + overviews: { + type: 'json', + description: 'Ranking overview for each keyword tracked in a Rank Tracker project', + }, + // Rank Tracker SERP Overview output + positions: { + type: 'json', + description: 'Every ranking result on the SERP for a tracked keyword', + }, + // Rank Tracker Competitors Overview output + competitorKeywords: { + type: 'json', + description: 'Tracked keywords with competitor ranking, traffic, and traffic value data', + }, + // Rank Tracker Competitors Stats output + competitorsStats: { + type: 'json', + description: 'Aggregate stats for each tracked Rank Tracker competitor', + }, }, } @@ -600,6 +1416,35 @@ export const AhrefsBlockMeta = { tags: ['marketing', 'monitoring', 'reporting'], alsoIntegrations: ['slack'], }, + { + icon: AhrefsIcon, + title: 'Ahrefs Rank Tracker daily digest', + prompt: + 'Build a scheduled daily workflow that pulls the Ahrefs Rank Tracker overview for my project, has an agent summarize position and traffic movers, and posts the digest to Slack.', + modules: ['scheduled', 'agent', 'workflows'], + category: 'marketing', + tags: ['marketing', 'reporting', 'monitoring'], + alsoIntegrations: ['slack'], + }, + { + icon: AhrefsIcon, + title: 'Ahrefs batch competitor snapshot', + prompt: + 'Create a workflow that runs an Ahrefs batch analysis across a list of competitor domains, writes the resulting Domain Rating, backlinks, and organic traffic to a comparison table, and has an agent call out the biggest gaps.', + modules: ['tables', 'agent', 'workflows'], + category: 'marketing', + tags: ['marketing', 'research'], + }, + { + icon: AhrefsIcon, + title: 'Ahrefs Site Audit issue tracker', + prompt: + 'Build a scheduled weekly workflow that pulls crawled pages from an Ahrefs Site Audit project, writes non-compliant or broken pages to a remediation table, and posts a Slack summary for the SEO lead.', + modules: ['scheduled', 'tables', 'agent', 'workflows'], + category: 'marketing', + tags: ['marketing', 'monitoring', 'automation'], + alsoIntegrations: ['slack'], + }, ], skills: [ { diff --git a/apps/sim/lib/integrations/integrations.json b/apps/sim/lib/integrations/integrations.json index 7790433367d..fcfc2ba81ff 100644 --- a/apps/sim/lib/integrations/integrations.json +++ b/apps/sim/lib/integrations/integrations.json @@ -387,12 +387,64 @@ "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." }, + { + "name": "Paid Pages", + "description": "Get a target domain's pages that receive paid search traffic, sorted by estimated paid traffic. Returns page URLs with their paid traffic, keyword counts, and estimated spend." + }, + { + "name": "Anchors", + "description": "Get the anchor text distribution for a target domain or URL's backlinks, showing how many links and referring domains use each anchor text." + }, { "name": "Keyword Overview", "description": "Get detailed metrics for a keyword including search volume, keyword difficulty, CPC, clicks, and traffic potential." + }, + { + "name": "Related Terms", + "description": "Get keyword ideas related to a seed keyword: terms the same top-ranking pages also rank for (\"also rank for\") or also discuss (\"also talk about\"), with volume, difficulty, and CPC." + }, + { + "name": "Domain Rating History", + "description": "Get the historical Domain Rating (DR) trend for a target domain or URL over a date range, grouped daily, weekly, or monthly." + }, + { + "name": "Metrics History", + "description": "Get the historical organic and paid traffic trend for a target domain or URL over a date range: organic traffic/cost and paid traffic/cost at each point in time." + }, + { + "name": "Referring Domains History", + "description": "Get the historical referring domains trend for a target domain or URL over a date range, grouped daily, weekly, or monthly." + }, + { + "name": "Keywords History", + "description": "Get the historical organic keyword ranking distribution for a target domain or URL over a date range: how many keywords rank in each position bucket at each point in time." + }, + { + "name": "Batch Analysis", + "description": "Get bulk SEO metrics (Domain Rating, backlinks, referring domains, organic traffic, and more) for multiple domains or URLs in a single request. Useful for comparing many competitors at once." + }, + { + "name": "Site Audit Page Explorer", + "description": "Get crawled pages from an Ahrefs Site Audit project with health and SEO metrics: HTTP status, title, link counts, backlinks, indexability, and traffic. Optionally filter to pages affected by a specific issue." + }, + { + "name": "Rank Tracker Overview", + "description": "Get ranking overview metrics for the keywords tracked in an Ahrefs Rank Tracker project: position, search volume, keyword difficulty, and estimated traffic. This endpoint is free and does not consume API units." + }, + { + "name": "Rank Tracker SERP Overview", + "description": "Get the full SERP (search engine results page) for a keyword tracked in an Ahrefs Rank Tracker project, including every ranking URL with its position, title, and authority metrics. This endpoint is free and does not consume API units." + }, + { + "name": "Rank Tracker Competitors Overview", + "description": "Get competitor rankings for the keywords tracked in an Ahrefs Rank Tracker project: each tracked keyword's volume and difficulty alongside every competitor's position, traffic, and traffic value. This endpoint is free and does not consume API units." + }, + { + "name": "Rank Tracker Competitors Stats", + "description": "Get aggregate competitor stats for an Ahrefs Rank Tracker project: each competitor's traffic, traffic value, average position, and share of voice across all tracked keywords. This endpoint is free and does not consume API units." } ], - "operationCount": 10, + "operationCount": 23, "triggers": [], "triggerCount": 0, "authType": "api-key", diff --git a/apps/sim/tools/ahrefs/anchors.ts b/apps/sim/tools/ahrefs/anchors.ts new file mode 100644 index 00000000000..f0851fbfb09 --- /dev/null +++ b/apps/sim/tools/ahrefs/anchors.ts @@ -0,0 +1,120 @@ +import type { AhrefsAnchorsParams, AhrefsAnchorsResponse } from '@/tools/ahrefs/types' +import type { ToolConfig } from '@/tools/types' + +const SELECT_FIELDS = 'anchor,links_to_target,dofollow_links,refdomains,first_seen,last_seen' + +export const anchorsTool: ToolConfig = { + id: 'ahrefs_anchors', + name: 'Ahrefs Anchors', + description: + "Get the anchor text distribution for a target domain or URL's backlinks, showing how many links and referring domains use each anchor text.", + version: '1.0.0', + + params: { + target: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + 'The target domain or URL to analyze. Example: "example.com" or "https://example.com/page"', + }, + mode: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match)', + }, + history: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Historical scope: "live" (currently live), "all_time" (default, includes lost backlinks), or "since:YYYY-MM-DD" (backlinks found since a date)', + }, + limit: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Maximum number of results to return. Example: 50 (default: 1000)', + }, + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Ahrefs API Key', + }, + }, + + request: { + url: (params) => { + const url = new URL('https://api.ahrefs.com/v3/site-explorer/anchors') + url.searchParams.set('target', params.target) + 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)) + 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 anchors') + } + + const anchors = (data.anchors || []).map((item: any) => ({ + anchor: item.anchor || '', + backlinks: item.links_to_target ?? 0, + dofollowBacklinks: item.dofollow_links ?? 0, + referringDomains: item.refdomains ?? 0, + firstSeen: item.first_seen || '', + lastSeen: item.last_seen ?? null, + })) + + return { + success: true, + output: { + anchors, + }, + } + }, + + outputs: { + anchors: { + type: 'array', + description: 'Anchor text distribution for the backlink profile', + items: { + type: 'object', + properties: { + anchor: { type: 'string', description: 'The anchor text' }, + backlinks: { type: 'number', description: 'Total backlinks using this anchor text' }, + dofollowBacklinks: { + type: 'number', + description: 'Number of dofollow backlinks using this anchor text', + }, + referringDomains: { + type: 'number', + description: 'Number of unique referring domains using this anchor text', + }, + firstSeen: { + type: 'string', + description: 'When a link with this anchor was first found', + }, + lastSeen: { + type: 'string', + description: 'When a backlink with this anchor was last seen (null if still live)', + optional: true, + }, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/ahrefs/batch_analysis.ts b/apps/sim/tools/ahrefs/batch_analysis.ts new file mode 100644 index 00000000000..585f8fd95e8 --- /dev/null +++ b/apps/sim/tools/ahrefs/batch_analysis.ts @@ -0,0 +1,165 @@ +import type { AhrefsBatchAnalysisParams, AhrefsBatchAnalysisResponse } from '@/tools/ahrefs/types' +import type { ToolConfig } from '@/tools/types' + +const SELECT_FIELDS = + 'url,domain_rating,ahrefs_rank,backlinks,refdomains,org_traffic,org_keywords,paid_traffic' + +export const batchAnalysisTool: ToolConfig = + { + id: 'ahrefs_batch_analysis', + name: 'Ahrefs Batch Analysis', + description: + 'Get bulk SEO metrics (Domain Rating, backlinks, referring domains, organic traffic, and more) for multiple domains or URLs in a single request. Useful for comparing many competitors at once.', + version: '1.0.0', + + params: { + targets: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + 'Comma-separated list of domains or URLs to analyze. Example: "example.com,competitor.com"', + }, + mode: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Analysis mode applied to every target: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match)', + }, + protocol: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Protocol applied to every target: "both" (default), "http", or "https"', + }, + country: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Country code for traffic data. Example: "us", "gb", "de" (default: "us")', + }, + volumeMode: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Search volume calculation: "monthly" or "average" (default: "monthly")', + }, + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Ahrefs API Key', + }, + }, + + request: { + url: () => 'https://api.ahrefs.com/v3/batch-analysis/batch-analysis', + method: 'POST', + headers: (params) => ({ + Accept: 'application/json', + 'Content-Type': 'application/json', + Authorization: `Bearer ${params.apiKey}`, + }), + body: (params) => { + const targets = params.targets + .split(',') + .map((target) => target.trim()) + .filter((target) => target.length > 0) + .map((url) => ({ + url, + mode: params.mode || 'subdomains', + protocol: params.protocol || 'both', + })) + + return { + select: SELECT_FIELDS.split(','), + targets, + country: params.country || 'us', + volume_mode: params.volumeMode || 'monthly', + } + }, + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error?.message || data.error || 'Failed to run batch analysis') + } + + const results = (data.targets || []).map((item: any) => ({ + url: item.url || '', + index: item.index ?? 0, + domainRating: item.domain_rating ?? null, + ahrefsRank: item.ahrefs_rank ?? null, + backlinks: item.backlinks ?? null, + referringDomains: item.refdomains ?? null, + organicTraffic: item.org_traffic ?? null, + organicKeywords: item.org_keywords ?? null, + paidTraffic: item.paid_traffic ?? null, + error: item.error ?? null, + })) + + return { + success: true, + output: { + results, + }, + } + }, + + outputs: { + results: { + type: 'array', + description: 'Bulk metrics for each analyzed target, in submission order', + items: { + type: 'object', + properties: { + url: { type: 'string', description: 'The analyzed target URL or domain' }, + index: { type: 'number', description: 'Index of the target in the submitted list' }, + domainRating: { + type: 'number', + description: 'Domain Rating score (0-100)', + optional: true, + }, + ahrefsRank: { + type: 'number', + description: 'Ahrefs Rank (global ranking)', + optional: true, + }, + backlinks: { + type: 'number', + description: 'Total backlinks to the target', + optional: true, + }, + referringDomains: { + type: 'number', + description: 'Unique domains linking to the target', + optional: true, + }, + organicTraffic: { + type: 'number', + description: 'Estimated monthly organic traffic', + optional: true, + }, + organicKeywords: { + type: 'number', + description: 'Number of organic keywords ranked (top 100)', + optional: true, + }, + paidTraffic: { + type: 'number', + description: 'Estimated monthly paid search traffic', + optional: true, + }, + error: { + type: 'string', + description: 'Error message if this target could not be analyzed', + optional: true, + }, + }, + }, + }, + }, + } diff --git a/apps/sim/tools/ahrefs/domain_rating_history.ts b/apps/sim/tools/ahrefs/domain_rating_history.ts new file mode 100644 index 00000000000..a06763e4031 --- /dev/null +++ b/apps/sim/tools/ahrefs/domain_rating_history.ts @@ -0,0 +1,100 @@ +import type { + AhrefsDomainRatingHistoryParams, + AhrefsDomainRatingHistoryResponse, +} from '@/tools/ahrefs/types' +import type { ToolConfig } from '@/tools/types' + +export const domainRatingHistoryTool: ToolConfig< + AhrefsDomainRatingHistoryParams, + AhrefsDomainRatingHistoryResponse +> = { + id: 'ahrefs_domain_rating_history', + name: 'Ahrefs Domain Rating History', + description: + 'Get the historical Domain Rating (DR) trend for a target domain or URL over a date range, grouped daily, weekly, or monthly.', + version: '1.0.0', + + params: { + target: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The target domain or URL to analyze. Example: "example.com"', + }, + dateFrom: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Start date of the historical period, in YYYY-MM-DD format', + }, + dateTo: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'End date of the historical period, in YYYY-MM-DD format (defaults to today)', + }, + historyGrouping: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Time interval for grouping data points: "daily", "weekly", or "monthly" (default: "monthly")', + }, + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Ahrefs API Key', + }, + }, + + request: { + url: (params) => { + const url = new URL('https://api.ahrefs.com/v3/site-explorer/domain-rating-history') + url.searchParams.set('target', params.target) + url.searchParams.set('date_from', params.dateFrom) + if (params.dateTo) url.searchParams.set('date_to', params.dateTo) + if (params.historyGrouping) url.searchParams.set('history_grouping', params.historyGrouping) + 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 domain rating history') + } + + const domainRatings = (data.domain_ratings || []).map((item: any) => ({ + date: item.date || '', + domainRating: item.domain_rating ?? 0, + })) + + return { + success: true, + output: { + domainRatings, + }, + } + }, + + outputs: { + domainRatings: { + type: 'array', + description: 'Historical Domain Rating data points', + items: { + type: 'object', + properties: { + date: { type: 'string', description: 'The date of the measurement' }, + domainRating: { type: 'number', description: 'Domain Rating score (0-100) on this date' }, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/ahrefs/index.ts b/apps/sim/tools/ahrefs/index.ts index cc68f7a1296..701683f334a 100644 --- a/apps/sim/tools/ahrefs/index.ts +++ b/apps/sim/tools/ahrefs/index.ts @@ -1,12 +1,25 @@ +import { anchorsTool } from '@/tools/ahrefs/anchors' import { backlinksTool } from '@/tools/ahrefs/backlinks' import { backlinksStatsTool } from '@/tools/ahrefs/backlinks_stats' +import { batchAnalysisTool } from '@/tools/ahrefs/batch_analysis' import { brokenBacklinksTool } from '@/tools/ahrefs/broken_backlinks' import { domainRatingTool } from '@/tools/ahrefs/domain_rating' +import { domainRatingHistoryTool } from '@/tools/ahrefs/domain_rating_history' import { keywordOverviewTool } from '@/tools/ahrefs/keyword_overview' +import { keywordsHistoryTool } from '@/tools/ahrefs/keywords_history' import { metricsTool } from '@/tools/ahrefs/metrics' +import { metricsHistoryTool } from '@/tools/ahrefs/metrics_history' import { organicCompetitorsTool } from '@/tools/ahrefs/organic_competitors' import { organicKeywordsTool } from '@/tools/ahrefs/organic_keywords' +import { paidPagesTool } from '@/tools/ahrefs/paid_pages' +import { rankTrackerCompetitorsOverviewTool } from '@/tools/ahrefs/rank_tracker_competitors_overview' +import { rankTrackerCompetitorsStatsTool } from '@/tools/ahrefs/rank_tracker_competitors_stats' +import { rankTrackerOverviewTool } from '@/tools/ahrefs/rank_tracker_overview' +import { rankTrackerSerpOverviewTool } from '@/tools/ahrefs/rank_tracker_serp_overview' +import { refdomainsHistoryTool } from '@/tools/ahrefs/refdomains_history' import { referringDomainsTool } from '@/tools/ahrefs/referring_domains' +import { relatedTermsTool } from '@/tools/ahrefs/related_terms' +import { siteAuditPageExplorerTool } from '@/tools/ahrefs/site_audit_page_explorer' import { topPagesTool } from '@/tools/ahrefs/top_pages' export const ahrefsDomainRatingTool = domainRatingTool @@ -19,5 +32,18 @@ export const ahrefsKeywordOverviewTool = keywordOverviewTool export const ahrefsBrokenBacklinksTool = brokenBacklinksTool export const ahrefsMetricsTool = metricsTool export const ahrefsOrganicCompetitorsTool = organicCompetitorsTool +export const ahrefsRankTrackerOverviewTool = rankTrackerOverviewTool +export const ahrefsRankTrackerSerpOverviewTool = rankTrackerSerpOverviewTool +export const ahrefsRankTrackerCompetitorsOverviewTool = rankTrackerCompetitorsOverviewTool +export const ahrefsRankTrackerCompetitorsStatsTool = rankTrackerCompetitorsStatsTool +export const ahrefsBatchAnalysisTool = batchAnalysisTool +export const ahrefsSiteAuditPageExplorerTool = siteAuditPageExplorerTool +export const ahrefsDomainRatingHistoryTool = domainRatingHistoryTool +export const ahrefsMetricsHistoryTool = metricsHistoryTool +export const ahrefsRefdomainsHistoryTool = refdomainsHistoryTool +export const ahrefsKeywordsHistoryTool = keywordsHistoryTool +export const ahrefsRelatedTermsTool = relatedTermsTool +export const ahrefsAnchorsTool = anchorsTool +export const ahrefsPaidPagesTool = paidPagesTool export * from '@/tools/ahrefs/types' diff --git a/apps/sim/tools/ahrefs/keyword_overview.ts b/apps/sim/tools/ahrefs/keyword_overview.ts index 632e8522409..3ee7c4142de 100644 --- a/apps/sim/tools/ahrefs/keyword_overview.ts +++ b/apps/sim/tools/ahrefs/keyword_overview.ts @@ -69,7 +69,7 @@ export const keywordOverviewTool: ToolConfig< keyword: result.keyword || '', searchVolume: result.volume ?? 0, keywordDifficulty: result.difficulty ?? null, - cpc: result.cpc ?? null, + cpc: typeof result.cpc === 'number' ? result.cpc / 100 : null, clicks: result.clicks ?? null, clicksPercentage: result.searches_pct_clicks_organic_only ?? null, parentTopic: result.parent_topic ?? null, diff --git a/apps/sim/tools/ahrefs/keywords_history.ts b/apps/sim/tools/ahrefs/keywords_history.ts new file mode 100644 index 00000000000..b74c852975f --- /dev/null +++ b/apps/sim/tools/ahrefs/keywords_history.ts @@ -0,0 +1,126 @@ +import type { + AhrefsKeywordsHistoryParams, + AhrefsKeywordsHistoryResponse, +} from '@/tools/ahrefs/types' +import type { ToolConfig } from '@/tools/types' + +const SELECT_FIELDS = 'date,top3,top4_10,top11_20,top21_50,top51_plus' + +export const keywordsHistoryTool: ToolConfig< + AhrefsKeywordsHistoryParams, + AhrefsKeywordsHistoryResponse +> = { + id: 'ahrefs_keywords_history', + name: 'Ahrefs Keywords History', + description: + 'Get the historical organic keyword ranking distribution for a target domain or URL over a date range: how many keywords rank in each position bucket at each point in time.', + version: '1.0.0', + + params: { + target: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The target domain or URL to analyze. Example: "example.com"', + }, + dateFrom: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Start date of the historical period, in YYYY-MM-DD format', + }, + dateTo: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'End date of the historical period, in YYYY-MM-DD format (defaults to today)', + }, + historyGrouping: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Time interval for grouping data points: "daily", "weekly", or "monthly" (default: "monthly")', + }, + country: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Country code for search results. Example: "us", "gb", "de" (default: "us")', + }, + mode: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match)', + }, + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Ahrefs API Key', + }, + }, + + request: { + url: (params) => { + const url = new URL('https://api.ahrefs.com/v3/site-explorer/keywords-history') + url.searchParams.set('target', params.target) + url.searchParams.set('date_from', params.dateFrom) + url.searchParams.set('select', SELECT_FIELDS) + if (params.historyGrouping) url.searchParams.set('history_grouping', params.historyGrouping) + if (params.dateTo) url.searchParams.set('date_to', params.dateTo) + 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 keywords history') + } + + const keywordsHistory = (data.keywords || []).map((item: any) => ({ + date: item.date || '', + top3: item.top3 ?? 0, + top4To10: item.top4_10 ?? 0, + top11To20: item.top11_20 ?? 0, + top21To50: item.top21_50 ?? 0, + top51Plus: item.top51_plus ?? 0, + })) + + return { + success: true, + output: { + keywordsHistory, + }, + } + }, + + outputs: { + keywordsHistory: { + type: 'array', + description: 'Historical organic keyword ranking distribution', + items: { + type: 'object', + properties: { + date: { type: 'string', description: 'Date of the record' }, + top3: { type: 'number', description: 'Keywords ranking in top 3 organic results' }, + top4To10: { type: 'number', description: 'Keywords ranking in positions 4-10' }, + top11To20: { type: 'number', description: 'Keywords ranking in positions 11-20' }, + top21To50: { type: 'number', description: 'Keywords ranking in positions 21-50' }, + top51Plus: { type: 'number', description: 'Keywords ranking in position 51 and beyond' }, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/ahrefs/metrics.ts b/apps/sim/tools/ahrefs/metrics.ts index 99184349d74..df71f6c8f75 100644 --- a/apps/sim/tools/ahrefs/metrics.ts +++ b/apps/sim/tools/ahrefs/metrics.ts @@ -76,11 +76,11 @@ export const metricsTool: ToolConfig organicTraffic: metrics.org_traffic ?? 0, organicKeywords: metrics.org_keywords ?? 0, organicKeywordsTop3: metrics.org_keywords_1_3 ?? 0, - organicCost: metrics.org_cost ?? null, + organicCost: typeof metrics.org_cost === 'number' ? metrics.org_cost / 100 : null, paidTraffic: metrics.paid_traffic ?? 0, paidKeywords: metrics.paid_keywords ?? 0, paidPages: metrics.paid_pages ?? 0, - paidCost: metrics.paid_cost ?? null, + paidCost: typeof metrics.paid_cost === 'number' ? metrics.paid_cost / 100 : null, }, }, } diff --git a/apps/sim/tools/ahrefs/metrics_history.ts b/apps/sim/tools/ahrefs/metrics_history.ts new file mode 100644 index 00000000000..1e30e4f6922 --- /dev/null +++ b/apps/sim/tools/ahrefs/metrics_history.ts @@ -0,0 +1,136 @@ +import type { AhrefsMetricsHistoryParams, AhrefsMetricsHistoryResponse } from '@/tools/ahrefs/types' +import type { ToolConfig } from '@/tools/types' + +const SELECT_FIELDS = 'date,org_traffic,org_cost,paid_traffic,paid_cost' + +export const metricsHistoryTool: ToolConfig< + AhrefsMetricsHistoryParams, + AhrefsMetricsHistoryResponse +> = { + id: 'ahrefs_metrics_history', + name: 'Ahrefs Metrics History', + description: + 'Get the historical organic and paid traffic trend for a target domain or URL over a date range: organic traffic/cost and paid traffic/cost at each point in time.', + version: '1.0.0', + + params: { + target: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The target domain or URL to analyze. Example: "example.com"', + }, + dateFrom: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Start date of the historical period, in YYYY-MM-DD format', + }, + dateTo: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'End date of the historical period, in YYYY-MM-DD format (defaults to today)', + }, + volumeMode: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Search volume calculation: "monthly" or "average" (default: "monthly")', + }, + historyGrouping: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Time interval for grouping data points: "daily", "weekly", or "monthly" (default: "monthly")', + }, + country: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Country code for traffic data. Example: "us", "gb", "de" (default: "us")', + }, + mode: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match)', + }, + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Ahrefs API Key', + }, + }, + + request: { + url: (params) => { + const url = new URL('https://api.ahrefs.com/v3/site-explorer/metrics-history') + url.searchParams.set('target', params.target) + url.searchParams.set('date_from', params.dateFrom) + if (params.dateTo) url.searchParams.set('date_to', params.dateTo) + url.searchParams.set('select', SELECT_FIELDS) + url.searchParams.set('volume_mode', params.volumeMode || 'monthly') + if (params.historyGrouping) url.searchParams.set('history_grouping', params.historyGrouping) + 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 history') + } + + const metrics = (data.metrics || []).map((item: any) => ({ + date: item.date || '', + organicTraffic: item.org_traffic ?? 0, + organicCost: typeof item.org_cost === 'number' ? item.org_cost / 100 : null, + paidTraffic: item.paid_traffic ?? 0, + paidCost: typeof item.paid_cost === 'number' ? item.paid_cost / 100 : null, + })) + + return { + success: true, + output: { + metricsHistory: metrics, + }, + } + }, + + outputs: { + metricsHistory: { + type: 'array', + description: 'Historical organic and paid traffic data points', + items: { + type: 'object', + properties: { + date: { type: 'string', description: 'Date of the metric entry' }, + organicTraffic: { type: 'number', description: 'Estimated monthly organic visits' }, + organicCost: { + type: 'number', + description: 'Estimated monthly cost to replicate organic traffic via ads (USD)', + optional: true, + }, + paidTraffic: { type: 'number', description: 'Estimated monthly paid search visits' }, + paidCost: { + type: 'number', + description: 'Estimated monthly paid search spend (USD)', + optional: true, + }, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/ahrefs/paid_pages.ts b/apps/sim/tools/ahrefs/paid_pages.ts new file mode 100644 index 00000000000..1ab8001ee9f --- /dev/null +++ b/apps/sim/tools/ahrefs/paid_pages.ts @@ -0,0 +1,134 @@ +import type { AhrefsPaidPagesParams, AhrefsPaidPagesResponse } from '@/tools/ahrefs/types' +import type { ToolConfig } from '@/tools/types' + +const SELECT_FIELDS = 'url,sum_traffic,keywords,top_keyword,value,ads_count' + +export const paidPagesTool: ToolConfig = { + id: 'ahrefs_paid_pages', + name: 'Ahrefs Paid Pages', + description: + "Get a target domain's pages that receive paid search traffic, sorted by estimated paid traffic. Returns page URLs with their paid traffic, keyword counts, and estimated spend.", + version: '1.0.0', + + params: { + target: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The target domain or URL to analyze. Example: "example.com"', + }, + country: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Country code for traffic data. Example: "us", "gb", "de" (default: "us")', + }, + mode: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match)', + }, + date: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Date to report metrics on, in YYYY-MM-DD format (defaults to today)', + }, + limit: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Maximum number of results to return. Example: 50 (default: 1000)', + }, + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Ahrefs API Key', + }, + }, + + request: { + url: (params) => { + const url = new URL('https://api.ahrefs.com/v3/site-explorer/paid-pages') + url.searchParams.set('target', params.target) + 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) + url.searchParams.set('country', params.country || 'us') + 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 paid pages') + } + + const paidPages = (data.pages || []).map((page: any) => ({ + url: page.url ?? null, + traffic: page.sum_traffic ?? null, + keywords: page.keywords ?? null, + topKeyword: page.top_keyword ?? null, + value: typeof page.value === 'number' ? page.value / 100 : null, + adsCount: page.ads_count ?? null, + })) + + return { + success: true, + output: { + paidPages, + }, + } + }, + + outputs: { + paidPages: { + type: 'array', + description: 'List of pages receiving paid search traffic', + items: { + type: 'object', + properties: { + url: { type: 'string', description: 'The page URL', optional: true }, + traffic: { + type: 'number', + description: 'Estimated monthly paid search traffic', + optional: true, + }, + keywords: { + type: 'number', + description: 'Number of paid keywords the page ranks for', + optional: true, + }, + topKeyword: { + type: 'string', + description: 'The top keyword driving paid traffic to this page', + optional: true, + }, + value: { + type: 'number', + description: 'Estimated monthly paid traffic cost in USD', + optional: true, + }, + adsCount: { + type: 'number', + description: 'Number of unique ads shown for this page', + optional: true, + }, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/ahrefs/rank_tracker_competitors_overview.ts b/apps/sim/tools/ahrefs/rank_tracker_competitors_overview.ts new file mode 100644 index 00000000000..a78f8e1c2a5 --- /dev/null +++ b/apps/sim/tools/ahrefs/rank_tracker_competitors_overview.ts @@ -0,0 +1,167 @@ +import type { + AhrefsRankTrackerCompetitorsOverviewParams, + AhrefsRankTrackerCompetitorsOverviewResponse, +} from '@/tools/ahrefs/types' +import type { ToolConfig } from '@/tools/types' + +const SELECT_FIELDS = 'keyword,volume,keyword_difficulty,serp_features,competitors_list' + +export const rankTrackerCompetitorsOverviewTool: ToolConfig< + AhrefsRankTrackerCompetitorsOverviewParams, + AhrefsRankTrackerCompetitorsOverviewResponse +> = { + id: 'ahrefs_rank_tracker_competitors_overview', + name: 'Ahrefs Rank Tracker Competitors Overview', + description: + "Get competitor rankings for the keywords tracked in an Ahrefs Rank Tracker project: each tracked keyword's volume and difficulty alongside every competitor's position, traffic, and traffic value. This endpoint is free and does not consume API units.", + version: '1.0.0', + + params: { + projectId: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: 'The Rank Tracker project ID (found in the project URL in Ahrefs)', + }, + date: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Date to report rankings for, in YYYY-MM-DD format', + }, + device: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Rankings device type: "desktop" or "mobile"', + }, + dateCompared: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Comparison date in YYYY-MM-DD format, to compute position/traffic deltas', + }, + volumeMode: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Search volume calculation: "monthly" or "average" (default: "monthly")', + }, + limit: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Maximum number of results to return. Example: 50 (default: 1000)', + }, + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Ahrefs API Key', + }, + }, + + request: { + url: (params) => { + const url = new URL('https://api.ahrefs.com/v3/rank-tracker/competitors-overview') + url.searchParams.set('project_id', String(params.projectId)) + url.searchParams.set('date', params.date) + url.searchParams.set('device', params.device) + url.searchParams.set('select', SELECT_FIELDS) + if (params.dateCompared) url.searchParams.set('date_compared', params.dateCompared) + url.searchParams.set('volume_mode', params.volumeMode || 'monthly') + 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 rank tracker competitors overview' + ) + } + + const competitorKeywords = (data.keywords || []).map((item: any) => ({ + keyword: item.keyword || '', + volume: item.volume ?? null, + keywordDifficulty: item.keyword_difficulty ?? null, + serpFeatures: item.serp_features ?? [], + competitorsList: (item.competitors_list || []).map((competitor: any) => ({ + url: competitor.url || '', + position: competitor.position ?? null, + bestPositionKind: competitor.best_position_kind ?? null, + traffic: competitor.traffic ?? null, + value: typeof competitor.value === 'number' ? competitor.value / 100 : null, + })), + })) + + return { + success: true, + output: { + competitorKeywords, + }, + } + }, + + outputs: { + competitorKeywords: { + type: 'array', + description: 'Tracked keywords with competitor ranking data', + items: { + type: 'object', + properties: { + keyword: { type: 'string', description: 'The tracked keyword' }, + volume: { type: 'number', description: 'Average monthly search volume', optional: true }, + keywordDifficulty: { + type: 'number', + description: 'Keyword difficulty score (0-100)', + optional: true, + }, + serpFeatures: { + type: 'array', + description: 'SERP features present in the results', + items: { type: 'string' }, + }, + competitorsList: { + type: 'array', + description: 'Ranking data for each tracked competitor on this keyword', + items: { + type: 'object', + properties: { + url: { type: 'string', description: "The competitor's ranking URL" }, + position: { + type: 'number', + description: 'Current ranking position', + optional: true, + }, + bestPositionKind: { + type: 'string', + description: 'Type of the best position achieved', + optional: true, + }, + traffic: { + type: 'number', + description: 'Estimated traffic to the competitor', + optional: true, + }, + value: { + type: 'number', + description: 'Estimated traffic value (USD)', + optional: true, + }, + }, + }, + }, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/ahrefs/rank_tracker_competitors_stats.ts b/apps/sim/tools/ahrefs/rank_tracker_competitors_stats.ts new file mode 100644 index 00000000000..d8b8ab39143 --- /dev/null +++ b/apps/sim/tools/ahrefs/rank_tracker_competitors_stats.ts @@ -0,0 +1,132 @@ +import type { + AhrefsRankTrackerCompetitorsStatsParams, + AhrefsRankTrackerCompetitorsStatsResponse, +} from '@/tools/ahrefs/types' +import type { ToolConfig } from '@/tools/types' + +const SELECT_FIELDS = + 'competitor,traffic,traffic_value,average_position,pos_1_3,pos_4_10,share_of_voice,share_of_traffic_value' + +export const rankTrackerCompetitorsStatsTool: ToolConfig< + AhrefsRankTrackerCompetitorsStatsParams, + AhrefsRankTrackerCompetitorsStatsResponse +> = { + id: 'ahrefs_rank_tracker_competitors_stats', + name: 'Ahrefs Rank Tracker Competitors Stats', + description: + "Get aggregate competitor stats for an Ahrefs Rank Tracker project: each competitor's traffic, traffic value, average position, and share of voice across all tracked keywords. This endpoint is free and does not consume API units.", + version: '1.0.0', + + params: { + projectId: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: 'The Rank Tracker project ID (found in the project URL in Ahrefs)', + }, + date: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Date to report metrics for, in YYYY-MM-DD format', + }, + device: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Rankings device type: "desktop" or "mobile"', + }, + volumeMode: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Search volume calculation: "monthly" or "average" (default: "monthly")', + }, + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Ahrefs API Key', + }, + }, + + request: { + url: (params) => { + const url = new URL('https://api.ahrefs.com/v3/rank-tracker/competitors-stats') + url.searchParams.set('select', SELECT_FIELDS) + url.searchParams.set('date', params.date) + url.searchParams.set('device', params.device) + url.searchParams.set('project_id', String(params.projectId)) + url.searchParams.set('volume_mode', params.volumeMode || 'monthly') + 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 rank tracker competitors stats' + ) + } + + const competitorsStats = (data['competitors-metrics'] || []).map((item: any) => ({ + competitor: item.competitor || '', + traffic: item.traffic ?? null, + trafficValue: typeof item.traffic_value === 'number' ? item.traffic_value / 100 : null, + averagePosition: item.average_position ?? null, + pos1To3: item.pos_1_3 ?? 0, + pos4To10: item.pos_4_10 ?? 0, + shareOfVoice: item.share_of_voice ?? 0, + shareOfTrafficValue: item.share_of_traffic_value ?? 0, + })) + + return { + success: true, + output: { + competitorsStats, + }, + } + }, + + outputs: { + competitorsStats: { + type: 'array', + description: 'Aggregate stats for each tracked competitor', + items: { + type: 'object', + properties: { + competitor: { type: 'string', description: "The competitor's URL" }, + traffic: { + type: 'number', + description: 'Estimated monthly organic visits', + optional: true, + }, + trafficValue: { + type: 'number', + description: 'Estimated monthly organic traffic value (USD)', + optional: true, + }, + averagePosition: { + type: 'number', + description: 'Average top organic position across tracked keywords', + optional: true, + }, + pos1To3: { type: 'number', description: 'Keywords ranking in top 3 positions' }, + pos4To10: { type: 'number', description: 'Keywords ranking in positions 4-10' }, + shareOfVoice: { type: 'number', description: 'Organic traffic share percentage' }, + shareOfTrafficValue: { + type: 'number', + description: 'Organic traffic value share percentage', + }, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/ahrefs/rank_tracker_overview.ts b/apps/sim/tools/ahrefs/rank_tracker_overview.ts new file mode 100644 index 00000000000..a0105be81fc --- /dev/null +++ b/apps/sim/tools/ahrefs/rank_tracker_overview.ts @@ -0,0 +1,149 @@ +import type { + AhrefsRankTrackerOverviewParams, + AhrefsRankTrackerOverviewResponse, +} from '@/tools/ahrefs/types' +import type { ToolConfig } from '@/tools/types' + +const SELECT_FIELDS = + 'keyword,position,volume,keyword_difficulty,url,traffic,serp_features,best_position_kind' + +export const rankTrackerOverviewTool: ToolConfig< + AhrefsRankTrackerOverviewParams, + AhrefsRankTrackerOverviewResponse +> = { + id: 'ahrefs_rank_tracker_overview', + name: 'Ahrefs Rank Tracker Overview', + description: + 'Get ranking overview metrics for the keywords tracked in an Ahrefs Rank Tracker project: position, search volume, keyword difficulty, and estimated traffic. This endpoint is free and does not consume API units.', + version: '1.0.0', + + params: { + projectId: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: 'The Rank Tracker project ID (found in the project URL in Ahrefs)', + }, + date: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Date to report rankings for, in YYYY-MM-DD format', + }, + device: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Rankings device type: "desktop" or "mobile"', + }, + dateCompared: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Comparison date in YYYY-MM-DD format, to compute position/traffic deltas', + }, + volumeMode: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Search volume calculation: "monthly" or "average" (default: "monthly")', + }, + limit: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Maximum number of results to return. Example: 50 (default: 1000)', + }, + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Ahrefs API Key', + }, + }, + + request: { + url: (params) => { + const url = new URL('https://api.ahrefs.com/v3/rank-tracker/overview') + url.searchParams.set('project_id', String(params.projectId)) + url.searchParams.set('date', params.date) + url.searchParams.set('device', params.device) + url.searchParams.set('select', SELECT_FIELDS) + if (params.dateCompared) url.searchParams.set('date_compared', params.dateCompared) + url.searchParams.set('volume_mode', params.volumeMode || 'monthly') + 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 rank tracker overview') + } + + const overviews = (data.overviews || []).map((item: any) => ({ + keyword: item.keyword || '', + position: item.position ?? null, + volume: item.volume ?? null, + keywordDifficulty: item.keyword_difficulty ?? null, + url: item.url ?? null, + traffic: item.traffic ?? null, + serpFeatures: item.serp_features ?? [], + bestPositionKind: item.best_position_kind ?? null, + })) + + return { + success: true, + output: { + overviews, + }, + } + }, + + outputs: { + overviews: { + type: 'array', + description: 'Ranking overview for each tracked keyword', + items: { + type: 'object', + properties: { + keyword: { type: 'string', description: 'The tracked keyword' }, + position: { + type: 'number', + description: 'Top organic search position', + optional: true, + }, + volume: { type: 'number', description: 'Average monthly search volume', optional: true }, + keywordDifficulty: { + type: 'number', + description: 'Keyword difficulty score (0-100)', + optional: true, + }, + url: { type: 'string', description: 'Top-ranking URL', optional: true }, + traffic: { + type: 'number', + description: 'Estimated monthly organic visits', + optional: true, + }, + serpFeatures: { + type: 'array', + description: 'SERP features present in the results', + items: { type: 'string' }, + }, + bestPositionKind: { + type: 'string', + description: 'Type of the top position (organic, paid, or SERP feature)', + optional: true, + }, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/ahrefs/rank_tracker_serp_overview.ts b/apps/sim/tools/ahrefs/rank_tracker_serp_overview.ts new file mode 100644 index 00000000000..a1b72f7de04 --- /dev/null +++ b/apps/sim/tools/ahrefs/rank_tracker_serp_overview.ts @@ -0,0 +1,166 @@ +import type { + AhrefsRankTrackerSerpOverviewParams, + AhrefsRankTrackerSerpOverviewResponse, +} from '@/tools/ahrefs/types' +import type { ToolConfig } from '@/tools/types' + +export const rankTrackerSerpOverviewTool: ToolConfig< + AhrefsRankTrackerSerpOverviewParams, + AhrefsRankTrackerSerpOverviewResponse +> = { + id: 'ahrefs_rank_tracker_serp_overview', + name: 'Ahrefs Rank Tracker SERP Overview', + description: + 'Get the full SERP (search engine results page) for a keyword tracked in an Ahrefs Rank Tracker project, including every ranking URL with its position, title, and authority metrics. This endpoint is free and does not consume API units.', + version: '1.0.0', + + params: { + projectId: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: 'The Rank Tracker project ID (found in the project URL in Ahrefs)', + }, + keyword: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The tracked keyword to retrieve SERP data for', + }, + country: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Country code for the tracked keyword. Example: "us", "gb", "de"', + }, + device: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Rankings device type: "desktop" or "mobile"', + }, + topPositions: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Number of top organic positions to return (defaults to all available)', + }, + date: { + type: 'string', + required: false, + visibility: 'user-only', + description: + 'Timestamp to return the last available SERP Overview at, in YYYY-MM-DDThh:mm:ss format', + }, + locationId: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Location ID of the tracked keyword, if tracked at a specific location', + }, + languageCode: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Language code of the tracked keyword', + }, + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Ahrefs API Key', + }, + }, + + request: { + url: (params) => { + const url = new URL('https://api.ahrefs.com/v3/rank-tracker/serp-overview') + url.searchParams.set('project_id', String(params.projectId)) + url.searchParams.set('keyword', params.keyword) + url.searchParams.set('country', params.country) + url.searchParams.set('device', params.device) + if (params.topPositions) url.searchParams.set('top_positions', String(params.topPositions)) + if (params.date) url.searchParams.set('date', params.date) + if (params.locationId) url.searchParams.set('location_id', String(params.locationId)) + if (params.languageCode) url.searchParams.set('language_code', params.languageCode) + 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 SERP overview') + } + + const positions = (data.positions || []).map((item: any) => ({ + position: item.position ?? 0, + url: item.url || '', + title: item.title || '', + type: item.type ?? [], + domainRating: item.domain_rating ?? 0, + urlRating: item.url_rating ?? 0, + backlinks: item.backlinks ?? 0, + refdomains: item.refdomains ?? 0, + traffic: item.traffic ?? 0, + value: typeof item.value === 'number' ? item.value / 100 : null, + topKeyword: item.top_keyword ?? null, + topKeywordVolume: item.top_keyword_volume ?? null, + updateDate: item.update_date || '', + })) + + return { + success: true, + output: { + positions, + }, + } + }, + + outputs: { + positions: { + type: 'array', + description: 'Every ranking result on the SERP for the tracked keyword', + items: { + type: 'object', + properties: { + position: { type: 'number', description: 'Position of the result in the SERP' }, + url: { type: 'string', description: 'URL of the ranking page' }, + title: { type: 'string', description: 'Page title' }, + type: { + type: 'array', + description: 'The kind of the position: organic, paid, or a SERP feature', + items: { type: 'string' }, + }, + domainRating: { type: 'number', description: 'Domain Rating of the ranking domain' }, + urlRating: { type: 'number', description: 'URL Rating of the ranking page' }, + backlinks: { type: 'number', description: 'Total backlinks to the ranking domain' }, + refdomains: { type: 'number', description: 'Unique referring domains' }, + traffic: { type: 'number', description: 'Estimated monthly organic search traffic' }, + value: { + type: 'number', + description: 'Estimated monthly traffic value (USD)', + optional: true, + }, + topKeyword: { + type: 'string', + description: 'Highest-traffic keyword ranking for this page', + optional: true, + }, + topKeywordVolume: { + type: 'number', + description: 'Monthly search volume for the top keyword', + optional: true, + }, + updateDate: { type: 'string', description: 'Date the SERP was last checked' }, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/ahrefs/refdomains_history.ts b/apps/sim/tools/ahrefs/refdomains_history.ts new file mode 100644 index 00000000000..a2bcc993196 --- /dev/null +++ b/apps/sim/tools/ahrefs/refdomains_history.ts @@ -0,0 +1,113 @@ +import type { + AhrefsRefdomainsHistoryParams, + AhrefsRefdomainsHistoryResponse, +} from '@/tools/ahrefs/types' +import type { ToolConfig } from '@/tools/types' + +export const refdomainsHistoryTool: ToolConfig< + AhrefsRefdomainsHistoryParams, + AhrefsRefdomainsHistoryResponse +> = { + id: 'ahrefs_refdomains_history', + name: 'Ahrefs Referring Domains History', + description: + 'Get the historical referring domains trend for a target domain or URL over a date range, grouped daily, weekly, or monthly.', + version: '1.0.0', + + params: { + target: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The target domain or URL to analyze. Example: "example.com"', + }, + dateFrom: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Start date of the historical period, in YYYY-MM-DD format', + }, + dateTo: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'End date of the historical period, in YYYY-MM-DD format (defaults to today)', + }, + historyGrouping: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Time interval for grouping data points: "daily", "weekly", or "monthly" (default: "monthly")', + }, + mode: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match)', + }, + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Ahrefs API Key', + }, + }, + + request: { + url: (params) => { + const url = new URL('https://api.ahrefs.com/v3/site-explorer/refdomains-history') + url.searchParams.set('target', params.target) + url.searchParams.set('date_from', params.dateFrom) + if (params.dateTo) url.searchParams.set('date_to', params.dateTo) + if (params.historyGrouping) url.searchParams.set('history_grouping', params.historyGrouping) + 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 referring domains history' + ) + } + + const referringDomainsHistory = (data.refdomains || []).map((item: any) => ({ + date: item.date || '', + referringDomains: item.refdomains ?? 0, + })) + + return { + success: true, + output: { + referringDomainsHistory, + }, + } + }, + + outputs: { + referringDomainsHistory: { + type: 'array', + description: 'Historical referring domains count data points', + items: { + type: 'object', + properties: { + date: { type: 'string', description: 'The date of the data point' }, + referringDomains: { + type: 'number', + description: 'Total number of unique domains linking to the target on this date', + }, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/ahrefs/related_terms.ts b/apps/sim/tools/ahrefs/related_terms.ts new file mode 100644 index 00000000000..3b5c7626ec3 --- /dev/null +++ b/apps/sim/tools/ahrefs/related_terms.ts @@ -0,0 +1,139 @@ +import type { AhrefsRelatedTermsParams, AhrefsRelatedTermsResponse } from '@/tools/ahrefs/types' +import type { ToolConfig } from '@/tools/types' + +const SELECT_FIELDS = + 'keyword,volume,difficulty,cpc,parent_topic,traffic_potential,intents,serp_features' + +export const relatedTermsTool: ToolConfig = { + id: 'ahrefs_related_terms', + name: 'Ahrefs Related Terms', + description: + 'Get keyword ideas related to a seed keyword: terms the same top-ranking pages also rank for ("also rank for") or also discuss ("also talk about"), with volume, difficulty, and CPC.', + version: '1.0.0', + + params: { + keyword: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The seed keyword to find related terms for', + }, + country: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Country code for keyword data. Example: "us", "gb", "de" (default: "us")', + }, + terms: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Type of related keywords to return: "also_rank_for", "also_talk_about", or "all" (default: "all")', + }, + viewFor: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Whether to derive related terms from the top 10 or top 100 ranking pages (default: "top_10")', + }, + limit: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Maximum number of results to return. Example: 50 (default: 1000)', + }, + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Ahrefs API Key', + }, + }, + + request: { + url: (params) => { + const url = new URL('https://api.ahrefs.com/v3/keywords-explorer/related-terms') + url.searchParams.set('select', SELECT_FIELDS) + url.searchParams.set('country', params.country || 'us') + url.searchParams.set('keywords', params.keyword) + if (params.terms) url.searchParams.set('terms', params.terms) + if (params.viewFor) url.searchParams.set('view_for', params.viewFor) + 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 related terms') + } + + const relatedTerms = (data.keywords || []).map((item: any) => ({ + keyword: item.keyword || '', + volume: item.volume ?? null, + keywordDifficulty: item.difficulty ?? null, + cpc: typeof item.cpc === 'number' ? item.cpc / 100 : null, + parentTopic: item.parent_topic ?? null, + trafficPotential: item.traffic_potential ?? null, + intents: item.intents ?? null, + serpFeatures: item.serp_features ?? [], + })) + + return { + success: true, + output: { + relatedTerms, + }, + } + }, + + outputs: { + relatedTerms: { + type: 'array', + description: 'Related keyword ideas for the seed keyword', + items: { + type: 'object', + properties: { + keyword: { type: 'string', description: 'The related keyword' }, + volume: { type: 'number', description: 'Average monthly search volume', optional: true }, + keywordDifficulty: { + type: 'number', + description: 'Keyword difficulty score (0-100)', + optional: true, + }, + cpc: { type: 'number', description: 'Cost per click in USD', optional: true }, + parentTopic: { + type: 'string', + description: 'The parent topic for this keyword', + optional: true, + }, + 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, + }, + serpFeatures: { + type: 'array', + description: 'SERP features present in the results', + items: { type: 'string' }, + }, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/ahrefs/site_audit_page_explorer.ts b/apps/sim/tools/ahrefs/site_audit_page_explorer.ts new file mode 100644 index 00000000000..76ef8c70ac1 --- /dev/null +++ b/apps/sim/tools/ahrefs/site_audit_page_explorer.ts @@ -0,0 +1,142 @@ +import type { + AhrefsSiteAuditPageExplorerParams, + AhrefsSiteAuditPageExplorerResponse, +} from '@/tools/ahrefs/types' +import type { ToolConfig } from '@/tools/types' + +const SELECT_FIELDS = + 'url,http_code,title,internal_links,external_links,backlinks,compliant,traffic' + +export const siteAuditPageExplorerTool: ToolConfig< + AhrefsSiteAuditPageExplorerParams, + AhrefsSiteAuditPageExplorerResponse +> = { + id: 'ahrefs_site_audit_page_explorer', + name: 'Ahrefs Site Audit Page Explorer', + description: + 'Get crawled pages from an Ahrefs Site Audit project with health and SEO metrics: HTTP status, title, link counts, backlinks, indexability, and traffic. Optionally filter to pages affected by a specific issue.', + version: '1.0.0', + + params: { + projectId: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: 'The Site Audit project ID (found in the project URL in Ahrefs)', + }, + date: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Crawl date in YYYY-MM-DDThh:mm:ss format (defaults to the most recent crawl)', + }, + limit: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Maximum number of results to return. Example: 50 (default: 1000)', + }, + offset: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Number of results to skip, for pagination', + }, + issueId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Only return pages affected by this issue ID', + }, + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Ahrefs API Key', + }, + }, + + request: { + url: (params) => { + const url = new URL('https://api.ahrefs.com/v3/site-audit/page-explorer') + url.searchParams.set('project_id', String(params.projectId)) + url.searchParams.set('select', SELECT_FIELDS) + if (params.date) url.searchParams.set('date', params.date) + if (params.limit) url.searchParams.set('limit', String(params.limit)) + if (params.offset) url.searchParams.set('offset', String(params.offset)) + if (params.issueId) url.searchParams.set('issue_id', params.issueId) + 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 site audit pages') + } + + const auditPages = (data.pages || []).map((page: any) => ({ + url: page.url || '', + httpCode: page.http_code ?? null, + title: page.title ?? [], + internalLinks: (page.internal_links || []).length, + externalLinks: (page.external_links || []).length, + backlinks: page.backlinks ?? null, + compliant: page.compliant ?? null, + traffic: page.traffic ?? null, + })) + + return { + success: true, + output: { + auditPages, + }, + } + }, + + outputs: { + auditPages: { + type: 'array', + description: 'List of crawled pages with health and SEO metrics', + items: { + type: 'object', + properties: { + url: { type: 'string', description: 'The crawled page URL' }, + httpCode: { + type: 'number', + description: 'HTTP status code returned by the URL', + optional: true, + }, + title: { + type: 'array', + description: 'Page title tag(s)', + items: { type: 'string' }, + }, + internalLinks: { type: 'number', description: 'Number of internal outgoing links' }, + externalLinks: { type: 'number', description: 'Number of external outgoing links' }, + backlinks: { + type: 'number', + description: 'Number of incoming external links to the page', + optional: true, + }, + compliant: { + type: 'boolean', + description: 'Whether the page is indexable (200 status, no canonical/noindex)', + optional: true, + }, + traffic: { + type: 'number', + description: 'Estimated monthly organic traffic to the page', + optional: true, + }, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/ahrefs/top_pages.ts b/apps/sim/tools/ahrefs/top_pages.ts index 9feb05c40b6..8c0b6e0f22a 100644 --- a/apps/sim/tools/ahrefs/top_pages.ts +++ b/apps/sim/tools/ahrefs/top_pages.ts @@ -28,7 +28,7 @@ export const topPagesTool: ToolConfig | null + serpFeatures: string[] +} + +export interface AhrefsRelatedTermsResponse extends ToolResponse { + output: { + relatedTerms: AhrefsRelatedTerm[] + } +} + +// Anchors tool types +export interface AhrefsAnchorsParams extends AhrefsBaseParams { + target: string + mode?: AhrefsTargetMode + history?: AhrefsHistory + limit?: number +} + +interface AhrefsAnchor { + anchor: string + backlinks: number + dofollowBacklinks: number + referringDomains: number + firstSeen: string + lastSeen: string | null +} + +export interface AhrefsAnchorsResponse extends ToolResponse { + output: { + anchors: AhrefsAnchor[] + } +} + +// Paid Pages tool types +export interface AhrefsPaidPagesParams extends AhrefsBaseParams { + target: string + country?: string + mode?: AhrefsTargetMode + date?: string // Date in YYYY-MM-DD format, defaults to today + limit?: number +} + +interface AhrefsPaidPage { + url: string | null + traffic: number | null + keywords: number | null + topKeyword: string | null + value: number | null + adsCount: number | null +} + +export interface AhrefsPaidPagesResponse extends ToolResponse { + output: { + paidPages: AhrefsPaidPage[] + } +} + // Union type for all possible responses export type AhrefsResponse = | AhrefsDomainRatingResponse @@ -254,3 +594,16 @@ export type AhrefsResponse = | AhrefsBrokenBacklinksResponse | AhrefsMetricsResponse | AhrefsOrganicCompetitorsResponse + | AhrefsRankTrackerOverviewResponse + | AhrefsRankTrackerSerpOverviewResponse + | AhrefsRankTrackerCompetitorsOverviewResponse + | AhrefsRankTrackerCompetitorsStatsResponse + | AhrefsBatchAnalysisResponse + | AhrefsSiteAuditPageExplorerResponse + | AhrefsDomainRatingHistoryResponse + | AhrefsMetricsHistoryResponse + | AhrefsRefdomainsHistoryResponse + | AhrefsKeywordsHistoryResponse + | AhrefsRelatedTermsResponse + | AhrefsAnchorsResponse + | AhrefsPaidPagesResponse diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index fc052e9a56d..c801c3b979a 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -67,15 +67,28 @@ import { agiloftUpdateRecordTool, } from '@/tools/agiloft' import { + ahrefsAnchorsTool, ahrefsBacklinksStatsTool, ahrefsBacklinksTool, + ahrefsBatchAnalysisTool, ahrefsBrokenBacklinksTool, + ahrefsDomainRatingHistoryTool, ahrefsDomainRatingTool, ahrefsKeywordOverviewTool, + ahrefsKeywordsHistoryTool, + ahrefsMetricsHistoryTool, ahrefsMetricsTool, ahrefsOrganicCompetitorsTool, ahrefsOrganicKeywordsTool, + ahrefsPaidPagesTool, + ahrefsRankTrackerCompetitorsOverviewTool, + ahrefsRankTrackerCompetitorsStatsTool, + ahrefsRankTrackerOverviewTool, + ahrefsRankTrackerSerpOverviewTool, + ahrefsRefdomainsHistoryTool, ahrefsReferringDomainsTool, + ahrefsRelatedTermsTool, + ahrefsSiteAuditPageExplorerTool, ahrefsTopPagesTool, } from '@/tools/ahrefs' import { @@ -6825,15 +6838,28 @@ export const tools: Record = { attio_update_record: attioUpdateRecordTool, attio_update_task: attioUpdateTaskTool, attio_update_webhook: attioUpdateWebhookTool, + ahrefs_anchors: ahrefsAnchorsTool, ahrefs_backlinks: ahrefsBacklinksTool, ahrefs_backlinks_stats: ahrefsBacklinksStatsTool, + ahrefs_batch_analysis: ahrefsBatchAnalysisTool, ahrefs_broken_backlinks: ahrefsBrokenBacklinksTool, ahrefs_domain_rating: ahrefsDomainRatingTool, + ahrefs_domain_rating_history: ahrefsDomainRatingHistoryTool, ahrefs_keyword_overview: ahrefsKeywordOverviewTool, + ahrefs_keywords_history: ahrefsKeywordsHistoryTool, ahrefs_metrics: ahrefsMetricsTool, + ahrefs_metrics_history: ahrefsMetricsHistoryTool, ahrefs_organic_competitors: ahrefsOrganicCompetitorsTool, ahrefs_organic_keywords: ahrefsOrganicKeywordsTool, + ahrefs_paid_pages: ahrefsPaidPagesTool, + ahrefs_rank_tracker_competitors_overview: ahrefsRankTrackerCompetitorsOverviewTool, + ahrefs_rank_tracker_competitors_stats: ahrefsRankTrackerCompetitorsStatsTool, + ahrefs_rank_tracker_overview: ahrefsRankTrackerOverviewTool, + ahrefs_rank_tracker_serp_overview: ahrefsRankTrackerSerpOverviewTool, + ahrefs_refdomains_history: ahrefsRefdomainsHistoryTool, ahrefs_referring_domains: ahrefsReferringDomainsTool, + ahrefs_related_terms: ahrefsRelatedTermsTool, + ahrefs_site_audit_page_explorer: ahrefsSiteAuditPageExplorerTool, ahrefs_top_pages: ahrefsTopPagesTool, apify_run_actor_sync: apifyRunActorSyncTool, apify_run_actor_async: apifyRunActorAsyncTool, From aeddfdd9e21eadba7cb372c69de1e7c8f49e2d92 Mon Sep 17 00:00:00 2001 From: Waleed Date: Mon, 6 Jul 2026 16:27:13 -0700 Subject: [PATCH 21/35] fix(comparison): correct misleading pricing contrast on n8n page (#5451) n8n's pricing standout feature implied a per-step pricing contrast that doesn't hold up; reframe around the verified unlimited-seats difference. --- apps/sim/lib/compare/data/competitors/n8n.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/sim/lib/compare/data/competitors/n8n.ts b/apps/sim/lib/compare/data/competitors/n8n.ts index 75a56a31c48..db7e407d145 100644 --- a/apps/sim/lib/compare/data/competitors/n8n.ts +++ b/apps/sim/lib/compare/data/competitors/n8n.ts @@ -32,10 +32,10 @@ export const n8nProfile: CompetitorProfile = { 'n8n is a fair-code workflow automation platform combining a visual, node-based builder with custom code and built-in AI/agent nodes, available as a self-hosted or cloud-hosted product.', standoutFeatures: [ { - title: 'Execution-based pricing, not per-step or per-seat', + title: 'Unlimited users on every plan, including the entry tier', description: - 'n8n bills by monthly workflow executions, not by operation, step, or user seat. A full run start-to-finish counts once, no matter how many nodes it contains, and unlimited users are included even on the Starter plan.', - shortDescription: 'Bills by monthly executions, not steps or seats, with unlimited users.', + 'n8n includes unlimited users on every plan, including the €20/month Starter tier, and bills by monthly workflow executions rather than by user seat: a full run start-to-finish counts once no matter how many nodes it contains.', + shortDescription: 'Unlimited users on every tier, billed by monthly executions, not by seat.', source: { url: 'https://n8n.io/pricing/', label: 'n8n Pricing', asOf: '2026-07-02' }, }, { @@ -767,9 +767,9 @@ export const n8nProfile: CompetitorProfile = { }, pricing: { pricingModel: { - value: 'Per-workflow-execution pricing tiers (not per step, not per seat)', + value: 'Per-workflow-execution pricing tiers, unlimited users at every tier', detail: - "n8n's pricing page states workflows are billed by monthly execution count. A workflow that runs start-to-finish counts as one execution regardless of the number of steps or nodes, explicitly contrasted against competitors that charge per step or per user. Unlimited users are included at every paid tier.", + "n8n's pricing page states workflows are billed by monthly execution count. A workflow that runs start-to-finish counts as one execution regardless of the number of steps or nodes. Unlimited users are included at every paid tier.", shortValue: 'Billed by monthly workflow executions', confidence: 'verified', sources: [{ url: 'https://n8n.io/pricing/', label: 'n8n Pricing', asOf: '2026-07-02' }], From a50b373e9f865193ddd756551563ed7b6fa651cb Mon Sep 17 00:00:00 2001 From: Waleed Date: Mon, 6 Jul 2026 16:52:27 -0700 Subject: [PATCH 22/35] fix(comparison): correct contrast claims across competitor pages (#5452) * fix(comparison): correct false-contrast claims across competitor pages Audited every Sim-vs-competitor page against Sim's own facts; fixed standout/limitation claims that implicitly overstated a Sim gap that doesn't actually exist, plus a few stale or under-sourced facts. * fix(comparison): address review feedback on wording precision Clarify the pipedream acquisition-status claim, fix circular OIDC phrasing, correct a source/claim mismatch, and remove a stale SOC2 implication. * fix(comparison): fix issues introduced by the previous fix pass Second independent audit pass on the fixed pages caught a few new problems from the edits themselves: a stale/mismatched source, an internal contradiction, an unverified superlative, a still-dominant false-contrast paragraph, a broken repo link, an editorializing note leaking into shipped copy, and a duplicated/grammatically broken entry. * fix(comparison): qualify self-reported Series B figure in value field The confidence downgrade to 'estimated' wasn't reflected in the value field itself, which still stated the Series B/total funding as plain fact. --- .../lib/compare/data/competitors/crewai.ts | 22 +++------ apps/sim/lib/compare/data/competitors/dust.ts | 29 ++++++------ .../lib/compare/data/competitors/flowise.ts | 16 +++---- .../lib/compare/data/competitors/gumloop.ts | 34 +++++++------- .../lib/compare/data/competitors/langchain.ts | 28 +---------- .../lib/compare/data/competitors/langflow.ts | 7 +-- apps/sim/lib/compare/data/competitors/make.ts | 44 +++++------------- .../data/competitors/microsoft-copilot.ts | 17 ++----- apps/sim/lib/compare/data/competitors/n8n.ts | 29 +++--------- .../data/competitors/openai-agentkit.ts | 16 +++---- .../lib/compare/data/competitors/openclaw.ts | 15 +++--- .../lib/compare/data/competitors/pipedream.ts | 33 +++++-------- .../data/competitors/power-automate.ts | 18 ++++---- .../lib/compare/data/competitors/retool.ts | 12 ++--- .../lib/compare/data/competitors/stackai.ts | 25 +++------- .../sim/lib/compare/data/competitors/tines.ts | 37 ++++++--------- .../lib/compare/data/competitors/vellum.ts | 46 +++++-------------- .../lib/compare/data/competitors/workato.ts | 26 ++++++++--- .../lib/compare/data/competitors/zapier.ts | 20 ++------ 19 files changed, 171 insertions(+), 303 deletions(-) diff --git a/apps/sim/lib/compare/data/competitors/crewai.ts b/apps/sim/lib/compare/data/competitors/crewai.ts index ea230014aaa..6924cb95276 100644 --- a/apps/sim/lib/compare/data/competitors/crewai.ts +++ b/apps/sim/lib/compare/data/competitors/crewai.ts @@ -51,10 +51,11 @@ export const crewaiProfile: CompetitorProfile = { }, }, { - title: 'Native Agent2Agent (A2A) protocol support as a first-class primitive', + title: 'Acts as both an A2A client and an A2A server', description: - 'CrewAI treats the open Agent2Agent (A2A) protocol as a first-class delegation primitive: agents can be configured with an A2AClientConfig to delegate tasks to and request information from remote A2A-compliant agents (with Bearer, OAuth2, API key, or HTTP auth), and/or an A2AServerConfig to expose a CrewAI agent as an A2A-compliant server other frameworks can call, via the optional crewai[a2a] extra.', - shortDescription: 'Delegates to and serves as remote agents via the open A2A protocol.', + 'CrewAI treats the open Agent2Agent (A2A) protocol as a first-class delegation primitive: agents can be configured with an A2AClientConfig to delegate tasks to and request information from remote A2A-compliant agents (with Bearer, OAuth2, API key, or HTTP auth), and/or an A2AServerConfig to expose a CrewAI agent as an A2A-compliant server other frameworks can call, via the optional crewai[a2a] extra. Sim ships a dedicated A2A block that calls, tracks, and discovers external A2A-compliant agents, but does not document a way to expose a Sim workflow as an A2A server of its own.', + shortDescription: + "Delegates to remote A2A agents and can expose a crew as an A2A server; Sim's A2A block only calls out to external agents.", source: { url: 'https://docs.crewai.com/en/learn/a2a-agent-delegation', label: 'Agent-to-Agent (A2A) Protocol - CrewAI Docs', @@ -64,9 +65,9 @@ export const crewaiProfile: CompetitorProfile = { { title: 'CrewAI AMP: natural-language visual Studio on top of the code framework', description: - 'CrewAI AMP (the commercial Agent Management Platform) adds Crew Studio, a chat-and-canvas interface where a builder describes an automation in natural language and the AI generates agents, tasks, and tools as an editable drag-and-drop workflow, exportable to Python code. This gives the code-first framework an optional visual entry point for non-developers.', + 'CrewAI AMP (the commercial Agent Management Platform) adds Crew Studio, a chat-and-canvas interface where a builder describes an automation in natural language and the AI generates agents, tasks, and tools as an editable drag-and-drop workflow, exportable to Python code. This gives the code-first framework an optional visual entry point for non-developers. Sim ships an equivalent natural-language builder (Chat and in-editor Copilot) as a core, free part of the product, not a separate paid add-on layered on top of a code-only open-source base.', shortDescription: - 'Natural-language chat generates an editable visual workflow, exportable to code.', + "Natural-language chat generates an editable visual workflow, exportable to code, as a paid AMP add-on; Sim's Chat and Copilot ship the same capability free.", source: { url: 'https://docs.crewai.com/en/enterprise/features/crew-studio', label: 'Crew Studio - CrewAI Docs', @@ -110,17 +111,6 @@ export const crewaiProfile: CompetitorProfile = { asOf: '2026-07-02', }, }, - { - title: 'Native vector store support limited to two backends', - description: - "CrewAI's built-in RAG/knowledge system ships native support for only ChromaDB (the default) and Qdrant as vector store backends. Broader coverage (Pinecone, PGVector, Supabase, etc.) requires custom integration work, not a documented first-party connector.", - shortDescription: 'Native knowledge/RAG vector stores are limited to ChromaDB and Qdrant.', - source: { - url: 'https://docs.crewai.com/en/concepts/knowledge', - label: 'Knowledge - CrewAI Docs', - asOf: '2026-07-02', - }, - }, { title: 'Requires Python fluency; no low-code entry point in the core product', description: diff --git a/apps/sim/lib/compare/data/competitors/dust.ts b/apps/sim/lib/compare/data/competitors/dust.ts index a93120f9540..3e1bb4ed63d 100644 --- a/apps/sim/lib/compare/data/competitors/dust.ts +++ b/apps/sim/lib/compare/data/competitors/dust.ts @@ -17,10 +17,11 @@ export const dustProfile: CompetitorProfile = { 'Dust is an enterprise AI agent platform where teams build no-code agents connected to company data and tools in a shared, multiplayer workspace, then deploy them to chat, Slack, and other surfaces.', standoutFeatures: [ { - title: 'Purely no-code, form-based builder for non-technical teams', + title: + 'Zero visual/flow layer by design, building only through forms, text, and conversation', description: - "Dust's Agent Builder is entirely form and text based, name, description, instructions, model, tools, knowledge, guided by a conversational 'Sidekick' assistant, with no visual canvas at all (its earlier block-based 'Dust Apps' product is deprecated). Agents deploy natively into a shared, multiplayer workspace and out to Slack, Teams, and other chat surfaces. A team that wants business users assembling agents from plain-language instructions and templates, with no drag-and-drop layer to learn, gets that directly. Teams that do want infrastructure-as-code can also define Skills and agent configurations as files in a Git repository and sync them via an official GitHub Action, with the same PR review and rollback workflow as application code.", - shortDescription: 'No-code, form-based builder for business teams, no visual canvas at all.', + "Dust's Agent Builder is entirely form and text based, name, description, instructions, model, tools, knowledge, guided by a conversational 'Sidekick' assistant, with no visual canvas at all (its earlier block-based 'Dust Apps' product is deprecated). Agents deploy natively into a shared, multiplayer workspace and out to Slack, Teams, and other chat surfaces. A team that wants agents assembled purely from plain-language instructions and templates, with no drag-and-drop layer to learn or maintain, gets that directly. Teams that do want infrastructure-as-code can also define Skills and agent configurations as files in a Git repository and sync them via an official GitHub Action, with the same PR review and rollback workflow as application code.", + shortDescription: 'No visual/flow canvas at all, only forms, text, and conversation.', source: { url: 'https://docs.dust.tt/changelog/gitops-sync-for-skills-agent-configurations-with-github-action', label: 'GitOps sync for Skills & Agent configurations | Dust changelog', @@ -28,11 +29,10 @@ export const dustProfile: CompetitorProfile = { }, }, { - title: "'Skills' as reusable, shared agent instruction/tool packages", + title: "'Skills' can attach to many agents at once, with one edit propagating to all of them", description: - "Skills are named, reusable packages of instructions, knowledge, and tools that can be attached to multiple agents at once. Updating a Skill's instructions automatically propagates the improvement to every agent using it, rather than requiring each agent to be edited individually.", - shortDescription: - 'Reusable instruction/tool packages that update every agent using them at once.', + "A single Skill can be attached to multiple agents simultaneously, and updating its instructions once automatically propagates that change to every agent using it, rather than requiring each agent's copy to be edited individually.", + shortDescription: 'One Skill edit auto-propagates to every agent it is attached to.', source: { url: 'https://docs.dust.tt/docs/skills', label: 'Skills | Dust Docs', @@ -51,10 +51,11 @@ export const dustProfile: CompetitorProfile = { }, }, { - title: 'Dual-role MCP: consumes external servers and exposes Dust as one', + title: "Client-side MCP tools that execute locally in the end user's own environment", description: - "Dust agents can call tools from external MCP servers, remote or client-side (client-side tools execute in the user's own environment for sensitive operations). Dust can also be exposed as an MCP server, so external MCP-compatible clients (e.g. Claude Desktop, Cursor) can call Dust agents and data as tools.", - shortDescription: 'Both calls external MCP tools and can be called as an MCP server itself.', + "Beyond remote MCP servers, Dust supports client-side MCP servers whose tools run directly in the end user's local environment rather than on Dust's own infrastructure, for sensitive operations that shouldn't leave the user's machine. Dust can also be exposed as an MCP server itself, so external MCP-compatible clients (e.g. Claude Desktop, Cursor) can call Dust agents and data as tools.", + shortDescription: + "MCP tools that run locally in the user's own environment for sensitive operations.", source: { url: 'https://docs.dust.tt/docs/client-side-mcp-server', label: 'Client Side MCP Server (Preview) | Dust Docs', @@ -87,10 +88,12 @@ export const dustProfile: CompetitorProfile = { }, }, { - title: 'No dedicated pre-deployment evaluation/dataset-testing framework', + title: + 'No dedicated pre-deployment evaluation/dataset-testing framework, a gap shared with most agent builders', description: - "Dust says it is 'not a pre-deployment evaluation platform': dataset-based regression testing belongs in CI/CD pipelines and specialized testing tools, and Dust builds observability signals into the agent-builder workflow instead of a formal eval-suite feature.", - shortDescription: 'Dust says it is not a pre-deployment evaluation platform.', + "Dust explicitly says it is 'not a pre-deployment evaluation platform': dataset-based regression testing belongs in CI/CD pipelines and specialized testing tools, and Dust builds observability signals into the agent-builder workflow instead of a formal eval-suite feature. This is a gap most agent builders share, including Sim, whose own Evaluator and Guardrails blocks are per-call scoring/validation primitives rather than a batch golden-dataset eval-suite runner.", + shortDescription: + 'Dust says it is not a pre-deployment eval platform, a gap shared with most agent builders.', source: { url: 'https://dust.tt/blog/evaluation-to-maintenance', label: 'From Evaluation to Maintenance | Dust Blog', diff --git a/apps/sim/lib/compare/data/competitors/flowise.ts b/apps/sim/lib/compare/data/competitors/flowise.ts index e3ab62ae007..3ed440bee2d 100644 --- a/apps/sim/lib/compare/data/competitors/flowise.ts +++ b/apps/sim/lib/compare/data/competitors/flowise.ts @@ -29,23 +29,23 @@ export const flowiseProfile: CompetitorProfile = { }, }, { - title: 'Agentflow V2 with built-in human-in-the-loop and evaluation', + title: 'Built-in dataset-based batch evaluation', description: - 'Agentflow V2 supports loops, conditional branching, and a dedicated Human Input node that pauses execution for approve/reject feedback before sensitive tool calls (bookings, sends, orders) proceed. Flowise also ships a built-in Evaluations feature that runs chatflows/agentflows against a dataset, scoring outputs with string, numeric, or LLM-as-judge evaluators and reporting pass/fail rate, average tokens, and latency.', + "Flowise ships a built-in Evaluations feature that runs chatflows/agentflows against a saved dataset in one batch, scoring outputs with string, numeric, or LLM-as-judge evaluators and reporting pass/fail rate, average tokens, and latency across the whole run. Sim's own Evaluator block scores individual calls against user-defined metrics, but has no equivalent golden-dataset batch runner. (Flowise's Agentflow V2 also has a Human Input node for pausing on approve/reject feedback, comparable to Sim's own human-in-the-loop approval block.)", shortDescription: - 'Native human-approval node plus built-in dataset-based LLM-judge evaluation reporting.', + 'Built-in dataset-based batch evaluation with LLM-judge scoring and pass/fail reporting.', source: { - url: 'https://docs.flowiseai.com/tutorials/human-in-the-loop', - label: 'Flowise Docs: Human In The Loop', + url: 'https://docs.flowiseai.com/using-flowise/evaluations', + label: 'Flowise Docs: Evaluations', asOf: '2026-07-02', }, }, { - title: 'Large open-source project with Apache 2.0 core', + title: 'Larger existing open-source community, on the same Apache 2.0 license as Sim', description: - "Flowise's Community Edition is Apache License 2.0, its GitHub repo has roughly 54,000 stars, and it has an active Discord community with full self-hosting support via Docker.", + 'Both Flowise and Sim are Apache License 2.0 and self-hostable, so the license itself is not a differentiator. Where Flowise stands out is community scale: its GitHub repo has roughly 54,000 stars and an active Discord community built up since 2023.', shortDescription: - 'Apache 2.0 licensed, ~54k GitHub stars, actively maintained open-source project.', + 'Same Apache 2.0 license as Sim, but a larger existing community: ~54k GitHub stars, active Discord.', source: { url: 'https://github.com/FlowiseAI/Flowise', label: 'GitHub: FlowiseAI/Flowise', diff --git a/apps/sim/lib/compare/data/competitors/gumloop.ts b/apps/sim/lib/compare/data/competitors/gumloop.ts index 2b444e5810f..edbebc9f1ee 100644 --- a/apps/sim/lib/compare/data/competitors/gumloop.ts +++ b/apps/sim/lib/compare/data/competitors/gumloop.ts @@ -37,11 +37,10 @@ export const gumloopProfile: CompetitorProfile = { }, }, { - title: "Natural-language flow building via 'Gen' copilot", + title: 'Gen copilot debugs existing flows node-by-node on canvas', description: - 'An AI assistant named Gen can plan, build, modify, and debug workflows from plain-English descriptions directly on the canvas, lowering the barrier for non-technical users.', - shortDescription: - 'An AI copilot builds, edits, and debugs workflows from plain-English prompts.', + 'Beyond building new flows from a prompt, Gen can step into an already-built workflow and debug it node-by-node directly on the canvas, working from a plain-English description of what is going wrong.', + shortDescription: 'Gen debugs an existing workflow node-by-node directly on the canvas.', source: { url: 'https://www.gumloop.com/blog/agentic-ai-tools', label: 'Gumloop blog: agentic AI tools', @@ -49,11 +48,10 @@ export const gumloopProfile: CompetitorProfile = { }, }, { - title: 'Plain-English guardrail policies with human-in-the-loop approval', + title: 'Plain-English, org-wide guardrail policy engine', description: - 'Organizations can define app/tool usage policies in plain English at org, team, or agent level; violating actions can be blocked or tagged and logged, and sensitive actions can pause mid-task for a human approval card.', - shortDescription: - 'Plain-English usage policies plus mid-task human approval for sensitive actions.', + 'Organizations can define app/tool usage policies in plain English at org, team, or agent level; violating actions can be blocked or tagged and logged.', + shortDescription: 'Plain-English usage policies enforced at org, team, or agent level.', source: { url: 'https://www.gumloop.com/solutions/security', label: 'Gumloop Security & Trust', @@ -88,7 +86,7 @@ export const gumloopProfile: CompetitorProfile = { { title: 'No public self-hosting of the core platform', description: - "Gumloop is only available as managed SaaS or an enterprise-managed VPC deployment operated by Gumloop inside a customer's cloud project. There is no downloadable, self-managed install of the Gumloop application itself; a separate community project, guMCP, is open source but is not the platform.", + "Gumloop is only available as managed SaaS or an enterprise-managed VPC deployment operated by Gumloop inside a customer's cloud project. There is no downloadable, self-managed install of the Gumloop application itself; Gumloop's own guMCP_template repo is a self-hosted MCP-server starter, not an install of the platform.", shortDescription: 'No downloadable self-hosted install. Only managed SaaS or enterprise VPC.', source: { url: 'https://www.gumloop.com/solutions/security', @@ -163,7 +161,7 @@ export const gumloopProfile: CompetitorProfile = { value: 'No public self-host option for the core Gumloop app; enterprise customers can get a managed Virtual Private Cloud (VPC) deployment into their own cloud (e.g. GCP) instead of full self-hosting', detail: - "Gumloop deploys and operates the platform inside the customer's cloud project rather than offering a downloadable, self-managed open-source install. A separate community open-source project, guMCP, provides self-hostable MCP servers but is not the Gumloop app itself.", + "Gumloop deploys and operates the platform inside the customer's cloud project rather than offering a downloadable, self-managed open-source install. Gumloop's own guMCP_template repo is an open-source starter for self-hosted MCP servers, but it is not an install of the Gumloop app itself.", shortValue: 'No self-host; VPC deployment only', confidence: 'estimated', sources: [ @@ -173,8 +171,8 @@ export const gumloopProfile: CompetitorProfile = { asOf: '2026-07-02', }, { - url: 'https://github.com/agoddijn-fern/guMCP-self-hosted', - label: 'guMCP self-hosted (community project)', + url: 'https://github.com/gumloop/guMCP_template', + label: "guMCP_template (Gumloop's self-hosted MCP starter repo)", asOf: '2026-07-02', }, ], @@ -210,7 +208,7 @@ export const gumloopProfile: CompetitorProfile = { license: { value: 'Proprietary', detail: - 'The core Gumloop application has no open-source license; it is a closed, hosted commercial SaaS product. A separate community MCP-server project, guMCP, is open source but is not the Gumloop platform.', + "The core Gumloop application has no open-source license; it is a closed, hosted commercial SaaS product. Gumloop's own guMCP_template repo is an open-source MCP-server starter, but it is not the Gumloop platform.", shortValue: 'Proprietary', confidence: 'estimated', sources: [ @@ -722,6 +720,8 @@ export const gumloopProfile: CompetitorProfile = { entryPaidPlan: { value: 'Pro plan at $37/month for 20k+ credits. Includes unlimited seats/teams, 5 concurrent runs, 25 concurrent agent interactions, agent reflections, unified billing, and 1 hosted MCP server instance', + detail: + "Gumloop's pricing page lists 'MCP Server Hosting (1)' under the Pro plan without clarifying its scope: it is not stated whether this cap limits access to the 100+ pre-built, zero-setup MCP servers described on gumloop.com/mcp, or only applies to a separate custom MCP server that Gumloop hosts on a customer's behalf. That distinction is not resolved anywhere on Gumloop's own pricing or MCP pages.", shortValue: '$37/month Pro plan, 20k+ credits', confidence: 'verified', sources: [ @@ -1117,11 +1117,11 @@ export const gumloopProfile: CompetitorProfile = { }, companyMaturity: { value: - "Founded in Vancouver in April 2023 (originally as 'AgentHub') by Max Brodeur-Urbas and Rahul Behal. Raised a $3.1M seed (July 2024), a $17M Series A in January 2025 (led by Nexus Venture Partners), and a $50M Series B in March 2026 (led by Benchmark). About $70M raised in total across 3 rounds. Y Combinator alum with roughly 37 employees as of mid-2026.", + "Founded in Vancouver in April 2023 (originally as 'AgentHub') by Max Brodeur-Urbas and Rahul Behal. Raised a $3.1M seed (July 2024) and a $17M Series A in January 2025 (led by Nexus Venture Partners), both independently corroborated; a self-reported $50M Series B in March 2026 (led by Benchmark) would bring the total to about $70M across 3 rounds. Y Combinator alum with roughly 37 employees as of mid-2026.", detail: - 'Gumloop started as a side project in a Vancouver bedroom in April 2023, founded by Max Brodeur-Urbas and Rahul Behal under the name AgentHub before rebranding to Gumloop. It raised a $3.1M seed round in July 2024, a $17M Series A in January 2025 led by Nexus Venture Partners (with First Round Capital, Y Combinator, and angel investors), and a $50M Series B in March 2026 led by Benchmark (with Nexus Venture Partners, First Round Capital, Y Combinator, Box Group, The Cannon Project, and Shopify Ventures). Total raised is about $70M across 3 rounds. Y Combinator lists a team size of 37.', - shortValue: 'Founded 2023, ~$70M raised, Series B in 2026', - confidence: 'verified', + "Gumloop started as a side project in a Vancouver bedroom in April 2023, founded by Max Brodeur-Urbas and Rahul Behal under the name AgentHub before rebranding to Gumloop. It raised a $3.1M seed round in July 2024 and a $17M Series A in January 2025 led by Nexus Venture Partners (with First Round Capital, Y Combinator, and angel investors), both independently corroborated by TechCrunch. The $50M Series B in March 2026 led by Benchmark (with Nexus Venture Partners, First Round Capital, Y Combinator, Box Group, The Cannon Project, and Shopify Ventures) is self-reported on Gumloop's own blog only, with no independent press or funding-tracker corroboration found. Total raised is about $70M across 3 rounds. Y Combinator lists a team size of 37.", + shortValue: 'Founded 2023, ~$70M raised, Series B in 2026 (self-reported)', + confidence: 'estimated', sources: [ { url: 'https://www.gumloop.com/blog/gumloops-17m-series-a', diff --git a/apps/sim/lib/compare/data/competitors/langchain.ts b/apps/sim/lib/compare/data/competitors/langchain.ts index 2bdd4955168..0c58d2b12be 100644 --- a/apps/sim/lib/compare/data/competitors/langchain.ts +++ b/apps/sim/lib/compare/data/competitors/langchain.ts @@ -32,7 +32,7 @@ export const langchainProfile: CompetitorProfile = { { title: 'Dynamic parallel fan-out via the Send API', description: - 'A routing function can return a list of Send objects instead of a single next-node key, letting LangGraph spawn a runtime-determined number of parallel branches (e.g. one worker per item in a list of unknown length) that merge back through a state reducer. This is a native map-reduce pattern, not a fixed number of parallel branches wired at build time.', + 'A routing function can return a list of Send objects instead of a single next-node key, letting LangGraph spawn a runtime-determined number of parallel branches (e.g. one worker per item in a list of unknown length) that merge back through a state reducer. Because the merge step is arbitrary code, a developer can implement any custom aggregation logic, not just a fixed join behavior.', shortDescription: 'Send API spawns a runtime-determined number of parallel branches that merge via a reducer.', source: { @@ -53,18 +53,6 @@ export const langchainProfile: CompetitorProfile = { asOf: '2026-07-02', }, }, - { - title: 'SKILL.md-based reusable agent skills via Deep Agents SkillsMiddleware', - description: - "The Deep Agents harness (LangChain's batteries-included agent framework) ships a SkillsMiddleware that loads named SKILL.md files from a directory and injects them into the system prompt using progressive disclosure (metadata surfaced first, full instructions pulled in on demand), letting a team define a workflow once and reuse it as a named capability across multiple agents.", - shortDescription: - 'SkillsMiddleware loads named SKILL.md files and injects them via progressive disclosure.', - source: { - url: 'https://reference.langchain.com/python/deepagents/middleware/skills', - label: 'skills | deepagents | LangChain Reference', - asOf: '2026-07-02', - }, - }, { title: 'Native A2A protocol support as both server and client', description: @@ -114,18 +102,6 @@ export const langchainProfile: CompetitorProfile = { asOf: '2026-07-02', }, }, - { - title: 'Durability is checkpoint persistence, not automatic failure detection', - description: - "LangGraph's checkpointer saves state after every node, but nothing automatically detects a crashed process; it only lets a resumed process recover from the last saved state. An operator (or external process supervisor) still has to notice the failure and trigger the resume.", - shortDescription: - 'Checkpointer saves state on failure, but nothing automatically detects a crashed process.', - source: { - url: 'https://www.diagrid.io/blog/checkpoints-are-not-durable-execution-why-langgraph-crewai-google-adk-and-others-fall-short-for-production-agent-workflows', - label: "Why Checkpoints Aren't Durable Execution: LangGraph", - asOf: '2026-07-02', - }, - }, { title: 'No dedicated native image/video/audio generation capability', description: @@ -564,7 +540,7 @@ export const langchainProfile: CompetitorProfile = { }, a2aProtocol: { value: - "Yes: LangChain shipped native A2A (Agent2Agent) support via langchain-adk (March 2026), letting any LangChain agent expose itself as an A2A server and call other A2A-compliant agents regardless of the framework that built them, with Agent Cards auto-generated from the agent's name/description/tool list. The local LangGraph dev server exposes A2A endpoints at /a2a/{assistant_id}.", + "Yes: the local LangGraph dev server and the LangSmith Deployment Agent Server both expose native A2A (Agent2Agent) endpoints at /a2a/{assistant_id}, letting any LangChain/LangGraph agent expose itself as an A2A server and call other A2A-compliant agents regardless of the framework that built them, with Agent Cards auto-generated from the agent's name/description/tool list.", detail: "The LangSmith Deployment A2A endpoint maps the protocol's contextId to a LangGraph thread_id automatically, so A2A conversations get the same tracing/observability as native LangGraph runs.", shortValue: 'Yes, native A2A server/client support with auto-generated Agent Cards', diff --git a/apps/sim/lib/compare/data/competitors/langflow.ts b/apps/sim/lib/compare/data/competitors/langflow.ts index 80a89675709..93e28c1aee0 100644 --- a/apps/sim/lib/compare/data/competitors/langflow.ts +++ b/apps/sim/lib/compare/data/competitors/langflow.ts @@ -29,10 +29,11 @@ export const langflowProfile: CompetitorProfile = { }, }, { - title: 'Dual-direction MCP support', + title: 'Zero-step MCP server exposure', description: - 'Langflow can act as an MCP client, connecting to external MCP servers as tool sources. It also automatically exposes every flow with a Chat Output as its own MCP server, so any flow becomes a callable tool for outside MCP clients.', - shortDescription: 'Both consumes external MCP servers and publishes flows as MCP servers.', + 'Every Langflow project is automatically registered as an MCP server the moment it is created, exposing any flow with a Chat Output as a callable MCP tool with no separate publish or deploy step. Sim also supports both MCP client and server roles, but publishing a workflow as an MCP tool requires an explicit deploy step.', + shortDescription: + 'Every project auto-registers as an MCP server on creation, no deploy step.', source: { url: 'https://docs.langflow.org/mcp-server', label: 'Langflow Docs: Use Langflow as an MCP server', diff --git a/apps/sim/lib/compare/data/competitors/make.ts b/apps/sim/lib/compare/data/competitors/make.ts index a66783f4d35..ee39cd274d7 100644 --- a/apps/sim/lib/compare/data/competitors/make.ts +++ b/apps/sim/lib/compare/data/competitors/make.ts @@ -31,10 +31,10 @@ export const makeProfile: CompetitorProfile = { 'Make (make.com) is a closed-source, cloud-only visual workflow-automation platform where users connect app "modules" on a canvas into scenarios. It now also offers AI Agent blocks, an MCP server, and a JS/Python code step, billed on a per-module-execution credit model.', standoutFeatures: [ { - title: '8,000+ template gallery available on the free tier', + title: '3,000+ integrations and an 8,000+ template gallery', description: - "Make's public template gallery hosts over 8,000 pre-built scenarios spanning thousands of use cases, browsable and importable free on every plan including Free, with users paying only for the credits consumed when the imported scenario runs.", - shortDescription: '8,000+ importable scenario templates, free on every plan including Free.', + 'Make lists 3,000+ integration apps and a public gallery of over 8,000 pre-built, importable scenario templates, free to browse on every plan including Free.', + shortDescription: '3,000+ integrations and an 8,000+ template gallery, free on every plan.', source: { url: 'https://www.make.com/en/templates', label: 'Make Templates gallery', @@ -53,35 +53,13 @@ export const makeProfile: CompetitorProfile = { }, }, { - title: 'Native MCP Server', + title: 'MCP Toolboxes for team-level shared servers', description: - 'Make ships a first-party, cloud-hosted Model Context Protocol server that exposes any scenario as a callable tool to external AI agents/clients (Claude, Cursor, etc.) via a generated token and URL, with no local infrastructure to manage.', - shortDescription: 'Cloud-hosted MCP server exposes scenarios as tools with zero setup.', + 'Beyond exposing a single scenario as an MCP tool, Make offers MCP Toolboxes: team-level dedicated MCP servers that bundle a curated subset of multiple scenarios behind one shared endpoint for external AI clients to call.', + shortDescription: 'Team-level MCP servers bundling multiple scenarios as tools.', source: { - url: 'https://www.make.com/en/blog/model-context-protocol-mcp-server', - label: 'Make blog: What is MCP Server?', - asOf: '2026-07-02', - }, - }, - { - title: 'Built-in Knowledge/RAG store for agents', - description: - "Users upload files directly to an agent's 'Knowledge,' which Make automatically chunks, embeds, and stores in a RAG vector database so the agent retrieves only relevant chunks at inference time, reducing token usage.", - shortDescription: 'Uploaded files are auto-chunked and embedded into a RAG store for agents.', - source: { - url: 'https://help.make.com/knowledge', - label: 'Make Knowledge help doc', - asOf: '2026-07-02', - }, - }, - { - title: 'In-scenario JS/Python code step', - description: - "The native 'Make Code' module lets users execute arbitrary JavaScript (Node.js) or Python directly inside a scenario step with no external server, for custom transforms and logic beyond the visual modules.", - shortDescription: 'Run JS or Python inline as a scenario step, no external server needed.', - source: { - url: 'https://www.make.com/en/blog/make-code-app', - label: 'Make blog: Make Code App', + url: 'https://help.make.com/mcp-toolboxes', + label: 'MCP toolboxes - Make Help Center', asOf: '2026-07-02', }, }, @@ -121,10 +99,10 @@ export const makeProfile: CompetitorProfile = { }, }, { - title: 'Audit logs and granular RBAC gated to higher tiers', + title: 'Granular RBAC gated to the Teams plan and above', description: - 'Audit logs and full team/role-based permission management are only available starting on the Teams plan ($38/mo) and Enterprise, with default log retention of just 30 days even on paid plans; lower tiers (Free, Core, Pro) lack these governance controls.', - shortDescription: 'Audit logs and role-based permissions require the Teams plan or above.', + "Full team/role-based permission management is only available starting on the Teams plan ($38/mo) and Enterprise; lower tiers (Free, Core, Pro) get unlimited users but no role-based access controls, unlike Sim, which ships admin/write/read roles on every tier. Audit logs are also gated to Teams/Enterprise, with default retention of just 30 days on paid plans, though this specific gating pattern isn't unique to Make: Sim's own dedicated audit-log API is likewise Enterprise-only.", + shortDescription: 'RBAC needs the Teams plan or above; audit logs are similarly gated.', source: { url: 'https://www.make.com/en/pricing', label: 'Make Pricing page', diff --git a/apps/sim/lib/compare/data/competitors/microsoft-copilot.ts b/apps/sim/lib/compare/data/competitors/microsoft-copilot.ts index cd799eae375..06d02a37abe 100644 --- a/apps/sim/lib/compare/data/competitors/microsoft-copilot.ts +++ b/apps/sim/lib/compare/data/competitors/microsoft-copilot.ts @@ -42,22 +42,11 @@ export const microsoftCopilotProfile: CompetitorProfile = { }, }, { - title: 'Reusable, portable Agent Skills', + title: 'Automatic fallback to a default model if a selected model is disabled', description: - 'A Skill is a named capability defined once as a SKILL.md file (YAML front matter plus Markdown instructions, optionally bundled with scripts, templates, or reference documents into a ZIP package). Skills are authored in Copilot Studio or a text editor, attached to multiple agents, and exported to share with others, unlike a one-off system prompt tied to a single agent.', - shortDescription: 'Named, Markdown-defined capabilities reusable across multiple agents.', - source: { - url: 'https://learn.microsoft.com/en-us/microsoft-copilot-studio/agents-experience/skills-overview', - label: 'Skills overview for agents (preview) - Microsoft Copilot Studio | Microsoft Learn', - asOf: '2026-07-02', - }, - }, - { - title: 'Multi-model support including Anthropic Claude and bring-your-own-model', - description: - 'Agents can use OpenAI GPT models, Anthropic Claude Sonnet 4 and Opus 4.1, any model in the Azure AI Model Catalog, or a bring-your-own-model connection to an Azure AI Foundry deployment (endpoint URI, deployment name, and API key) for individual prompts. Admins enable or restrict non-default models tenant-wide, and the agent falls back to the default OpenAI model automatically if a selected model is disabled.', + "If an admin disables a non-default model tenant-wide, Copilot Studio automatically falls back to the default OpenAI model rather than failing the request. Admins choose among OpenAI GPT models, Anthropic Claude Sonnet 4 and Opus 4.1, any model in the Azure AI Model Catalog, or a bring-your-own-model connection to an Azure AI Foundry deployment, but Sim's own model-fallback only retries the same model with hosted keys, not a cross-model fallback like this.", shortDescription: - 'OpenAI, Anthropic Claude, Azure AI Model Catalog, or a bring-your-own model.', + 'Falls back to the default OpenAI model automatically if a selected model is disabled.', source: { url: 'https://www.microsoft.com/en-us/microsoft-copilot/blog/copilot-studio/anthropic-joins-the-multi-model-lineup-in-microsoft-copilot-studio/', label: 'Anthropic joins the multi-model lineup in Microsoft Copilot Studio', diff --git a/apps/sim/lib/compare/data/competitors/n8n.ts b/apps/sim/lib/compare/data/competitors/n8n.ts index db7e407d145..65779ed55da 100644 --- a/apps/sim/lib/compare/data/competitors/n8n.ts +++ b/apps/sim/lib/compare/data/competitors/n8n.ts @@ -104,11 +104,10 @@ export const n8nProfile: CompetitorProfile = { source: { url: 'https://trust.n8n.io/', label: 'n8n Trust Center', asOf: '2026-07-02' }, }, { - title: - 'SSO/SAML/LDAP, audit logging, and dedicated SLA support gated to paid/Enterprise tiers', + title: 'No RBAC or workflow sharing at all on the free Community edition', description: - 'SSO/SAML/LDAP, custom project roles, audit log export/SIEM streaming, and dedicated SLA-backed support are not available on the Community (free, self-hosted) edition; they require the Business or Enterprise plans.', - shortDescription: 'Governance features require Business or Enterprise, not the free tier.', + "n8n's free, self-hosted Community edition has zero role-based access control; project roles and any workflow sharing require a paid Business or Enterprise plan. Sim includes baseline workspace and organization roles (admin/write/read) on every plan, including the free tier, and reserves only SSO/SAML/OIDC, audit-log export, and SLA-backed support for Enterprise.", + shortDescription: 'Free Community edition has zero RBAC; Sim includes roles on every plan.', source: { url: 'https://n8n.io/pricing/', label: 'n8n Pricing', asOf: '2026-07-02' }, }, { @@ -449,7 +448,7 @@ export const n8nProfile: CompetitorProfile = { evaluationGuardrails: { value: 'Yes: dedicated Evaluations feature (Light + Metric-based)', detail: - "n8n provides an Evaluation node/trigger and an Evaluations tab supporting 'Light evaluations' (manual test cases during development) and 'Metric-based evaluations' (scoring at scale for production), with built-in metrics (AI-judged Helpfulness, string similarity, categorization, tools-used) plus custom metrics. n8n's own 2026 AI Agent Development Tools report scores n8n 0 out of 2 on both \"JSON validity\" and \"Format check\" evaluation types, versus Sim's 2 out of 2 on both, via Sim's Guardrails block.", + "n8n provides an Evaluation node/trigger and an Evaluations tab supporting 'Light evaluations' (manual test cases during development) and 'Metric-based evaluations' (scoring at scale for production), with built-in metrics (AI-judged Helpfulness, string similarity, categorization, tools-used) plus custom metrics.", shortValue: 'Light and metric-based evaluation testing', confidence: 'verified', sources: [ @@ -463,12 +462,6 @@ export const n8nProfile: CompetitorProfile = { label: 'n8n Blog: Introducing Evaluations for AI workflows', asOf: '2026-07-02', }, - { - url: 'https://n8n.io/reports/2026-ai-agent-development-tools/#vendors', - label: - 'n8n: 2026 AI Agent Development Tools report (JSON validity, Format check scores)', - asOf: '2026-07-02', - }, ], }, humanInTheLoop: { @@ -533,7 +526,7 @@ export const n8nProfile: CompetitorProfile = { 'No: n8n has no first-class, named reusable prompt/knowledge-snippet object that agents reference. Reuse is achieved informally by exporting/importing workflow JSON or calling a sub-workflow (e.g. a "Tool (Workflow)" node) as a reusable scratchpad, not by a dedicated skills library.', shortValue: 'No dedicated reusable skill/snippet object', detail: - 'System prompts are configured per AI Agent node; the closest analog is reusable sub-workflows or exported JSON, not a named, invokable skill library. n8n\'s own 2026 AI Agent Development Tools report independently scores n8n 0 out of 2 on "Agent skills directory," versus Sim\'s 2 out of 2.', + 'System prompts are configured per AI Agent node; the closest analog is reusable sub-workflows or exported JSON, not a named, invokable skill library.', confidence: 'verified', sources: [ { @@ -546,11 +539,6 @@ export const n8nProfile: CompetitorProfile = { label: 'Reusable thinking tools workflow template', asOf: '2026-07-02', }, - { - url: 'https://n8n.io/reports/2026-ai-agent-development-tools/#vendors', - label: 'n8n: 2026 AI Agent Development Tools report (Agent skills directory score)', - asOf: '2026-07-02', - }, ], }, nativeChatDeployment: { @@ -688,7 +676,7 @@ export const n8nProfile: CompetitorProfile = { customCodeSteps: { value: 'Yes: JavaScript and Python via Code node, plus a Custom Code Tool for AI agents', detail: - "n8n's Code node supports both JavaScript and Python for custom logic inside a workflow. A separate Custom Code Tool node lets an AI Agent call arbitrary code as one of its tools. n8n's own 2026 AI Agent Development Tools report scores n8n 1 out of 2 on \"Sandboxing,\" versus Sim's 2 out of 2, backed by Sim's isolated-vm (V8 isolate) sandbox running in a separate child process.", + "n8n's Code node supports both JavaScript and Python for custom logic inside a workflow. A separate Custom Code Tool node lets an AI Agent call arbitrary code as one of its tools.", shortValue: 'JavaScript and Python via Code node', confidence: 'estimated', sources: [ @@ -697,11 +685,6 @@ export const n8nProfile: CompetitorProfile = { label: 'n8n Tools Agent docs (mentions Custom Code Tool)', asOf: '2026-07-02', }, - { - url: 'https://n8n.io/reports/2026-ai-agent-development-tools/#vendors', - label: 'n8n: 2026 AI Agent Development Tools report (Sandboxing score)', - asOf: '2026-07-02', - }, ], }, apiPublishing: { diff --git a/apps/sim/lib/compare/data/competitors/openai-agentkit.ts b/apps/sim/lib/compare/data/competitors/openai-agentkit.ts index eaf14bef792..036fb22767f 100644 --- a/apps/sim/lib/compare/data/competitors/openai-agentkit.ts +++ b/apps/sim/lib/compare/data/competitors/openai-agentkit.ts @@ -33,9 +33,9 @@ export const openaiAgentkitProfile: CompetitorProfile = { "OpenAI AgentKit bundled a visual Agent Builder, ChatKit embeddable chat UI, Connector Registry, Guardrails, and Evals for building agentic workflows on OpenAI's models. But OpenAI is winding down Agent Builder and Evals, with full shutdown November 30, 2026, in favor of the code-first Agents SDK or ChatGPT Workspace Agents.", standoutFeatures: [ { - title: 'Code-first, all-OpenAI stack with no visual builder going forward', + title: 'An official, open-source Agents SDK wired natively to its own models', description: - "With Agent Builder and Evals winding down (full shutdown November 30, 2026), OpenAI's path forward is the code-first Agents SDK, openai-agents-python, open source under the MIT license with over 27,500 GitHub stars, natively wired into OpenAI's own model lineup. A team fully committed to an all-OpenAI, code-first stack, with no visual builder layer, gets that directly.", + "The Agents SDK, openai-agents-python, is open source under the MIT license with over 27,500 GitHub stars and natively wired into OpenAI's own model lineup. It's the path OpenAI is steering AgentKit users toward as Agent Builder and Evals wind down (full shutdown November 30, 2026), and a team fully committed to an all-OpenAI, code-first stack gets that directly.", shortDescription: 'Open-source code-first framework, natively wired to OpenAI models.', source: { url: 'https://github.com/openai/openai-agents-python', @@ -66,13 +66,13 @@ export const openaiAgentkitProfile: CompetitorProfile = { }, }, { - title: 'Guardrails open-source safety layer', + title: 'Guardrails ships as a standalone, installable open-source package', description: - 'Agent Builder shipped an open-source, modular guardrails layer that can mask/flag PII, detect jailbreaks, and apply other safety checks around agent behavior.', - shortDescription: 'Open-source guardrails layer masks PII and detects jailbreaks.', + "Agent Builder's Guardrails node is backed by a separately installable, open-source package that can be used outside AgentKit entirely, adding jailbreak detection alongside PII masking and other safety checks. Sim's Guardrails block covers PII masking and hallucination/RAG scoring too, but it's a built-in workflow block, not a standalone library you can drop into an unrelated codebase.", + shortDescription: 'Standalone open-source package adds jailbreak detection to PII masking.', source: { - url: 'https://openai.com/index/introducing-agentkit/', - label: 'Introducing AgentKit (via search excerpt)', + url: 'https://guardrails.openai.com/', + label: 'OpenAI Guardrails', asOf: '2026-07-02', }, }, @@ -105,7 +105,7 @@ export const openaiAgentkitProfile: CompetitorProfile = { title: 'Usage-based, per-token/per-call pricing with no published flat plan for AgentKit itself', description: - 'There is no dedicated AgentKit subscription tier. Costs are the sum of model tokens, Code Interpreter sessions ($0.03-$1.92 per session by memory tier), and File Search ($0.10/GB-day storage plus $2.50 per 1,000 tool calls), which makes cost forecasting harder than a flat, seat-based plan.', + "There is no dedicated AgentKit subscription tier. Costs are the sum of model tokens, Code Interpreter sessions ($0.03-$1.92 per session by memory tier), and File Search ($0.10/GB-day storage plus $2.50 per 1,000 tool calls), which makes cost forecasting harder than Sim's published pricing, where a Pro seat is a flat $25/user/month with a monthly credit allowance included, rather than open-ended per-token and per-call metering.", shortDescription: 'No flat plan; costs scale with token and tool usage.', source: { url: 'https://developers.openai.com/api/docs/pricing', diff --git a/apps/sim/lib/compare/data/competitors/openclaw.ts b/apps/sim/lib/compare/data/competitors/openclaw.ts index ec108c84c4f..e00e959c956 100644 --- a/apps/sim/lib/compare/data/competitors/openclaw.ts +++ b/apps/sim/lib/compare/data/competitors/openclaw.ts @@ -32,7 +32,7 @@ export const openClawProfile: CompetitorProfile = { { title: 'ClawHub Skills marketplace with multi-scanner security pipeline', description: - 'Skills are Markdown-based instruction packages (SKILL.md files) installable from the public ClawHub registry, git repos, or local directories. Every published skill runs through a ClawScan pipeline combining static analysis, VirusTotal, and NVIDIA SkillSpector (added June 2026), and gets a Clean/Suspicious/Malicious verdict plus a Skill Card documenting provenance.', + "Skills are Markdown-based instruction packages (SKILL.md files) installable from the public ClawHub registry, git repos, or local directories. Every published skill runs through a ClawScan pipeline combining static analysis, VirusTotal, and NVIDIA SkillSpector (added June 2026), and gets a Clean/Suspicious/Malicious verdict plus a Skill Card documenting provenance. This scanning was introduced only after researchers found roughly 900 ClawHub skills with a documented credential-leak or malware finding (see limitations), and OpenClaw's own docs still tell users to treat third-party skills as untrusted code.", shortDescription: 'Markdown skills from ClawHub, each scanned by static analysis, VirusTotal, and SkillSpector.', source: { @@ -66,11 +66,11 @@ export const openClawProfile: CompetitorProfile = { }, }, { - title: 'Markdown-file memory instead of an opaque vector store', + title: 'Markdown-file memory with no ingestion pipeline', description: - "Long-term memory is stored as plain, human-readable Markdown files (daily notes plus a curated MEMORY.md), layered with semantic search (memorySearch), instead of hiding retrieved context inside a vector database the user can't inspect or edit directly.", + 'Long-term memory is stored as plain, human-readable Markdown files (daily notes plus a curated MEMORY.md), layered with semantic search (memorySearch), instead of running an ingestion/chunking pipeline into a database. The files are natively git-trackable and editable in any text editor, with no separate KB module standing between the user and their own memory.', shortDescription: - 'Long-term memory lives in editable Markdown files, not a hidden vector store.', + 'Long-term memory lives in plain, git-trackable Markdown files, not a database-backed KB module.', source: { url: 'https://docs.openclaw.ai/concepts/memory', label: 'OpenClaw Docs: Memory overview', @@ -128,11 +128,10 @@ export const openClawProfile: CompetitorProfile = { }, }, { - title: 'No enterprise compliance certifications (SOC 2, ISO 27001, HIPAA)', + title: 'No SOC 2 report or other compliance attestation', description: - 'OpenClaw is a self-hosted open-source project run by a non-profit foundation, not a vendor selling a hosted service, so it publishes no SOC 2, ISO 27001, HIPAA, or similar compliance attestation. Security for data-at-rest and processing falls entirely on the operator running their own instance.', - shortDescription: - 'No SOC 2/ISO/HIPAA attestations; the self-hosting operator owns all compliance risk.', + 'OpenClaw is a self-hosted open-source project run by a non-profit foundation, not a vendor selling a hosted service, so it publishes no SOC 2 report or other compliance attestation. Sim is SOC 2 compliant; like OpenClaw, Sim does not currently hold ISO 27001 or HIPAA certification. Security for data-at-rest and processing on OpenClaw falls entirely on the operator running their own instance.', + shortDescription: 'No SOC 2 report; the self-hosting operator owns all compliance risk.', source: { url: 'https://docs.openclaw.ai/gateway/security', label: 'OpenClaw Docs: Security', diff --git a/apps/sim/lib/compare/data/competitors/pipedream.ts b/apps/sim/lib/compare/data/competitors/pipedream.ts index fed13bb7424..ee506a3994a 100644 --- a/apps/sim/lib/compare/data/competitors/pipedream.ts +++ b/apps/sim/lib/compare/data/competitors/pipedream.ts @@ -38,17 +38,6 @@ export const pipedreamProfile: CompetitorProfile = { shortDescription: 'Every workflow step can drop into custom code in four languages.', source: { url: 'https://pipedream.com/docs', label: 'Pipedream Docs', asOf: '2026-07-02' }, }, - { - title: 'Edit workflows with natural language', - description: - "An 'Edit with AI' button in the workflow builder header or any code step lets users modify an existing workflow using natural-language instructions.", - shortDescription: "An 'Edit with AI' button lets users modify workflows by prompt.", - source: { - url: 'https://pipedream.com/blog/build-workflows-faster-with-ai/', - label: 'Pipedream Blog: Build workflows faster with AI', - asOf: '2026-07-04', - }, - }, { title: 'Source-available component registry on GitHub', description: @@ -96,13 +85,13 @@ export const pipedreamProfile: CompetitorProfile = { }, }, { - title: 'Support response-time SLAs not publicly documented', + title: 'No native fail-and-continue branch on step errors', description: - 'The Enterprise plan reportedly includes a dedicated Success Engineer and a platform uptime guarantee, but support response-time SLAs are not published on any plan page and appear to be negotiated directly with sales.', - shortDescription: 'No published response-time SLA on any public plan page.', + 'By default an unhandled step error halts the entire workflow execution. Auto-retry (Advanced plan and above) and the global $error event stream let teams react to a failure after the fact, but neither lets the original run continue past the failing step in the same execution without hand-coded try/catch or conditional logic.', + shortDescription: 'An unhandled step error halts the whole run by default.', source: { - url: 'https://pipedream.com/pricing', - label: 'Pipedream Pricing page', + url: 'https://pipedream.com/docs/workflows/building-workflows/errors', + label: 'Pipedream Docs: Handling Errors', asOf: '2026-07-02', }, }, @@ -1099,21 +1088,21 @@ export const pipedreamProfile: CompetitorProfile = { }, companyMaturity: { value: - 'Founded 2019, San Francisco; ~11-50 employees pre-acquisition; raised ~$22M across 2 rounds; acquired by Workday (deal announced Nov 19, 2025)', + 'Founded 2019, San Francisco; ~11-50 employees pre-acquisition; raised ~$22M across 2 rounds; agreed to be acquired by Workday (signed Nov 19, 2025), with no public confirmation the deal has closed as of mid-2026', detail: - "Per Crunchbase, Pipedream was founded in 2019 and headquartered in San Francisco, CA, with founders including Tod Sacerdoti (CEO), Dylan Sather, TJ Koblentz, and Pravin Savkar; it raised a total of ~$22M across 2 funding rounds (investors include Felicis and CRV) and had a headcount signal of 11-50 employees. Workday announced a definitive agreement to acquire Pipedream on November 19, 2025, with the transaction expected to close in Workday's Q4 FY2026 (by end of January 2026); Pipedream is now being positioned as Workday's integration layer for AI agent workflows across Workday and third-party apps.", - shortValue: 'Founded 2019; acquired by Workday, Nov 2025', - confidence: 'verified', + "Per Crunchbase, Pipedream was founded in 2019 and headquartered in San Francisco, CA, with founders including Tod Sacerdoti (CEO), Dylan Sather, TJ Koblentz, and Pravin Savkar; it raised a total of ~$22M across 2 funding rounds (investors include Felicis and CRV) and had a headcount signal of 11-50 employees. Workday signed a definitive agreement to acquire Pipedream on November 19, 2025, with the transaction originally expected to close in Workday's Q4 FY2026 (by end of January 2026), subject to closing conditions. That expected close date has since passed, and as of this writing neither Workday nor Pipedream has publicly announced that the deal has closed.", + shortValue: 'Founded 2019; agreed to be acquired by Workday, close unconfirmed', + confidence: 'estimated', sources: [ { url: 'https://newsroom.workday.com/2025-11-19-Workday-Signs-Definitive-Agreement-to-Acquire-Pipedream', label: 'Workday Newsroom – Workday Signs Definitive Agreement to Acquire Pipedream', - asOf: '2026-07-02', + asOf: '2026-07-06', }, { url: 'https://pipedream.com/blog/pipedream-to-be-acquired-by-workday/', label: 'Pipedream Blog – Pipedream to be acquired by Workday', - asOf: '2026-07-02', + asOf: '2026-07-06', }, { url: 'https://www.crunchbase.com/organization/pipedream', diff --git a/apps/sim/lib/compare/data/competitors/power-automate.ts b/apps/sim/lib/compare/data/competitors/power-automate.ts index 52c6ecc1654..84016f60493 100644 --- a/apps/sim/lib/compare/data/competitors/power-automate.ts +++ b/apps/sim/lib/compare/data/competitors/power-automate.ts @@ -29,7 +29,7 @@ export const powerAutomateProfile: CompetitorProfile = { { title: 'Solutions-based ALM with managed/unmanaged packages and pipelines', description: - 'Flows package into Dataverse Solutions (managed vs. unmanaged) and promote from dev to test to production via Power Platform Pipelines, with environment variables swapping per-environment references, a full multi-environment promotion model, not just single-workflow versioning.', + 'Flows package into Dataverse Solutions (managed vs. unmanaged) and promote from dev to test to production via Power Platform Pipelines, with environment variables swapping per-environment references. The promotion is tied to Dataverse as a shared enterprise data platform, with centralized admin governance over which environments a solution can move into.', shortDescription: 'Flows promote dev to test to production via Dataverse Solutions and Pipelines.', source: { @@ -50,11 +50,11 @@ export const powerAutomateProfile: CompetitorProfile = { }, }, { - title: 'Multi-model choice including Anthropic Claude in Copilot Studio', + title: 'Tenant-wide admin gating with automatic model fallback in Copilot Studio', description: - 'Copilot Studio added Claude Sonnet 4 and Claude Opus 4.1 as selectable models alongside OpenAI GPT models, with admin controls to enable/restrict and automatic fallback to the default OpenAI model if disabled.', + 'Copilot Studio lets admins enable or restrict which models (including Anthropic Claude Sonnet 4 and Opus 4.1 alongside OpenAI GPT models) are available tenant-wide from the Microsoft 365 Admin Center, and agents automatically fall back to the default OpenAI GPT-4o model if their selected model is disabled, with no additional configuration required.', shortDescription: - 'Copilot Studio supports Claude Sonnet 4 and Opus 4.1 alongside OpenAI models.', + 'Admins gate model access tenant-wide, with automatic fallback to the default model.', source: { url: 'https://www.microsoft.com/en-us/microsoft-copilot/blog/copilot-studio/anthropic-joins-the-multi-model-lineup-in-microsoft-copilot-studio/', label: 'Anthropic joins the multi-model lineup in Microsoft Copilot Studio', @@ -76,13 +76,13 @@ export const powerAutomateProfile: CompetitorProfile = { ], limitations: [ { - title: 'No native side-by-side version diff/compare view', + title: 'No live, concurrent multi-user editing in the flow designer', description: - 'Power Automate has version history with restore, but no built-in visual diff between two flow versions. Teams export both definitions and run a manual text diff in an external tool like VS Code to see what changed.', - shortDescription: 'No built-in visual diff between two flow versions.', + "Power Automate's cloud flow designer supports sharing a flow with co-owners, but not live, concurrent multi-user editing with visible cursors and synced changes on the same flow. Microsoft's live coauthoring feature exists for Power Apps Studio canvas apps, a separate product, not the Power Automate flow designer.", + shortDescription: 'No true live co-editing in the flow designer, only owner-level sharing.', source: { - url: 'https://sharepains.com/2024/04/26/version-history-in-power-automate-flows/', - label: 'Version history in Power Automate flows', + url: 'https://learn.microsoft.com/en-us/power-automate/guide-to-cloud-flow-sharing-permissions', + label: 'Guide to cloud flow sharing and permissions | Microsoft Learn', asOf: '2026-07-02', }, }, diff --git a/apps/sim/lib/compare/data/competitors/retool.ts b/apps/sim/lib/compare/data/competitors/retool.ts index 8133fd51c86..e9f6b208d36 100644 --- a/apps/sim/lib/compare/data/competitors/retool.ts +++ b/apps/sim/lib/compare/data/competitors/retool.ts @@ -39,7 +39,7 @@ export const retoolProfile: CompetitorProfile = { { title: 'Retool Vectors (managed vector store)', description: - "A Retool-managed vector database that automatically indexes uploaded text, PDFs, or web pages, so AI apps and agents can look up relevant content with one click instead of building a custom search pipeline. The lookups always run through OpenAI's embedding API, even when the chat model is a different provider.", + "A Retool-managed vector database that automatically indexes uploaded text, PDFs, or web pages, so AI apps and agents can look up relevant content with one click. The lookups always run through OpenAI's embedding API, even when the chat model is a different provider, so there is no way to tune or swap the embedding step independently of the chat model.", shortDescription: 'Managed vector database with automatic indexing for one-click content lookup.', source: { @@ -49,11 +49,11 @@ export const retoolProfile: CompetitorProfile = { }, }, { - title: 'Bidirectional MCP support', + title: 'Workspace-level MCP server for platform management', description: - 'Retool Agents can connect outbound to external MCP servers (a standard for plugging AI agents into outside tools) to pull in tools like GitHub or Jira. Retool also exposes its own workspace as an MCP server (public beta), so build and management actions, such as creating apps, running queries, and managing users, can be performed directly from Claude, Cursor, Codex, or Kiro. This does not let you publish an individual deployed app or workflow as its own standalone MCP tool for outside consumption.', + 'Retool Agents can connect outbound to external MCP servers (a standard for plugging AI agents into outside tools) to pull in tools like GitHub or Jira. Retool also exposes its own workspace as an MCP server (public beta), so platform-administration actions, such as creating apps, running queries, and managing users, can be performed directly from Claude, Cursor, Codex, or Kiro. This does not let you publish an individual deployed app or workflow as its own standalone MCP tool for outside consumption.', shortDescription: - 'Connects to external MCP servers and exposes workspace management actions as one.', + 'Connects to external MCP servers and exposes workspace administration actions as one.', source: { url: 'https://retool.com/blog/how-to-use-mcp-in-retool', label: 'How to use MCP in Retool', @@ -63,8 +63,8 @@ export const retoolProfile: CompetitorProfile = { { title: 'Retool Agents (deterministic + non-deterministic decisioning)', description: - 'A dedicated agent-building surface that combines code-based deterministic logic, LLM-based non-deterministic decisions, and human-in-the-loop steps within one automation, distinct from the classic Workflows product.', - shortDescription: 'Combines deterministic logic, LLM decisions, and human-in-the-loop steps.', + 'A dedicated agent-building surface, separate from the classic Workflows product, that combines code-based deterministic logic, LLM-based non-deterministic decisions, and human-in-the-loop steps within one automation. Sim covers the same ground with agent, function, and human-in-the-loop blocks combined directly in its single workflow builder, without needing a second product.', + shortDescription: 'A separate product from classic Workflows for combining these primitives.', source: { url: 'https://docs.retool.com/agents', label: 'Retool Agents docs', diff --git a/apps/sim/lib/compare/data/competitors/stackai.ts b/apps/sim/lib/compare/data/competitors/stackai.ts index b3634226c5f..7a4bf12253b 100644 --- a/apps/sim/lib/compare/data/competitors/stackai.ts +++ b/apps/sim/lib/compare/data/competitors/stackai.ts @@ -17,10 +17,10 @@ export const stackaiProfile: CompetitorProfile = { 'StackAI is a proprietary, enterprise-focused visual platform for building, deploying, and governing AI agents, connecting LLMs and business systems through a drag-and-drop, low-code node builder.', standoutFeatures: [ { - title: 'SOC 2 Type II and ISO 27001 certified, with a public Trust Center', + title: 'ISO 27001 certified, with a public Trust Center detailing pen tests and DPAs', description: - 'StackAI publishes a Trust Center (trust.stackai.com) documenting SOC 2 Type II and ISO 27001 certification, third-party penetration test results, and DPAs with OpenAI and Anthropic.', - shortDescription: 'Public Trust Center with SOC 2, ISO 27001, and pen test results.', + 'StackAI publishes a Trust Center (trust.stackai.com) documenting ISO 27001 certification, third-party penetration test results, and DPAs with OpenAI and Anthropic. StackAI also holds a SOC 2 Type II audit, but so does Sim, so ISO 27001 is the actual point of difference here.', + shortDescription: 'Public Trust Center with ISO 27001, pen test results, and DPAs.', source: { url: 'https://trust.stackai.com/', label: 'StackAI Trust Center', @@ -28,9 +28,9 @@ export const stackaiProfile: CompetitorProfile = { }, }, { - title: 'Agentic Development Life Cycle (dev/staging/production promotion)', + title: 'PR-gated approval workflow for promoting agents between environments', description: - 'StackAI provides three default, isolated environments (development, staging, production), plus custom environments. Promotion between them is gated by pull requests, each environment can connect to its own data sources, and an admin approval queue sits before production deploys.', + "StackAI provides three default, isolated environments (development, staging, production), plus custom environments. Promotion between them requires a pull request that must be reviewed and approved, with an admin approval queue sitting before production deploys. Sim also supports forking a workspace into dev/qa/prod-style environments with diff and promote/rollback, but without a mandatory PR-review or approval gate, and that capability is itself gated to Sim's Enterprise plan on hosted Sim (or a feature flag on self-hosted deployments).", shortDescription: 'PR-gated dev/staging/production promotion with admin approval queues.', source: { url: 'https://www.stackai.com/blog/the-agentic-development-life-cycle-how-to-manage-ai-agents-at-scale', @@ -39,9 +39,9 @@ export const stackaiProfile: CompetitorProfile = { }, }, { - title: 'Version history with diff/compare and rollback', + title: 'Full version diff/compare on every manual edit, not just AI-generated changes', description: - 'Every save creates a full version snapshot of an agent. A comparison tool shows added or removed nodes, prompt and LLM config changes, and connection changes. Any version can be reverted, and reverting creates a new version rather than erasing history.', + 'Every save creates a full version snapshot of an agent, regardless of whether the change was made manually or by an AI assistant. A comparison tool shows added or removed nodes, prompt and LLM config changes, and connection changes. Any version can be reverted, and reverting creates a new version rather than erasing history. Sim diffs and reverts Copilot-generated edits, but manual edits only get local undo/redo, not a versioned diff.', shortDescription: 'Full version snapshots with diff/compare and one-click rollback.', source: { url: 'https://www.stackai.com/blog/the-agentic-development-life-cycle-how-to-manage-ai-agents-at-scale', @@ -49,17 +49,6 @@ export const stackaiProfile: CompetitorProfile = { asOf: '2026-07-02', }, }, - { - title: 'Human-in-the-loop approval gating before side effects', - description: - 'A workflow can pause at a decision point and send an approval request, for example via Slack, Teams, or email, before a risky action like sending an email, writing to a database, or provisioning access. The run resumes once a human approves, rejects, or gives feedback.', - shortDescription: 'Pauses workflows for human approval before risky tool calls execute.', - source: { - url: 'https://www.stackai.com/blog/introducing-stackai-human-in-the-loop-agentic-workflows-you-can-trust', - label: 'Introducing StackAI Human-in-the-Loop - StackAI blog', - asOf: '2026-07-02', - }, - }, { title: 'On-prem / VPC self-hosted deployment for enterprise', description: diff --git a/apps/sim/lib/compare/data/competitors/tines.ts b/apps/sim/lib/compare/data/competitors/tines.ts index eab44d28cbf..00437900da7 100644 --- a/apps/sim/lib/compare/data/competitors/tines.ts +++ b/apps/sim/lib/compare/data/competitors/tines.ts @@ -16,18 +16,6 @@ export const tinesProfile: CompetitorProfile = { oneLiner: 'Tines is a proprietary workflow automation platform, available cloud-hosted or self-hosted, originally built for security operations. Teams build event-driven "Stories" via a visual no/low-code canvas, natural language, or the API. It recently added native AI agent, MCP, and copilot capabilities.', standoutFeatures: [ - { - title: 'API-centric integration model', - description: - 'Instead of a fixed library of app connectors, Tines is built around a generic HTTP Request action that calls any API directly, trading pre-built connectors for broader reach and more manual setup.', - shortDescription: - 'A generic HTTP Request action reaches any API instead of fixed connectors.', - source: { - url: 'https://www.tines.com/blog/solving-the-integrations-problem/', - label: 'Solving the integrations problem', - asOf: '2026-07-02', - }, - }, { title: 'ISO 42001 AI-governance certification', description: @@ -40,24 +28,25 @@ export const tinesProfile: CompetitorProfile = { }, }, { - title: 'Workbench natural-language builder', + title: 'API-centric integration model', description: - 'Users describe an automation in plain language and Workbench, the platform-wide AI assistant, generates the Story automatically. Workbench absorbed the former "Story Copilot," which was renamed "Workbench for Storyboard" on June 2, 2026.', - shortDescription: 'Workbench turns plain-language descriptions into working automations.', + 'Instead of a fixed library of app connectors, Tines is built around a generic HTTP Request action that calls any API directly, trading pre-built connectors for broader reach and more manual setup.', + shortDescription: + 'A generic HTTP Request action reaches any API instead of fixed connectors.', source: { - url: 'https://www.tines.com/whats-new/workbench-for-stories/', - label: "Tines What's New", - asOf: '2026-07-04', + url: 'https://www.tines.com/blog/solving-the-integrations-problem/', + label: 'Solving the integrations problem', + asOf: '2026-07-02', }, }, { - title: 'Native MCP server and client support', + title: 'Tines University certification ladder', description: - 'Tines can act as both an MCP server (exposing its actions to AI clients) and an MCP client (consuming external MCP tools), framed around governed, policy-aligned AI access rather than unrestricted tool use.', - shortDescription: 'Acts as both an MCP server and client for governed AI tool access.', + 'Tines University pairs free foundational courses with instructor-led and self-paced Bootcamps and two certification tiers, Core and Advanced, that builders can share on LinkedIn. Sim Academy is a structured docs section without a formal certification path.', + shortDescription: 'Core and Advanced certifications builders can share on LinkedIn.', source: { - url: 'https://www.tines.com/platform/ai/', - label: 'AI Agents, Copilots & MCP | Tines', + url: 'https://www.tines.com/get-certified/', + label: 'Get certified | Tines', asOf: '2026-07-02', }, }, @@ -1131,6 +1120,8 @@ export const tinesProfile: CompetitorProfile = { companyMaturity: { value: 'Founded 2018 (Dublin/Boston) by Eoin Hinchy and Thomas Kinsella; raised ~$272M total across 6 rounds, most recently a $125M Series C (Feb 2025) led by Goldman Sachs at unicorn valuation (~$1.125B); reported headcount roughly 500-550 as of early-to-mid 2026', + detail: + "No funding round beyond the Feb 2025 Series C is publicly confirmed as of this profile's research date; headcount reflects the most recently reported figures (around 548 employees as of March 2026), not necessarily the current count.", shortValue: 'Founded 2018, ~$272M raised, ~500-550 employees', confidence: 'estimated', sources: [ diff --git a/apps/sim/lib/compare/data/competitors/vellum.ts b/apps/sim/lib/compare/data/competitors/vellum.ts index 5a68bdbb8f4..f09de0f34f4 100644 --- a/apps/sim/lib/compare/data/competitors/vellum.ts +++ b/apps/sim/lib/compare/data/competitors/vellum.ts @@ -18,10 +18,10 @@ export const vellumProfile: CompetitorProfile = { 'Vellum is an enterprise AI development platform for building, evaluating, and deploying LLM prompts, workflows, and agents.', standoutFeatures: [ { - title: 'SOC 2 Type 2 and HIPAA compliance with BAA', + title: 'HIPAA compliance with a signable BAA', description: - 'Vellum has SOC 2 Type 2 attestation and HIPAA compliance. Enterprise customers can sign a Business Associate Agreement for handling protected health information, corroborated by a third-party Drata case study.', - shortDescription: 'SOC 2 Type 2 and HIPAA compliance with a signable BAA.', + 'Vellum is HIPAA compliant and enterprise customers can sign a Business Associate Agreement for handling protected health information, corroborated by a third-party Drata case study. Sim does not currently offer HIPAA compliance (both platforms hold SOC 2 Type 2).', + shortDescription: 'HIPAA compliant with a signable BAA; Sim does not offer HIPAA.', source: { url: 'https://drata.com/customers/vellum', label: 'Vellum Case Study: Drata', @@ -29,38 +29,16 @@ export const vellumProfile: CompetitorProfile = { }, }, { - title: 'Self-hosted / VPC enterprise deployment', + title: 'Vendor-managed dedicated VPC deployment', description: - "Enterprise customers can run the platform inside their own AWS, Azure, or GCP VPC (or on-prem) via a Replicated-based install, keeping prompts and documents inside the customer's network perimeter.", - shortDescription: 'Runs inside your own AWS/Azure/GCP VPC or on-prem.', + 'Enterprise customers can get a Vellum-managed dedicated deployment inside a single-tenant AWS, Azure, or GCP VPC via a Replicated-based install. Sim offers self-hosting (Docker Compose or Kubernetes/Helm) but has no documented managed single-tenant/VPC hosting tier.', + shortDescription: 'Vendor-managed single-tenant VPC tier that Sim does not offer.', source: { url: 'https://docs.vellum.ai/self-hosting/getting-started/introduction', label: 'Self-Hosted Vellum: Vellum Docs', asOf: '2026-07-02', }, }, - { - title: "Natural-language agent building ('Vellum for Agents')", - description: - 'Non-technical users can describe a goal in plain language and have Vellum generate a working agent, automatically handling model selection, prompting, and integration wiring.', - shortDescription: 'Describe a goal in plain language and Vellum builds the agent.', - source: { - url: 'https://www.vellum.ai/blog/introducing-vellum-for-agents', - label: 'Introducing Vellum for Agents', - asOf: '2026-07-02', - }, - }, - { - title: '$20M Series A, followed by a consumer pivot', - description: - "Vellum raised a $20M Series A in July 2025 (on top of a 2023 YC seed) to grow its enterprise platform. Since then, the company has separately launched a rebranded 'Personal Intelligence' consumer assistant product, open-sourced under MIT license on GitHub.", - shortDescription: '$20M Series A, then a separate consumer product launch.', - source: { - url: 'https://www.vellum.ai/blog/announcing-our-20m-series-a', - label: 'Announcing our $20m Series A: Vellum', - asOf: '2026-07-02', - }, - }, ], limitations: [ { @@ -94,14 +72,14 @@ export const vellumProfile: CompetitorProfile = { }, }, { - title: 'Deprecated legacy workflow nodes', + title: 'No native spreadsheet-style data tables', description: - 'As of the January 2026 changelog, Vellum deprecated Merge, Conditional, and Output Nodes from the workflow builder UI (replaced by Merge Strategy, Ports, and Workflow Outputs respectively). Existing workflows using them continue to run but new instances can no longer be created.', - shortDescription: - 'Legacy Merge/Conditional/Output nodes retired in favor of newer equivalents.', + 'No Vellum documentation describes a native, spreadsheet-like data-table object with persistent rows/columns, keyboard navigation, or per-row writes from workflows; the closest documented tabular handling is uploading CSV/XLS files and extracting or generating structured output within workflow nodes, unlike Sim, which has a built-in Tables feature.', + shortDescription: 'No documented table/spreadsheet feature; CSV upload and extraction only.', source: { - url: 'https://docs.vellum.ai/changelog/2026/2026-01', - label: 'Vellum Changelog: January 2026', + url: 'https://docs.vellum.ai/developers/workflows-sdk/tutorials/document-data-extraction', + label: + 'Vellum Docs: Document Data Extraction tutorial (the closest documented tabular-data handling)', asOf: '2026-07-02', }, }, diff --git a/apps/sim/lib/compare/data/competitors/workato.ts b/apps/sim/lib/compare/data/competitors/workato.ts index 247a5b55316..8e9fd6a00a4 100644 --- a/apps/sim/lib/compare/data/competitors/workato.ts +++ b/apps/sim/lib/compare/data/competitors/workato.ts @@ -44,13 +44,13 @@ export const workatoProfile: CompetitorProfile = { }, }, { - title: 'Enterprise MCP server hosting', + title: "Pre-built departmental agents ('Genies')", description: - 'Workato exposes existing recipes and workflows as MCP tools through pre-built and remote/cloud-hosted MCP servers, letting any MCP-compatible client (Claude, ChatGPT, Agent Studio) dynamically discover and call enterprise workflows as agent tools without custom integration code.', - shortDescription: 'Recipes and workflows exposed as MCP tools for any compatible client.', + 'Agent Studio ships ready-made departmental agents (Genies) for IT, Sales, HR, Support, CX, and Marketing that customers can deploy and customize directly, alongside the option to build a custom agent from scratch.', + shortDescription: 'Ready-made departmental agents (Genies) for IT, Sales, HR, and more.', source: { - url: 'https://docs.workato.com/mcp.html', - label: 'MCP | Workato docs', + url: 'https://docs.workato.com/agentic/agentic.html', + label: 'Agentic | Workato docs', asOf: '2026-07-02', }, }, @@ -69,8 +69,9 @@ export const workatoProfile: CompetitorProfile = { { title: 'Bring-Your-Own-LLM for Agent Studio', description: - "Customers can power Genies with their own OpenAI or Anthropic API credentials instead of Workato's managed model contracts, giving direct control over LLM cost and vendor choice for agent workloads.", - shortDescription: 'Genies can run on customer-supplied OpenAI or Anthropic API keys.', + "Customers can power Genies with their own OpenAI or Anthropic API credentials instead of Workato's managed model contracts, giving direct control over LLM cost and vendor choice for agent workloads. This BYOLLM option is scoped to two providers and to Agent Studio specifically; Sim's own BYOK support spans any provider across every block, exempts usage from metered credit caps, and automatically round-robins multiple stored keys for the same provider.", + shortDescription: + 'Genies can run on customer-supplied OpenAI or Anthropic API keys, narrower in scope than Sim.', source: { url: 'https://www.workato.com/product-hub/changelog/bring-your-own-llm-byollm-support-for-agent-studio/', label: 'Bring Your Own LLM (BYOLLM) Support for Agent Studio | Workato Product Hub', @@ -123,6 +124,17 @@ export const workatoProfile: CompetitorProfile = { asOf: '2026-07-02', }, }, + { + title: 'SSO limited to SAML, no confirmed OIDC', + description: + "Workato's SSO documentation covers SAML with just-in-time provisioning and SAML role sync; it does not mention OIDC anywhere, so there is no public confirmation Workato supports it. Sim supports both SAML 2.0 and OIDC single sign-on.", + shortDescription: 'Documented SSO is SAML-only, with no confirmed OIDC support.', + source: { + url: 'https://docs.workato.com/user-accounts-and-teams/single-sign-on.html', + label: 'Workato Docs: Enable Single Sign-On for a Workato Workspace', + asOf: '2026-07-02', + }, + }, ], facts: { platform: { diff --git a/apps/sim/lib/compare/data/competitors/zapier.ts b/apps/sim/lib/compare/data/competitors/zapier.ts index 0e5cb9f56b8..34fe0616d6f 100644 --- a/apps/sim/lib/compare/data/competitors/zapier.ts +++ b/apps/sim/lib/compare/data/competitors/zapier.ts @@ -50,7 +50,7 @@ export const zapierProfile: CompetitorProfile = { { title: 'Zapier Copilot (natural-language build assistant)', description: - 'Copilot lets users describe an automation or agent in plain language and generates a draft Zap or agent, including custom code steps to fill integration gaps. In open beta.', + 'Copilot (open beta) lets users describe an automation or agent in plain language and generates a draft Zap or agent, including custom code steps to fill integration gaps. Sim offers the same natural-language building capability via Chat and in-editor Copilot.', shortDescription: 'Describe an automation and Copilot builds the Zap or agent for you.', source: { url: 'https://zapier.com/blog/zapier-copilot-guide/', @@ -58,17 +58,6 @@ export const zapierProfile: CompetitorProfile = { asOf: '2026-07-02', }, }, - { - title: 'Multi-provider LLM support across Zaps/Agents/Chatbots', - description: - 'Zapier lets users choose among OpenAI (GPT), Anthropic (Claude), and Google (Gemini) models inside AI-powered steps and chatbots, with BYOK for OpenAI, Anthropic, Gemini, and Azure OpenAI keys.', - shortDescription: 'Choose OpenAI, Anthropic, or Google models, with BYOK support.', - source: { - url: 'https://zapier.com/blog/ai-models-on-zapier/', - label: 'Which AI models can you automate on Zapier?', - asOf: '2026-07-02', - }, - }, ], limitations: [ { @@ -85,8 +74,9 @@ export const zapierProfile: CompetitorProfile = { { title: 'Task-based pricing scales quickly with usage', description: - 'Every Zap action step, and every MCP tool call at 2 tasks each, consumes a metered task, so pricing and plan tier are driven by execution volume rather than a flat seat or workflow count. Costs rise fast as usage grows.', - shortDescription: 'Costs scale with execution volume, not a flat seat count.', + "Every Zap action step, and every MCP tool call at 2 tasks each, consumes a metered task, so pricing and plan tier are driven by execution volume rather than a flat seat or workflow count. There's no way to fully exempt usage from the task count: even bringing your own AI provider key only covers AI-powered steps and chatbots, not the underlying app-action tasks. Costs rise fast as usage grows with no BYOK path around the per-task metering.", + shortDescription: + 'Costs scale with task volume; BYOK only covers AI steps, not app-action tasks.', source: { url: 'https://www.activepieces.com/blog/zapier-pricing', label: 'Zapier Pricing Breakdown (third-party analysis)', @@ -108,7 +98,7 @@ export const zapierProfile: CompetitorProfile = { title: 'No documented data-residency choice', description: "Zapier's infrastructure runs on AWS in the United States, with no selectable regional data residency or EU-only hosting option for standard customers.", - shortDescription: 'Runs on AWS in the US only, no regional hosting choice.', + shortDescription: 'No selectable region; standard customers are US-only (AWS).', source: { url: 'https://zapier.com/security-compliance', label: 'Zapier Security & Compliance (via search cache)', From 586b97fd1744ea1a112df02219a5efcedd5a0cfb Mon Sep 17 00:00:00 2001 From: Waleed Date: Mon, 6 Jul 2026 17:01:15 -0700 Subject: [PATCH 23/35] feat(attio): add attribute tools + fix API alignment gaps (#5445) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(attio): add attribute tools + fix API alignment gaps - Add 4 new tools: attio_list_attributes, get_attribute, create_attribute, update_attribute - Add missing completed_at field to task tools - Fix note tags output to match Attio's actual response shape (workspace-member vs record tags) - Fix attribute outputs missing is_default_value_enabled, default_value, relationship - Fix create_comment sending both entry and thread_id (Attio requires exactly one) - Wire attribute pagination + task sort into the block * fix(attio): address review feedback on attribute tools - Throw on invalid config JSON instead of silently overwriting with {} (create_attribute, update_attribute) - create_attribute: omit config field entirely when not provided instead of always sending {} - Add "Leave unchanged" option to Required/Unique dropdowns so update_attribute no longer clears existing constraints on unrelated field updates - Fix stale sort param description on list_tasks (was missing completed_at:asc/desc variants) * fix(attio): fix silent update-clobber bugs + complete comment mutual-exclusion - create_comment: support all 3 of Attio's mutually-exclusive comment targets (thread_id, record, entry), not just thread_id/entry - Fix update_task silently clearing is_completed on unrelated field updates (taskIsCompletedUpdate now defaults to "leave unchanged") - Fix update_list silently resetting workspace_access to full-access on unrelated field updates (listWorkspaceAccessUpdate, same pattern) - create_attribute: restore config:{} as always-required per Attio's schema (previously omitted it entirely, which is invalid on create) - create_record/update_record/assert_record/list_records: throw on invalid values/filter/sorts JSON instead of silently substituting {} (was a silent data-loss risk on malformed input) - get_task: drop stray Content-Type header on a GET request - types.ts: remove two dead unreferenced interfaces, fix workspaceMemberAccess type (was string, is actually an array) * fix(attio): gate operation-specific param mapping on current operation Params like taskIsCompleted/listWorkspaceAccess/attributeIsMultiselect/ attributeIsArchived are only meant for one specific operation, but their mapping wasn't checking params.operation. A stale value persisted in block state from a prior operation selection (e.g. switching the dropdown from create_task to update_task) could leak through and override the "leave unchanged" default on the other operation. * fix(attio): explicit comment target selector + attribute archived tri-state - Add "Leave unchanged" default to attributeIsArchived dropdown, same pattern as isRequired/isUnique, so a stale archived flag from an earlier edit can't unarchive an attribute on an unrelated update - create_comment: replace field-presence inference (which let stale record fields hijack a list-entry comment or vice versa) with an explicit commentTarget selector (List Entry / Record / Reply to Thread). Only the fields for the selected target are ever forwarded * fix(attio): gate remaining unscoped param mappings + comment-target back-compat - taskFilterCompleted (list_tasks filter) was mapped to isCompleted for every operation; gate it to list_tasks so it can't override the isCompleted value on an unrelated update_task call - Generic threadId (get_thread) was unconditionally forwarded and create_comment prioritizes thread_id first, so a leftover threadId from configuring get_thread could silently hijack a comment meant for a list entry or record; gate it to get_thread - Default commentTarget to the pre-existing behavior (entry, or thread if commentThreadId is set) when absent, so blocks saved before this field existed keep working instead of throwing * fix(attio): gate every param mapping by its operation, eliminate stale-value class of bugs The params() function reuses cleanParams keys (title, content, apiSlug, filter, sorts, recordId, entryId, list, object, threadId, ...) across several unrelated operation families. Every mapping was unconditional on presence alone, so a stale value left in block state from a previously selected operation could silently leak into an unrelated request and overwrite the intended value (last-write-wins on a shared key). Rewrote the whole function to gate every line by params.operation against the exact operation set its subBlock's condition exposes it under, closing this entire bug class in one pass instead of patching individual instances as they were found in review. Also split attributeIsRequired/attributeIsUnique into create-only (default false) and update-only (tri-state, default "leave unchanged") variants — the shared field let an explicit Yes/No choice from create_attribute carry over and silently change constraints on an unrelated update_attribute call. * fix(attio): preserve legacy taskIsCompleted/listWorkspaceAccess on update taskIsCompleted and listWorkspaceAccess pre-date this PR as fields shared between create and update operations (confirmed present on origin/staging). Splitting them into create/update variants earlier this session meant existing saved blocks with a value stored under the legacy field name would silently stop applying it on update_task / update_list once the new -Update field (always undefined for old blocks) took over. Fall back to the legacy field when the new field is untouched, so old saved workflows keep behaving exactly as before. --- apps/sim/blocks/blocks/attio.ts | 722 ++++++++++++++++++++--- apps/sim/tools/attio/assert_record.ts | 4 +- apps/sim/tools/attio/create_attribute.ts | 157 +++++ apps/sim/tools/attio/create_comment.ts | 45 +- apps/sim/tools/attio/create_note.ts | 4 +- apps/sim/tools/attio/create_record.ts | 2 +- apps/sim/tools/attio/create_task.ts | 1 + apps/sim/tools/attio/get_attribute.ts | 87 +++ apps/sim/tools/attio/get_note.ts | 4 +- apps/sim/tools/attio/get_task.ts | 2 +- apps/sim/tools/attio/index.ts | 4 + apps/sim/tools/attio/list_attributes.ts | 124 ++++ apps/sim/tools/attio/list_notes.ts | 4 +- apps/sim/tools/attio/list_records.ts | 4 +- apps/sim/tools/attio/list_tasks.ts | 4 +- apps/sim/tools/attio/types.ts | 227 +++++-- apps/sim/tools/attio/update_attribute.ts | 150 +++++ apps/sim/tools/attio/update_record.ts | 2 +- apps/sim/tools/attio/update_task.ts | 1 + apps/sim/tools/registry.ts | 8 + 20 files changed, 1423 insertions(+), 133 deletions(-) create mode 100644 apps/sim/tools/attio/create_attribute.ts create mode 100644 apps/sim/tools/attio/get_attribute.ts create mode 100644 apps/sim/tools/attio/list_attributes.ts create mode 100644 apps/sim/tools/attio/update_attribute.ts diff --git a/apps/sim/blocks/blocks/attio.ts b/apps/sim/blocks/blocks/attio.ts index 3016a905700..e991d1a5dee 100644 --- a/apps/sim/blocks/blocks/attio.ts +++ b/apps/sim/blocks/blocks/attio.ts @@ -64,6 +64,10 @@ export const AttioBlock: BlockConfig = { { label: 'Create Webhook', id: 'create_webhook' }, { label: 'Update Webhook', id: 'update_webhook' }, { label: 'Delete Webhook', id: 'delete_webhook' }, + { label: 'List Attributes', id: 'list_attributes' }, + { label: 'Get Attribute', id: 'get_attribute' }, + { label: 'Create Attribute', id: 'create_attribute' }, + { label: 'Update Attribute', id: 'update_attribute' }, ], value: () => 'list_records', }, @@ -430,7 +434,19 @@ YYYY-MM-DDTHH:mm:ss.SSSZ { label: 'Yes', id: 'true' }, ], value: () => 'false', - condition: { field: 'operation', value: ['create_task', 'update_task'] }, + condition: { field: 'operation', value: 'create_task' }, + }, + { + id: 'taskIsCompletedUpdate', + title: 'Completed', + type: 'dropdown', + options: [ + { label: 'Leave unchanged', id: 'unchanged' }, + { label: 'No', id: 'false' }, + { label: 'Yes', id: 'true' }, + ], + value: () => 'unchanged', + condition: { field: 'operation', value: 'update_task' }, }, { id: 'taskLinkedRecords', @@ -662,7 +678,20 @@ Return ONLY the JSON array. No explanations, no markdown, no extra text. { label: 'Read Only', id: 'read-only' }, ], value: () => 'full-access', - condition: { field: 'operation', value: ['create_list', 'update_list'] }, + condition: { field: 'operation', value: 'create_list' }, + }, + { + id: 'listWorkspaceAccessUpdate', + title: 'Workspace Access', + type: 'dropdown', + options: [ + { label: 'Leave unchanged', id: 'unchanged' }, + { label: 'Full Access', id: 'full-access' }, + { label: 'Read & Write', id: 'read-and-write' }, + { label: 'Read Only', id: 'read-only' }, + ], + value: () => 'unchanged', + condition: { field: 'operation', value: 'update_list' }, }, // List entry fields @@ -823,29 +852,97 @@ Return ONLY the JSON array. No explanations, no markdown, no extra text. condition: { field: 'operation', value: 'create_comment' }, required: { field: 'operation', value: 'create_comment' }, }, + { + id: 'commentTarget', + title: 'Comment On', + type: 'dropdown', + options: [ + { label: 'List Entry', id: 'entry' }, + { label: 'Record', id: 'record' }, + { label: 'Reply to Thread', id: 'thread' }, + ], + value: () => 'entry', + condition: { field: 'operation', value: 'create_comment' }, + }, { id: 'commentList', title: 'List', type: 'short-input', placeholder: 'List ID or slug', - condition: { field: 'operation', value: 'create_comment' }, - required: { field: 'operation', value: 'create_comment' }, + condition: { + field: 'operation', + value: 'create_comment', + and: { field: 'commentTarget', value: 'entry' }, + }, + required: { + field: 'operation', + value: 'create_comment', + and: { field: 'commentTarget', value: 'entry' }, + }, }, { id: 'commentEntryId', title: 'Entry ID', type: 'short-input', placeholder: 'List entry ID to comment on', - condition: { field: 'operation', value: 'create_comment' }, - required: { field: 'operation', value: 'create_comment' }, + condition: { + field: 'operation', + value: 'create_comment', + and: { field: 'commentTarget', value: 'entry' }, + }, + required: { + field: 'operation', + value: 'create_comment', + and: { field: 'commentTarget', value: 'entry' }, + }, + }, + { + id: 'commentRecordObject', + title: 'Record Object', + type: 'short-input', + placeholder: 'Object ID or slug the record belongs to', + condition: { + field: 'operation', + value: 'create_comment', + and: { field: 'commentTarget', value: 'record' }, + }, + required: { + field: 'operation', + value: 'create_comment', + and: { field: 'commentTarget', value: 'record' }, + }, + }, + { + id: 'commentRecordId', + title: 'Record ID', + type: 'short-input', + placeholder: 'Record ID to comment on directly', + condition: { + field: 'operation', + value: 'create_comment', + and: { field: 'commentTarget', value: 'record' }, + }, + required: { + field: 'operation', + value: 'create_comment', + and: { field: 'commentTarget', value: 'record' }, + }, }, { id: 'commentThreadId', title: 'Thread ID', type: 'short-input', - placeholder: 'Reply to thread (optional, omit to start new)', - condition: { field: 'operation', value: 'create_comment' }, - mode: 'advanced', + placeholder: 'Thread ID to reply to', + condition: { + field: 'operation', + value: 'create_comment', + and: { field: 'commentTarget', value: 'thread' }, + }, + required: { + field: 'operation', + value: 'create_comment', + and: { field: 'commentTarget', value: 'thread' }, + }, }, { id: 'commentCreatedAt', @@ -983,6 +1080,208 @@ workspace-member.created }, }, + // Attribute fields + { + id: 'attributeTarget', + title: 'Target', + type: 'dropdown', + options: [ + { label: 'Object', id: 'objects' }, + { label: 'List', id: 'lists' }, + ], + value: () => 'objects', + condition: { + field: 'operation', + value: ['list_attributes', 'get_attribute', 'create_attribute', 'update_attribute'], + }, + required: { + field: 'operation', + value: ['list_attributes', 'get_attribute', 'create_attribute', 'update_attribute'], + }, + }, + { + id: 'attributeIdentifier', + title: 'Object or List ID/Slug', + type: 'short-input', + placeholder: 'e.g. people, companies', + condition: { + field: 'operation', + value: ['list_attributes', 'get_attribute', 'create_attribute', 'update_attribute'], + }, + required: { + field: 'operation', + value: ['list_attributes', 'get_attribute', 'create_attribute', 'update_attribute'], + }, + }, + { + id: 'attributeId', + title: 'Attribute ID or Slug', + type: 'short-input', + placeholder: 'e.g. email_addresses', + condition: { field: 'operation', value: ['get_attribute', 'update_attribute'] }, + required: { field: 'operation', value: ['get_attribute', 'update_attribute'] }, + }, + { + id: 'attributeTitle', + title: 'Title', + type: 'short-input', + placeholder: 'e.g. Lead Source', + condition: { field: 'operation', value: ['create_attribute', 'update_attribute'] }, + required: { field: 'operation', value: 'create_attribute' }, + }, + { + id: 'attributeApiSlug', + title: 'API Slug', + type: 'short-input', + placeholder: 'e.g. lead_source', + condition: { field: 'operation', value: ['create_attribute', 'update_attribute'] }, + required: { field: 'operation', value: 'create_attribute' }, + }, + { + id: 'attributeType', + title: 'Type', + type: 'dropdown', + options: [ + { label: 'Text', id: 'text' }, + { label: 'Number', id: 'number' }, + { label: 'Checkbox', id: 'checkbox' }, + { label: 'Currency', id: 'currency' }, + { label: 'Date', id: 'date' }, + { label: 'Timestamp', id: 'timestamp' }, + { label: 'Rating', id: 'rating' }, + { label: 'Status', id: 'status' }, + { label: 'Select', id: 'select' }, + { label: 'Record Reference', id: 'record-reference' }, + { label: 'Actor Reference', id: 'actor-reference' }, + { label: 'Location', id: 'location' }, + { label: 'Domain', id: 'domain' }, + { label: 'Email Address', id: 'email-address' }, + { label: 'Phone Number', id: 'phone-number' }, + ], + condition: { field: 'operation', value: 'create_attribute' }, + required: { field: 'operation', value: 'create_attribute' }, + }, + { + id: 'attributeDescription', + title: 'Description', + type: 'long-input', + placeholder: 'Describe the attribute', + condition: { field: 'operation', value: ['create_attribute', 'update_attribute'] }, + mode: 'advanced', + }, + { + id: 'attributeIsRequired', + title: 'Required', + type: 'dropdown', + options: [ + { label: 'No', id: 'false' }, + { label: 'Yes', id: 'true' }, + ], + value: () => 'false', + condition: { field: 'operation', value: 'create_attribute' }, + mode: 'advanced', + }, + { + id: 'attributeIsRequiredUpdate', + title: 'Required', + type: 'dropdown', + options: [ + { label: 'Leave unchanged', id: 'unchanged' }, + { label: 'No', id: 'false' }, + { label: 'Yes', id: 'true' }, + ], + value: () => 'unchanged', + condition: { field: 'operation', value: 'update_attribute' }, + mode: 'advanced', + }, + { + id: 'attributeIsUnique', + title: 'Unique', + type: 'dropdown', + options: [ + { label: 'No', id: 'false' }, + { label: 'Yes', id: 'true' }, + ], + value: () => 'false', + condition: { field: 'operation', value: 'create_attribute' }, + mode: 'advanced', + }, + { + id: 'attributeIsUniqueUpdate', + title: 'Unique', + type: 'dropdown', + options: [ + { label: 'Leave unchanged', id: 'unchanged' }, + { label: 'No', id: 'false' }, + { label: 'Yes', id: 'true' }, + ], + value: () => 'unchanged', + condition: { field: 'operation', value: 'update_attribute' }, + mode: 'advanced', + }, + { + id: 'attributeIsMultiselect', + title: 'Multiselect', + type: 'dropdown', + options: [ + { label: 'No', id: 'false' }, + { label: 'Yes', id: 'true' }, + ], + value: () => 'false', + condition: { field: 'operation', value: 'create_attribute' }, + mode: 'advanced', + }, + { + id: 'attributeIsArchived', + title: 'Archived', + type: 'dropdown', + options: [ + { label: 'Leave unchanged', id: 'unchanged' }, + { label: 'No', id: 'false' }, + { label: 'Yes', id: 'true' }, + ], + value: () => 'unchanged', + condition: { field: 'operation', value: 'update_attribute' }, + mode: 'advanced', + }, + { + id: 'attributeConfig', + title: 'Config', + type: 'code', + placeholder: '{"default_currency_code": "USD", "display_type": "text"}', + condition: { field: 'operation', value: ['create_attribute', 'update_attribute'] }, + mode: 'advanced', + wandConfig: { + enabled: true, + maintainHistory: true, + prompt: `Generate Attio attribute type configuration as a JSON object. + +### CONTEXT +{context} + +### CRITICAL INSTRUCTION +Return ONLY the JSON object. No explanations, no markdown, no extra text. + +### EXAMPLES +Currency: {"default_currency_code": "USD", "display_type": "text"} +Record reference: {"allowed_objects": ["people", "companies"]}`, + placeholder: 'Describe the attribute configuration...', + generationType: 'json-object', + }, + }, + { + id: 'attributeShowArchived', + title: 'Show Archived', + type: 'dropdown', + options: [ + { label: 'No', id: 'false' }, + { label: 'Yes', id: 'true' }, + ], + value: () => 'false', + condition: { field: 'operation', value: 'list_attributes' }, + mode: 'advanced', + }, + // Shared limit { id: 'limit', @@ -1000,6 +1299,7 @@ workspace-member.created 'query_list_entries', 'list_threads', 'list_webhooks', + 'list_attributes', ], }, }, @@ -1018,9 +1318,23 @@ workspace-member.created 'query_list_entries', 'list_threads', 'list_webhooks', + 'list_attributes', ], }, }, + { + id: 'taskSort', + title: 'Sort', + type: 'dropdown', + options: [ + { label: 'Created At (asc)', id: 'created_at:asc' }, + { label: 'Created At (desc)', id: 'created_at:desc' }, + { label: 'Completed At (asc)', id: 'completed_at:asc' }, + { label: 'Completed At (desc)', id: 'completed_at:desc' }, + ], + condition: { field: 'operation', value: 'list_tasks' }, + mode: 'advanced', + }, ...getTrigger('attio_record_created').subBlocks, ...getTrigger('attio_record_updated').subBlocks, ...getTrigger('attio_record_deleted').subBlocks, @@ -1116,6 +1430,10 @@ workspace-member.created 'attio_create_webhook', 'attio_update_webhook', 'attio_delete_webhook', + 'attio_list_attributes', + 'attio_get_attribute', + 'attio_create_attribute', + 'attio_update_attribute', ], config: { tool: (params) => `attio_${params.operation}`, @@ -1123,92 +1441,285 @@ workspace-member.created const cleanParams: Record = { oauthCredential: params.oauthCredential, } - - // Record params - if (params.objectType) cleanParams.objectType = params.objectType - if (params.recordId) cleanParams.recordId = params.recordId - if (params.matchingAttribute) cleanParams.matchingAttribute = params.matchingAttribute - if (params.values) cleanParams.values = params.values - if (params.filter) cleanParams.filter = params.filter - if (params.sorts) cleanParams.sorts = params.sorts - if (params.query) cleanParams.query = params.query - if (params.objects) cleanParams.objects = params.objects + const op = params.operation + + // Record params — each field is only sent for the operations whose subBlock condition + // actually exposes it, so a stale value left over from switching operations never leaks + // into an unrelated request (cleanParams keys are reused across several operation families). + if ( + [ + 'list_records', + 'get_record', + 'create_record', + 'update_record', + 'delete_record', + 'assert_record', + ].includes(op) && + params.objectType + ) + cleanParams.objectType = params.objectType + if (['get_record', 'update_record', 'delete_record'].includes(op) && params.recordId) + cleanParams.recordId = params.recordId + if (op === 'assert_record' && params.matchingAttribute) + cleanParams.matchingAttribute = params.matchingAttribute + if (['create_record', 'update_record', 'assert_record'].includes(op) && params.values) + cleanParams.values = params.values + if (op === 'list_records' && params.filter) cleanParams.filter = params.filter + if (op === 'list_records' && params.sorts) cleanParams.sorts = params.sorts + if (op === 'search_records' && params.query) cleanParams.query = params.query + if (op === 'search_records' && params.objects) cleanParams.objects = params.objects // Note params - if (params.noteParentObject) cleanParams.parentObject = params.noteParentObject - if (params.noteParentRecordId) cleanParams.parentRecordId = params.noteParentRecordId - if (params.noteTitle) cleanParams.title = params.noteTitle - if (params.noteContent) cleanParams.content = params.noteContent - if (params.noteFormat) cleanParams.format = params.noteFormat - if (params.noteId) cleanParams.noteId = params.noteId - if (params.noteCreatedAt) cleanParams.createdAt = params.noteCreatedAt - if (params.noteMeetingId) cleanParams.meetingId = params.noteMeetingId + if (['list_notes', 'create_note'].includes(op) && params.noteParentObject) + cleanParams.parentObject = params.noteParentObject + if (['list_notes', 'create_note'].includes(op) && params.noteParentRecordId) + cleanParams.parentRecordId = params.noteParentRecordId + if (op === 'create_note' && params.noteTitle) cleanParams.title = params.noteTitle + if (op === 'create_note' && params.noteContent) cleanParams.content = params.noteContent + if (op === 'create_note' && params.noteFormat) cleanParams.format = params.noteFormat + if (op === 'create_note' && params.noteCreatedAt) + cleanParams.createdAt = params.noteCreatedAt + if (op === 'create_note' && params.noteMeetingId) + cleanParams.meetingId = params.noteMeetingId + if (['get_note', 'delete_note'].includes(op) && params.noteId) + cleanParams.noteId = params.noteId // Task params - if (params.taskContent) cleanParams.content = params.taskContent - if (params.taskDeadline) cleanParams.deadlineAt = params.taskDeadline - if (params.taskIsCompleted !== undefined) + if (op === 'create_task' && params.taskContent) cleanParams.content = params.taskContent + if (['create_task', 'update_task'].includes(op) && params.taskDeadline) + cleanParams.deadlineAt = params.taskDeadline + if (op === 'create_task' && params.taskIsCompleted !== undefined) cleanParams.isCompleted = params.taskIsCompleted === 'true' || params.taskIsCompleted === true - if (params.taskLinkedRecords) cleanParams.linkedRecords = params.taskLinkedRecords - if (params.taskAssignees) cleanParams.assignees = params.taskAssignees - if (params.taskId) cleanParams.taskId = params.taskId - if (params.taskFilterObject) cleanParams.linkedObject = params.taskFilterObject - if (params.taskFilterRecordId) cleanParams.linkedRecordId = params.taskFilterRecordId - if (params.taskFilterAssignee) cleanParams.assignee = params.taskFilterAssignee - if (params.taskFilterCompleted && params.taskFilterCompleted !== 'all') + if (op === 'update_task') { + // taskIsCompletedUpdate is the current control; taskIsCompleted is a fallback for blocks + // saved before the split, when that field applied to both create_task and update_task. + if ( + params.taskIsCompletedUpdate !== undefined && + params.taskIsCompletedUpdate !== 'unchanged' + ) { + cleanParams.isCompleted = + params.taskIsCompletedUpdate === 'true' || params.taskIsCompletedUpdate === true + } else if ( + params.taskIsCompletedUpdate === undefined && + params.taskIsCompleted !== undefined + ) { + cleanParams.isCompleted = + params.taskIsCompleted === 'true' || params.taskIsCompleted === true + } + } + if (['create_task', 'update_task'].includes(op) && params.taskLinkedRecords) + cleanParams.linkedRecords = params.taskLinkedRecords + if (['create_task', 'update_task'].includes(op) && params.taskAssignees) + cleanParams.assignees = params.taskAssignees + if (['get_task', 'update_task', 'delete_task'].includes(op) && params.taskId) + cleanParams.taskId = params.taskId + if (op === 'list_tasks' && params.taskFilterObject) + cleanParams.linkedObject = params.taskFilterObject + if (op === 'list_tasks' && params.taskFilterRecordId) + cleanParams.linkedRecordId = params.taskFilterRecordId + if (op === 'list_tasks' && params.taskFilterAssignee) + cleanParams.assignee = params.taskFilterAssignee + if ( + op === 'list_tasks' && + params.taskFilterCompleted && + params.taskFilterCompleted !== 'all' + ) cleanParams.isCompleted = params.taskFilterCompleted === 'true' + if (op === 'list_tasks' && params.taskSort) cleanParams.sort = params.taskSort // Object params - if (params.objectIdOrSlug) cleanParams.object = params.objectIdOrSlug - if (params.objectApiSlug) cleanParams.apiSlug = params.objectApiSlug - if (params.objectSingularNoun) cleanParams.singularNoun = params.objectSingularNoun - if (params.objectPluralNoun) cleanParams.pluralNoun = params.objectPluralNoun + if (['get_object', 'update_object'].includes(op) && params.objectIdOrSlug) + cleanParams.object = params.objectIdOrSlug + if (['create_object', 'update_object'].includes(op) && params.objectApiSlug) + cleanParams.apiSlug = params.objectApiSlug + if (['create_object', 'update_object'].includes(op) && params.objectSingularNoun) + cleanParams.singularNoun = params.objectSingularNoun + if (['create_object', 'update_object'].includes(op) && params.objectPluralNoun) + cleanParams.pluralNoun = params.objectPluralNoun // List params - if (params.listIdOrSlug) cleanParams.list = params.listIdOrSlug - if (params.listName) cleanParams.name = params.listName - if (params.listParentObject) cleanParams.parentObject = params.listParentObject - if (params.listApiSlug) cleanParams.apiSlug = params.listApiSlug - if (params.listWorkspaceAccess) cleanParams.workspaceAccess = params.listWorkspaceAccess + if ( + [ + 'get_list', + 'update_list', + 'query_list_entries', + 'get_list_entry', + 'create_list_entry', + 'update_list_entry', + 'delete_list_entry', + ].includes(op) && + params.listIdOrSlug + ) + cleanParams.list = params.listIdOrSlug + if (['create_list', 'update_list'].includes(op) && params.listName) + cleanParams.name = params.listName + if (op === 'create_list' && params.listParentObject) + cleanParams.parentObject = params.listParentObject + if (['create_list', 'update_list'].includes(op) && params.listApiSlug) + cleanParams.apiSlug = params.listApiSlug + if (op === 'create_list' && params.listWorkspaceAccess) + cleanParams.workspaceAccess = params.listWorkspaceAccess + if (op === 'update_list') { + // listWorkspaceAccessUpdate is the current control; listWorkspaceAccess is a fallback + // for blocks saved before the split, when that field applied to both create and update. + if ( + params.listWorkspaceAccessUpdate && + params.listWorkspaceAccessUpdate !== 'unchanged' + ) { + cleanParams.workspaceAccess = params.listWorkspaceAccessUpdate + } else if (!params.listWorkspaceAccessUpdate && params.listWorkspaceAccess) { + cleanParams.workspaceAccess = params.listWorkspaceAccess + } + } // List entry params - if (params.entryId) cleanParams.entryId = params.entryId - if (params.entryParentRecordId) cleanParams.parentRecordId = params.entryParentRecordId - if (params.entryParentObject) cleanParams.parentObject = params.entryParentObject - if (params.entryValues) cleanParams.entryValues = params.entryValues - if (params.entryFilter) cleanParams.filter = params.entryFilter - if (params.entrySorts) cleanParams.sorts = params.entrySorts + if ( + ['get_list_entry', 'update_list_entry', 'delete_list_entry'].includes(op) && + params.entryId + ) + cleanParams.entryId = params.entryId + if (op === 'create_list_entry' && params.entryParentRecordId) + cleanParams.parentRecordId = params.entryParentRecordId + if (op === 'create_list_entry' && params.entryParentObject) + cleanParams.parentObject = params.entryParentObject + if (['create_list_entry', 'update_list_entry'].includes(op) && params.entryValues) + cleanParams.entryValues = params.entryValues + if (op === 'query_list_entries' && params.entryFilter) + cleanParams.filter = params.entryFilter + if (op === 'query_list_entries' && params.entrySorts) cleanParams.sorts = params.entrySorts // Member params - if (params.memberId) cleanParams.memberId = params.memberId + if (op === 'get_member' && params.memberId) cleanParams.memberId = params.memberId // Comment params - if (params.commentContent) cleanParams.content = params.commentContent - if (params.commentFormat) cleanParams.format = params.commentFormat - if (params.commentAuthorType) cleanParams.authorType = params.commentAuthorType - if (params.commentAuthorId) cleanParams.authorId = params.commentAuthorId - if (params.commentList) cleanParams.list = params.commentList - if (params.commentEntryId) cleanParams.entryId = params.commentEntryId - if (params.commentThreadId) cleanParams.threadId = params.commentThreadId - if (params.commentCreatedAt) cleanParams.createdAt = params.commentCreatedAt - if (params.commentId) cleanParams.commentId = params.commentId + if (op === 'create_comment') { + if (params.commentContent) cleanParams.content = params.commentContent + if (params.commentFormat) cleanParams.format = params.commentFormat + if (params.commentAuthorType) cleanParams.authorType = params.commentAuthorType + if (params.commentAuthorId) cleanParams.authorId = params.commentAuthorId + // Blocks saved before commentTarget existed have no value for it — fall back to the + // pre-existing behavior (entry-based comment, or thread reply if a thread ID was set). + const commentTarget = + params.commentTarget ?? (params.commentThreadId ? 'thread' : 'entry') + if (commentTarget === 'entry') { + if (params.commentList) cleanParams.list = params.commentList + if (params.commentEntryId) cleanParams.entryId = params.commentEntryId + } else if (commentTarget === 'record') { + if (params.commentRecordObject) cleanParams.recordObject = params.commentRecordObject + if (params.commentRecordId) cleanParams.recordId = params.commentRecordId + } else if (commentTarget === 'thread') { + if (params.commentThreadId) cleanParams.threadId = params.commentThreadId + } + if (params.commentCreatedAt) cleanParams.createdAt = params.commentCreatedAt + } + if (['get_comment', 'delete_comment'].includes(op) && params.commentId) + cleanParams.commentId = params.commentId // Thread params - if (params.threadId) cleanParams.threadId = params.threadId - if (params.threadFilterRecordId) cleanParams.recordId = params.threadFilterRecordId - if (params.threadFilterObject) cleanParams.object = params.threadFilterObject - if (params.threadFilterEntryId) cleanParams.entryId = params.threadFilterEntryId - if (params.threadFilterList) cleanParams.list = params.threadFilterList + if (op === 'get_thread' && params.threadId) cleanParams.threadId = params.threadId + if (op === 'list_threads' && params.threadFilterRecordId) + cleanParams.recordId = params.threadFilterRecordId + if (op === 'list_threads' && params.threadFilterObject) + cleanParams.object = params.threadFilterObject + if (op === 'list_threads' && params.threadFilterEntryId) + cleanParams.entryId = params.threadFilterEntryId + if (op === 'list_threads' && params.threadFilterList) + cleanParams.list = params.threadFilterList // Webhook params - if (params.webhookId) cleanParams.webhookId = params.webhookId - if (params.webhookTargetUrl) cleanParams.targetUrl = params.webhookTargetUrl - if (params.webhookSubscriptions) cleanParams.subscriptions = params.webhookSubscriptions - - // Shared params - if (params.limit) cleanParams.limit = Number(params.limit) - if (params.offset) cleanParams.offset = Number(params.offset) + if (['get_webhook', 'update_webhook', 'delete_webhook'].includes(op) && params.webhookId) + cleanParams.webhookId = params.webhookId + if (['create_webhook', 'update_webhook'].includes(op) && params.webhookTargetUrl) + cleanParams.targetUrl = params.webhookTargetUrl + if (['create_webhook', 'update_webhook'].includes(op) && params.webhookSubscriptions) + cleanParams.subscriptions = params.webhookSubscriptions + + // Attribute params + const attributeOps = [ + 'list_attributes', + 'get_attribute', + 'create_attribute', + 'update_attribute', + ] + if (attributeOps.includes(op) && params.attributeTarget) + cleanParams.target = params.attributeTarget + if (attributeOps.includes(op) && params.attributeIdentifier) + cleanParams.identifier = params.attributeIdentifier + if (['get_attribute', 'update_attribute'].includes(op) && params.attributeId) + cleanParams.attribute = params.attributeId + if (['create_attribute', 'update_attribute'].includes(op) && params.attributeTitle) + cleanParams.title = params.attributeTitle + if (['create_attribute', 'update_attribute'].includes(op) && params.attributeApiSlug) + cleanParams.apiSlug = params.attributeApiSlug + if (op === 'create_attribute' && params.attributeType) + cleanParams.type = params.attributeType + if (['create_attribute', 'update_attribute'].includes(op) && params.attributeDescription) + cleanParams.description = params.attributeDescription + if (op === 'create_attribute' && params.attributeIsRequired !== undefined) + cleanParams.isRequired = + params.attributeIsRequired === 'true' || params.attributeIsRequired === true + if ( + op === 'update_attribute' && + params.attributeIsRequiredUpdate !== undefined && + params.attributeIsRequiredUpdate !== 'unchanged' + ) + cleanParams.isRequired = + params.attributeIsRequiredUpdate === 'true' || params.attributeIsRequiredUpdate === true + if (op === 'create_attribute' && params.attributeIsUnique !== undefined) + cleanParams.isUnique = + params.attributeIsUnique === 'true' || params.attributeIsUnique === true + if ( + op === 'update_attribute' && + params.attributeIsUniqueUpdate !== undefined && + params.attributeIsUniqueUpdate !== 'unchanged' + ) + cleanParams.isUnique = + params.attributeIsUniqueUpdate === 'true' || params.attributeIsUniqueUpdate === true + if (op === 'create_attribute' && params.attributeIsMultiselect !== undefined) + cleanParams.isMultiselect = + params.attributeIsMultiselect === 'true' || params.attributeIsMultiselect === true + if ( + op === 'update_attribute' && + params.attributeIsArchived !== undefined && + params.attributeIsArchived !== 'unchanged' + ) + cleanParams.isArchived = + params.attributeIsArchived === 'true' || params.attributeIsArchived === true + if (['create_attribute', 'update_attribute'].includes(op) && params.attributeConfig) + cleanParams.config = params.attributeConfig + if (op === 'list_attributes' && params.attributeShowArchived !== undefined) + cleanParams.showArchived = + params.attributeShowArchived === 'true' || params.attributeShowArchived === true + + // Shared pagination params — only meaningful for list-style operations + if ( + [ + 'list_records', + 'search_records', + 'list_notes', + 'list_tasks', + 'query_list_entries', + 'list_threads', + 'list_webhooks', + 'list_attributes', + ].includes(op) && + params.limit + ) + cleanParams.limit = Number(params.limit) + if ( + [ + 'list_records', + 'list_notes', + 'list_tasks', + 'query_list_entries', + 'list_threads', + 'list_webhooks', + 'list_attributes', + ].includes(op) && + params.offset + ) + cleanParams.offset = Number(params.offset) return cleanParams }, @@ -1237,6 +1748,7 @@ workspace-member.created taskContent: { type: 'string', description: 'Task content' }, taskDeadline: { type: 'string', description: 'Task deadline' }, taskIsCompleted: { type: 'string', description: 'Task completion status' }, + taskIsCompletedUpdate: { type: 'string', description: 'Task completion status (update)' }, taskLinkedRecords: { type: 'json', description: 'Linked records JSON array' }, taskAssignees: { type: 'json', description: 'Assignees JSON array' }, taskId: { type: 'string', description: 'Task ID' }, @@ -1249,6 +1761,10 @@ workspace-member.created listParentObject: { type: 'string', description: 'List parent object' }, listApiSlug: { type: 'string', description: 'List API slug' }, listWorkspaceAccess: { type: 'string', description: 'List workspace-level access' }, + listWorkspaceAccessUpdate: { + type: 'string', + description: 'List workspace-level access (update)', + }, entryId: { type: 'string', description: 'List entry ID' }, entryParentRecordId: { type: 'string', description: 'Record ID for list entry' }, entryParentObject: { type: 'string', description: 'Record object type for list entry' }, @@ -1260,8 +1776,11 @@ workspace-member.created commentFormat: { type: 'string', description: 'Comment format' }, commentAuthorType: { type: 'string', description: 'Comment author type' }, commentAuthorId: { type: 'string', description: 'Comment author ID' }, + commentTarget: { type: 'string', description: 'What the comment is attached to' }, commentList: { type: 'string', description: 'List for comment' }, commentEntryId: { type: 'string', description: 'Entry ID for comment' }, + commentRecordObject: { type: 'string', description: 'Object for record comment' }, + commentRecordId: { type: 'string', description: 'Record ID for record comment' }, commentThreadId: { type: 'string', description: 'Thread ID to reply to' }, commentCreatedAt: { type: 'string', description: 'Comment creation timestamp (backdate)' }, commentId: { type: 'string', description: 'Comment ID' }, @@ -1269,6 +1788,34 @@ workspace-member.created webhookId: { type: 'string', description: 'Webhook ID' }, webhookTargetUrl: { type: 'string', description: 'Webhook target URL' }, webhookSubscriptions: { type: 'json', description: 'Webhook event subscriptions' }, + attributeTarget: { + type: 'string', + description: 'Whether the attribute is on an object or list', + }, + attributeIdentifier: { type: 'string', description: 'The object or list ID or slug' }, + attributeId: { type: 'string', description: 'The attribute ID or slug' }, + attributeTitle: { type: 'string', description: 'The attribute display title' }, + attributeApiSlug: { type: 'string', description: 'The attribute API slug' }, + attributeType: { type: 'string', description: 'The attribute value type' }, + attributeDescription: { type: 'string', description: 'The attribute description' }, + attributeIsRequired: { type: 'string', description: 'Whether the attribute is required' }, + attributeIsRequiredUpdate: { + type: 'string', + description: 'Whether the attribute is required (update)', + }, + attributeIsUnique: { type: 'string', description: 'Whether the attribute is unique' }, + attributeIsUniqueUpdate: { + type: 'string', + description: 'Whether the attribute is unique (update)', + }, + attributeIsMultiselect: { type: 'string', description: 'Whether the attribute is multiselect' }, + attributeIsArchived: { type: 'string', description: 'Whether the attribute is archived' }, + attributeConfig: { type: 'json', description: 'Type-dependent attribute configuration' }, + attributeShowArchived: { + type: 'string', + description: 'Whether to include archived attributes', + }, + taskSort: { type: 'string', description: 'Task list sort order' }, limit: { type: 'string', description: 'Maximum number of results' }, offset: { type: 'string', description: 'Number of results to skip for pagination' }, }, @@ -1289,6 +1836,7 @@ workspace-member.created content: { type: 'string', description: 'Task or note content' }, deadlineAt: { type: 'string', description: 'Task deadline' }, isCompleted: { type: 'boolean', description: 'Task completion status' }, + completedAt: { type: 'string', description: 'When the task was completed' }, linkedRecords: { type: 'json', description: 'Linked records' }, assignees: { type: 'json', description: 'Task assignees' }, objects: { type: 'json', description: 'Array of objects' }, @@ -1322,6 +1870,32 @@ workspace-member.created deleted: { type: 'boolean', description: 'Whether the item was deleted' }, createdAt: { type: 'string', description: 'When the item was created' }, success: { type: 'boolean', description: 'Whether the operation succeeded' }, + attributes: { type: 'json', description: 'Array of attributes' }, + attributeId: { type: 'string', description: 'The attribute ID' }, + description: { type: 'string', description: 'The attribute description' }, + type: { type: 'string', description: 'The attribute value type' }, + isSystemAttribute: { + type: 'boolean', + description: 'Whether this is a built-in system attribute', + }, + isWritable: { type: 'boolean', description: 'Whether the attribute can be written to' }, + isRequired: { type: 'boolean', description: 'Whether the attribute is required' }, + isUnique: { type: 'boolean', description: 'Whether the attribute enforces uniqueness' }, + isMultiselect: { type: 'boolean', description: 'Whether the attribute is multiselect' }, + isDefaultValueEnabled: { + type: 'boolean', + description: 'Whether this attribute has a default value enabled', + }, + isArchived: { type: 'boolean', description: 'Whether the attribute is archived' }, + defaultValue: { + type: 'json', + description: 'The default value for this attribute, if enabled', + }, + relationship: { + type: 'json', + description: 'The related attribute, if this attribute is part of a relationship', + }, + config: { type: 'json', description: 'Type-dependent attribute configuration' }, }, } diff --git a/apps/sim/tools/attio/assert_record.ts b/apps/sim/tools/attio/assert_record.ts index 00864d740a0..71707cdae99 100644 --- a/apps/sim/tools/attio/assert_record.ts +++ b/apps/sim/tools/attio/assert_record.ts @@ -56,11 +56,11 @@ export const attioAssertRecordTool: ToolConfig { - let values: Record = {} + let values: Record try { values = typeof params.values === 'string' ? JSON.parse(params.values) : params.values } catch { - values = {} + throw new Error('Invalid JSON provided for record values') } return { data: { values } } }, diff --git a/apps/sim/tools/attio/create_attribute.ts b/apps/sim/tools/attio/create_attribute.ts new file mode 100644 index 00000000000..fe362358658 --- /dev/null +++ b/apps/sim/tools/attio/create_attribute.ts @@ -0,0 +1,157 @@ +import { createLogger } from '@sim/logger' +import type { ToolConfig } from '@/tools/types' +import type { AttioCreateAttributeParams, AttioCreateAttributeResponse } from './types' +import { ATTRIBUTE_OUTPUT_PROPERTIES } from './types' + +const logger = createLogger('AttioCreateAttribute') + +export const attioCreateAttributeTool: ToolConfig< + AttioCreateAttributeParams, + AttioCreateAttributeResponse +> = { + id: 'attio_create_attribute', + name: 'Attio Create Attribute', + description: 'Create a new attribute (schema field) on an Attio object or list', + version: '1.0.0', + + oauth: { + required: true, + provider: 'attio', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'The OAuth access token for the Attio API', + }, + target: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Whether to create the attribute on an object or a list: objects or lists', + }, + identifier: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The object or list ID or slug (e.g. people, companies)', + }, + title: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The attribute display title', + }, + apiSlug: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The attribute API slug (unique, snake_case)', + }, + type: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + 'The attribute value type (e.g. text, number, checkbox, currency, date, timestamp, rating, status, select, record-reference, actor-reference, location, domain, email-address, phone-number)', + }, + description: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'A description of the attribute', + }, + isRequired: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether new records must provide a value (default false)', + }, + isUnique: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether the attribute enforces uniqueness on new data (default false)', + }, + isMultiselect: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether the attribute supports multiple values (default false)', + }, + config: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'JSON object of type-dependent configuration (e.g. currency or record-reference settings)', + }, + }, + + request: { + url: (params) => + `https://api.attio.com/v2/${params.target.trim()}/${params.identifier.trim()}/attributes`, + method: 'POST', + headers: (params) => ({ + Authorization: `Bearer ${params.accessToken}`, + 'Content-Type': 'application/json', + }), + body: (params) => { + const data: Record = { + title: params.title, + api_slug: params.apiSlug, + description: params.description ?? null, + type: params.type, + is_required: params.isRequired ?? false, + is_unique: params.isUnique ?? false, + is_multiselect: params.isMultiselect ?? false, + // `config` is a required key on Attio's create-attribute request body (even though its + // nested fields are only required for type-dependent configs like currency/record-reference). + config: {}, + } + if (params.config) { + try { + data.config = + typeof params.config === 'string' ? JSON.parse(params.config) : params.config + } catch { + throw new Error('Invalid JSON provided for attribute config') + } + } + return { data } + }, + }, + + transformResponse: async (response) => { + const data = await response.json() + if (!response.ok) { + logger.error('Attio API request failed', { data, status: response.status }) + throw new Error(data.message || 'Failed to create attribute') + } + const attr = data.data + return { + success: true, + output: { + attributeId: attr.id?.attribute_id ?? null, + title: attr.title ?? null, + apiSlug: attr.api_slug ?? null, + description: attr.description ?? null, + type: attr.type ?? null, + isSystemAttribute: attr.is_system_attribute ?? false, + isWritable: attr.is_writable ?? false, + isRequired: attr.is_required ?? false, + isUnique: attr.is_unique ?? false, + isMultiselect: attr.is_multiselect ?? false, + isDefaultValueEnabled: attr.is_default_value_enabled ?? false, + isArchived: attr.is_archived ?? false, + defaultValue: attr.default_value ?? null, + relationship: attr.relationship ?? null, + config: attr.config ?? null, + createdAt: attr.created_at ?? null, + }, + } + }, + + outputs: ATTRIBUTE_OUTPUT_PROPERTIES, +} diff --git a/apps/sim/tools/attio/create_comment.ts b/apps/sim/tools/attio/create_comment.ts index e9fe596ed9a..b7fec326125 100644 --- a/apps/sim/tools/attio/create_comment.ts +++ b/apps/sim/tools/attio/create_comment.ts @@ -52,21 +52,37 @@ export const attioCreateCommentTool: ToolConfig< }, list: { type: 'string', - required: true, + required: false, visibility: 'user-or-llm', - description: 'The list ID or slug the entry belongs to', + description: + 'The list ID or slug the entry belongs to (used with entryId; omit if threadId or recordId is set)', }, entryId: { type: 'string', - required: true, + required: false, + visibility: 'user-or-llm', + description: + 'The list entry ID to comment on (used with list; omit if threadId or recordId is set)', + }, + recordObject: { + type: 'string', + required: false, visibility: 'user-or-llm', - description: 'The entry ID to comment on', + description: + 'The object ID or slug the record belongs to (used with recordId; omit if threadId or entryId is set)', + }, + recordId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'The record ID to comment on directly (used with recordObject; omit if threadId or entryId is set)', }, threadId: { type: 'string', required: false, visibility: 'user-or-llm', - description: 'Thread ID to reply to (omit to start a new thread)', + description: 'Thread ID to reply to (omit to start a new thread on a record or list entry)', }, createdAt: { type: 'string', @@ -91,12 +107,25 @@ export const attioCreateCommentTool: ToolConfig< type: params.authorType, id: params.authorId, }, - entry: { + } + // Attio's comment body accepts exactly one of `thread_id`, `record`, or `entry` — mutually exclusive. + if (params.threadId) { + data.thread_id = params.threadId + } else if (params.recordObject && params.recordId) { + data.record = { + object: params.recordObject, + record_id: params.recordId, + } + } else if (params.list && params.entryId) { + data.entry = { list: params.list, entry_id: params.entryId, - }, + } + } else { + throw new Error( + 'Must provide either threadId, both recordObject and recordId, or both list and entryId' + ) } - if (params.threadId) data.thread_id = params.threadId if (params.createdAt) data.created_at = params.createdAt return { data } }, diff --git a/apps/sim/tools/attio/create_note.ts b/apps/sim/tools/attio/create_note.ts index 3124959a92f..792366db663 100644 --- a/apps/sim/tools/attio/create_note.ts +++ b/apps/sim/tools/attio/create_note.ts @@ -1,7 +1,7 @@ import { createLogger } from '@sim/logger' import type { ToolConfig } from '@/tools/types' import type { AttioCreateNoteParams, AttioCreateNoteResponse } from './types' -import { NOTE_OUTPUT_PROPERTIES } from './types' +import { mapNoteTags, NOTE_OUTPUT_PROPERTIES } from './types' const logger = createLogger('AttioCreateNote') @@ -105,7 +105,7 @@ export const attioCreateNoteTool: ToolConfig = + { + id: 'attio_get_attribute', + name: 'Attio Get Attribute', + description: 'Get a single attribute (schema field) on an Attio object or list', + version: '1.0.0', + + oauth: { + required: true, + provider: 'attio', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'The OAuth access token for the Attio API', + }, + target: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Whether the attribute belongs to an object or a list: objects or lists', + }, + identifier: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The object or list ID or slug (e.g. people, companies)', + }, + attribute: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The attribute ID or slug', + }, + }, + + request: { + url: (params) => + `https://api.attio.com/v2/${params.target.trim()}/${params.identifier.trim()}/attributes/${params.attribute.trim()}`, + method: 'GET', + headers: (params) => ({ + Authorization: `Bearer ${params.accessToken}`, + }), + }, + + transformResponse: async (response) => { + const data = await response.json() + if (!response.ok) { + logger.error('Attio API request failed', { data, status: response.status }) + throw new Error(data.message || 'Failed to get attribute') + } + const attr = data.data + return { + success: true, + output: { + attributeId: attr.id?.attribute_id ?? null, + title: attr.title ?? null, + apiSlug: attr.api_slug ?? null, + description: attr.description ?? null, + type: attr.type ?? null, + isSystemAttribute: attr.is_system_attribute ?? false, + isWritable: attr.is_writable ?? false, + isRequired: attr.is_required ?? false, + isUnique: attr.is_unique ?? false, + isMultiselect: attr.is_multiselect ?? false, + isDefaultValueEnabled: attr.is_default_value_enabled ?? false, + isArchived: attr.is_archived ?? false, + defaultValue: attr.default_value ?? null, + relationship: attr.relationship ?? null, + config: attr.config ?? null, + createdAt: attr.created_at ?? null, + }, + } + }, + + outputs: ATTRIBUTE_OUTPUT_PROPERTIES, + } diff --git a/apps/sim/tools/attio/get_note.ts b/apps/sim/tools/attio/get_note.ts index 0a9ad4a8e76..b253e975bc0 100644 --- a/apps/sim/tools/attio/get_note.ts +++ b/apps/sim/tools/attio/get_note.ts @@ -1,7 +1,7 @@ import { createLogger } from '@sim/logger' import type { ToolConfig } from '@/tools/types' import type { AttioGetNoteParams, AttioGetNoteResponse } from './types' -import { NOTE_OUTPUT_PROPERTIES } from './types' +import { mapNoteTags, NOTE_OUTPUT_PROPERTIES } from './types' const logger = createLogger('AttioGetNote') @@ -56,7 +56,7 @@ export const attioGetNoteTool: ToolConfig ({ Authorization: `Bearer ${params.accessToken}`, - 'Content-Type': 'application/json', }), }, @@ -66,6 +65,7 @@ export const attioGetTaskTool: ToolConfig = { + id: 'attio_list_attributes', + name: 'Attio List Attributes', + description: 'List the attributes (schema fields) defined on an Attio object or list', + version: '1.0.0', + + oauth: { + required: true, + provider: 'attio', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'The OAuth access token for the Attio API', + }, + target: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Whether the attributes belong to an object or a list: objects or lists', + }, + identifier: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The object or list ID or slug (e.g. people, companies)', + }, + limit: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Maximum number of attributes to return', + }, + offset: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Number of attributes to skip for pagination', + }, + showArchived: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether to include archived attributes (default false)', + }, + }, + + request: { + url: (params) => { + const searchParams = new URLSearchParams() + if (params.limit != null) searchParams.set('limit', String(params.limit)) + if (params.offset != null) searchParams.set('offset', String(params.offset)) + if (params.showArchived != null) + searchParams.set('show_archived', String(params.showArchived)) + const qs = searchParams.toString() + return `https://api.attio.com/v2/${params.target.trim()}/${params.identifier.trim()}/attributes${qs ? `?${qs}` : ''}` + }, + method: 'GET', + headers: (params) => ({ + Authorization: `Bearer ${params.accessToken}`, + }), + }, + + transformResponse: async (response) => { + const data = await response.json() + if (!response.ok) { + logger.error('Attio API request failed', { data, status: response.status }) + throw new Error(data.message || 'Failed to list attributes') + } + const attributes = (data.data ?? []).map((attr: Record) => { + const id = attr.id as { attribute_id?: string } | undefined + return { + attributeId: id?.attribute_id ?? null, + title: (attr.title as string) ?? null, + apiSlug: (attr.api_slug as string) ?? null, + description: (attr.description as string) ?? null, + type: (attr.type as string) ?? null, + isSystemAttribute: (attr.is_system_attribute as boolean) ?? false, + isWritable: (attr.is_writable as boolean) ?? false, + isRequired: (attr.is_required as boolean) ?? false, + isUnique: (attr.is_unique as boolean) ?? false, + isMultiselect: (attr.is_multiselect as boolean) ?? false, + isDefaultValueEnabled: (attr.is_default_value_enabled as boolean) ?? false, + isArchived: (attr.is_archived as boolean) ?? false, + defaultValue: (attr.default_value as Record) ?? null, + relationship: (attr.relationship as Record) ?? null, + config: (attr.config as Record) ?? null, + createdAt: (attr.created_at as string) ?? null, + } + }) + return { + success: true, + output: { + attributes, + count: attributes.length, + }, + } + }, + + outputs: { + attributes: { + type: 'array', + description: 'Array of attributes', + items: { + type: 'object', + properties: ATTRIBUTE_OUTPUT_PROPERTIES, + }, + }, + count: { type: 'number', description: 'Number of attributes returned' }, + }, +} diff --git a/apps/sim/tools/attio/list_notes.ts b/apps/sim/tools/attio/list_notes.ts index fe7230752d8..ffd013cfec3 100644 --- a/apps/sim/tools/attio/list_notes.ts +++ b/apps/sim/tools/attio/list_notes.ts @@ -1,7 +1,7 @@ import { createLogger } from '@sim/logger' import type { ToolConfig } from '@/tools/types' import type { AttioListNotesParams, AttioListNotesResponse } from './types' -import { NOTE_OUTPUT_PROPERTIES } from './types' +import { mapNoteTags, NOTE_OUTPUT_PROPERTIES } from './types' const logger = createLogger('AttioListNotes') @@ -81,7 +81,7 @@ export const attioListNotesTool: ToolConfig ({ + type: tag.type ?? null, + workspaceMemberId: tag.workspace_member_id ?? null, + object: tag.object ?? null, + recordId: tag.record_id ?? null, + })) +} + /** Reusable actor shape returned by the Attio API */ export const ACTOR_OUTPUT_PROPERTIES = { type: { @@ -70,8 +96,22 @@ export const NOTE_OUTPUT_PROPERTIES = { items: { type: 'object', properties: { - type: { type: 'string', description: 'The tag type (e.g. workspace-member)' }, - workspaceMemberId: { type: 'string', description: 'The workspace member ID of the tagger' }, + type: { type: 'string', description: 'The tag type (workspace-member or record)' }, + workspaceMemberId: { + type: 'string', + description: 'The workspace member ID (present when type is workspace-member)', + optional: true, + }, + object: { + type: 'string', + description: 'The tagged object slug (present when type is record)', + optional: true, + }, + recordId: { + type: 'string', + description: 'The tagged record ID (present when type is record)', + optional: true, + }, }, }, }, @@ -89,6 +129,7 @@ export const TASK_OUTPUT_PROPERTIES = { content: { type: 'string', description: 'The task content' }, deadlineAt: { type: 'string', description: 'The task deadline', optional: true }, isCompleted: { type: 'boolean', description: 'Whether the task is completed' }, + completedAt: { type: 'string', description: 'When the task was completed', optional: true }, linkedRecords: { type: 'array', description: 'Records linked to this task', @@ -162,6 +203,43 @@ export const MEMBER_OUTPUT_PROPERTIES = { createdAt: { type: 'string', description: 'When the member was added' }, } as const satisfies Record +/** Shared output properties for Attio attributes */ +export const ATTRIBUTE_OUTPUT_PROPERTIES = { + attributeId: { type: 'string', description: 'The attribute ID' }, + title: { type: 'string', description: 'The attribute display title' }, + apiSlug: { type: 'string', description: 'The attribute API slug' }, + description: { type: 'string', description: 'The attribute description', optional: true }, + type: { + type: 'string', + description: 'The attribute value type (e.g. text, number, select, record-reference)', + }, + isSystemAttribute: { + type: 'boolean', + description: 'Whether this is a built-in system attribute', + }, + isWritable: { type: 'boolean', description: 'Whether the attribute can be written to' }, + isRequired: { type: 'boolean', description: 'Whether new records must provide a value' }, + isUnique: { type: 'boolean', description: 'Whether the attribute enforces uniqueness' }, + isMultiselect: { type: 'boolean', description: 'Whether the attribute supports multiple values' }, + isDefaultValueEnabled: { + type: 'boolean', + description: 'Whether this attribute has a default value enabled', + }, + isArchived: { type: 'boolean', description: 'Whether the attribute is archived' }, + defaultValue: { + type: 'json', + description: 'The default value for this attribute, if enabled', + optional: true, + }, + relationship: { + type: 'json', + description: 'The related attribute, if this attribute is part of a relationship', + optional: true, + }, + config: { type: 'json', description: 'Type-dependent attribute configuration', optional: true }, + createdAt: { type: 'string', description: 'When the attribute was created' }, +} as const satisfies Record + /** Shared output properties for Attio comments */ export const COMMENT_OUTPUT_PROPERTIES = { commentId: { type: 'string', description: 'The comment ID' }, @@ -248,32 +326,6 @@ interface AttioRecord { values: Record } -/** Raw Attio note shape from the API */ -interface AttioNote { - id: { workspace_id: string; note_id: string } - parent_object: string - parent_record_id: string - title: string - content_plaintext: string - content_markdown: string - meeting_id: string | null - tags: unknown[] - created_by_actor: unknown - created_at: string -} - -/** Raw Attio task shape from the API */ -interface AttioTask { - id: { workspace_id: string; task_id: string } - content_plaintext: string - deadline_at: string | null - is_completed: boolean - linked_records: Array<{ target_object_id: string; target_record_id: string }> - assignees: Array<{ referenced_actor_type: string; referenced_actor_id: string }> - created_by_actor: unknown - created_at: string -} - /** Params for listing/querying records */ export interface AttioListRecordsParams { accessToken: string @@ -453,7 +505,7 @@ export interface AttioListNotesResponse extends ToolResponse { contentPlaintext: string | null contentMarkdown: string | null meetingId: string | null - tags: unknown[] + tags: NoteTagOutput[] createdByActor: unknown createdAt: string | null }> @@ -471,7 +523,7 @@ export interface AttioCreateNoteResponse extends ToolResponse { contentPlaintext: string | null contentMarkdown: string | null meetingId: string | null - tags: unknown[] + tags: NoteTagOutput[] createdByActor: unknown createdAt: string | null } @@ -492,6 +544,7 @@ export interface AttioListTasksResponse extends ToolResponse { content: string | null deadlineAt: string | null isCompleted: boolean + completedAt: string | null linkedRecords: Array<{ targetObjectId: string | null; targetRecordId: string | null }> assignees: Array<{ type: string | null; id: string | null }> createdByActor: unknown @@ -508,6 +561,7 @@ export interface AttioCreateTaskResponse extends ToolResponse { content: string | null deadlineAt: string | null isCompleted: boolean + completedAt: string | null linkedRecords: Array<{ targetObjectId: string | null; targetRecordId: string | null }> assignees: Array<{ type: string | null; id: string | null }> createdByActor: unknown @@ -522,6 +576,7 @@ export interface AttioUpdateTaskResponse extends ToolResponse { content: string | null deadlineAt: string | null isCompleted: boolean + completedAt: string | null linkedRecords: Array<{ targetObjectId: string | null; targetRecordId: string | null }> assignees: Array<{ type: string | null; id: string | null }> createdByActor: unknown @@ -542,6 +597,7 @@ export interface AttioGetTaskResponse extends ToolResponse { content: string | null deadlineAt: string | null isCompleted: boolean + completedAt: string | null linkedRecords: Array<{ targetObjectId: string | null; targetRecordId: string | null }> assignees: Array<{ type: string | null; id: string | null }> createdByActor: unknown @@ -572,7 +628,7 @@ export interface AttioGetNoteResponse extends ToolResponse { contentPlaintext: string | null contentMarkdown: string | null meetingId: string | null - tags: unknown[] + tags: NoteTagOutput[] createdByActor: unknown createdAt: string | null } @@ -684,7 +740,7 @@ export interface AttioListListsResponse extends ToolResponse { name: string | null parentObject: string | null workspaceAccess: string | null - workspaceMemberAccess: string | null + workspaceMemberAccess: unknown createdByActor: { type: string | null; id: string | null } | null createdAt: string | null }> @@ -706,7 +762,7 @@ export interface AttioGetListResponse extends ToolResponse { name: string | null parentObject: string | null workspaceAccess: string | null - workspaceMemberAccess: string | null + workspaceMemberAccess: unknown createdByActor: { type: string | null; id: string | null } | null createdAt: string | null } @@ -730,7 +786,7 @@ export interface AttioCreateListResponse extends ToolResponse { name: string | null parentObject: string | null workspaceAccess: string | null - workspaceMemberAccess: string | null + workspaceMemberAccess: unknown createdByActor: { type: string | null; id: string | null } | null createdAt: string | null } @@ -754,7 +810,7 @@ export interface AttioUpdateListResponse extends ToolResponse { name: string | null parentObject: string | null workspaceAccess: string | null - workspaceMemberAccess: string | null + workspaceMemberAccess: unknown createdByActor: { type: string | null; id: string | null } | null createdAt: string | null } @@ -906,8 +962,10 @@ export interface AttioCreateCommentParams { format?: string authorType: string authorId: string - list: string - entryId: string + list?: string + entryId?: string + recordObject?: string + recordId?: string threadId?: string createdAt?: string } @@ -1098,6 +1156,97 @@ export interface AttioDeleteWebhookResponse extends ToolResponse { } } +/** Params for listing attributes on an object or list */ +export interface AttioListAttributesParams { + accessToken: string + target: string + identifier: string + limit?: number + offset?: number + showArchived?: boolean +} + +/** Attribute shape as returned in tool outputs (camelCase) */ +interface AttioAttributeOutput { + attributeId: string | null + title: string | null + apiSlug: string | null + description: string | null + type: string | null + isSystemAttribute: boolean + isWritable: boolean + isRequired: boolean + isUnique: boolean + isMultiselect: boolean + isDefaultValueEnabled: boolean + isArchived: boolean + defaultValue: Record | null + relationship: Record | null + config: Record | null + createdAt: string | null +} + +/** Response for listing attributes */ +export interface AttioListAttributesResponse extends ToolResponse { + output: { + attributes: AttioAttributeOutput[] + count: number + } +} + +/** Params for getting a single attribute */ +export interface AttioGetAttributeParams { + accessToken: string + target: string + identifier: string + attribute: string +} + +/** Response for getting a single attribute */ +export interface AttioGetAttributeResponse extends ToolResponse { + output: AttioAttributeOutput +} + +/** Params for creating an attribute */ +export interface AttioCreateAttributeParams { + accessToken: string + target: string + identifier: string + title: string + apiSlug: string + type: string + description?: string + isRequired?: boolean + isUnique?: boolean + isMultiselect?: boolean + config?: string +} + +/** Response for creating an attribute */ +export interface AttioCreateAttributeResponse extends ToolResponse { + output: AttioAttributeOutput +} + +/** Params for updating an attribute */ +export interface AttioUpdateAttributeParams { + accessToken: string + target: string + identifier: string + attribute: string + title?: string + apiSlug?: string + description?: string + isRequired?: boolean + isUnique?: boolean + isArchived?: boolean + config?: string +} + +/** Response for updating an attribute */ +export interface AttioUpdateAttributeResponse extends ToolResponse { + output: AttioAttributeOutput +} + export type AttioResponse = | AttioListRecordsResponse | AttioGetRecordResponse @@ -1140,3 +1289,7 @@ export type AttioResponse = | AttioCreateWebhookResponse | AttioUpdateWebhookResponse | AttioDeleteWebhookResponse + | AttioListAttributesResponse + | AttioGetAttributeResponse + | AttioCreateAttributeResponse + | AttioUpdateAttributeResponse diff --git a/apps/sim/tools/attio/update_attribute.ts b/apps/sim/tools/attio/update_attribute.ts new file mode 100644 index 00000000000..9cfd9c5d9d5 --- /dev/null +++ b/apps/sim/tools/attio/update_attribute.ts @@ -0,0 +1,150 @@ +import { createLogger } from '@sim/logger' +import type { ToolConfig } from '@/tools/types' +import type { AttioUpdateAttributeParams, AttioUpdateAttributeResponse } from './types' +import { ATTRIBUTE_OUTPUT_PROPERTIES } from './types' + +const logger = createLogger('AttioUpdateAttribute') + +export const attioUpdateAttributeTool: ToolConfig< + AttioUpdateAttributeParams, + AttioUpdateAttributeResponse +> = { + id: 'attio_update_attribute', + name: 'Attio Update Attribute', + description: 'Update an attribute (schema field) on an Attio object or list', + version: '1.0.0', + + oauth: { + required: true, + provider: 'attio', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'The OAuth access token for the Attio API', + }, + target: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Whether the attribute belongs to an object or a list: objects or lists', + }, + identifier: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The object or list ID or slug (e.g. people, companies)', + }, + attribute: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The attribute ID or slug to update', + }, + title: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'New attribute display title', + }, + apiSlug: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'New attribute API slug', + }, + description: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'New attribute description', + }, + isRequired: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether new records must provide a value', + }, + isUnique: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether the attribute enforces uniqueness on new data', + }, + isArchived: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Archive or unarchive the attribute', + }, + config: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'JSON object of type-dependent configuration', + }, + }, + + request: { + url: (params) => + `https://api.attio.com/v2/${params.target.trim()}/${params.identifier.trim()}/attributes/${params.attribute.trim()}`, + method: 'PATCH', + headers: (params) => ({ + Authorization: `Bearer ${params.accessToken}`, + 'Content-Type': 'application/json', + }), + body: (params) => { + const data: Record = {} + if (params.title != null) data.title = params.title + if (params.apiSlug != null) data.api_slug = params.apiSlug + if (params.description != null) data.description = params.description + if (params.isRequired != null) data.is_required = params.isRequired + if (params.isUnique != null) data.is_unique = params.isUnique + if (params.isArchived != null) data.is_archived = params.isArchived + if (params.config) { + try { + data.config = + typeof params.config === 'string' ? JSON.parse(params.config) : params.config + } catch { + throw new Error('Invalid JSON provided for attribute config') + } + } + return { data } + }, + }, + + transformResponse: async (response) => { + const data = await response.json() + if (!response.ok) { + logger.error('Attio API request failed', { data, status: response.status }) + throw new Error(data.message || 'Failed to update attribute') + } + const attr = data.data + return { + success: true, + output: { + attributeId: attr.id?.attribute_id ?? null, + title: attr.title ?? null, + apiSlug: attr.api_slug ?? null, + description: attr.description ?? null, + type: attr.type ?? null, + isSystemAttribute: attr.is_system_attribute ?? false, + isWritable: attr.is_writable ?? false, + isRequired: attr.is_required ?? false, + isUnique: attr.is_unique ?? false, + isMultiselect: attr.is_multiselect ?? false, + isDefaultValueEnabled: attr.is_default_value_enabled ?? false, + isArchived: attr.is_archived ?? false, + defaultValue: attr.default_value ?? null, + relationship: attr.relationship ?? null, + config: attr.config ?? null, + createdAt: attr.created_at ?? null, + }, + } + }, + + outputs: ATTRIBUTE_OUTPUT_PROPERTIES, +} diff --git a/apps/sim/tools/attio/update_record.ts b/apps/sim/tools/attio/update_record.ts index 21d613f9292..25548f46287 100644 --- a/apps/sim/tools/attio/update_record.ts +++ b/apps/sim/tools/attio/update_record.ts @@ -57,7 +57,7 @@ export const attioUpdateRecordTool: ToolConfig = { airtable_update_record: airtableUpdateRecordTool, airtable_upsert_records: airtableUpsertRecordsTool, attio_assert_record: attioAssertRecordTool, + attio_create_attribute: attioCreateAttributeTool, attio_create_comment: attioCreateCommentTool, attio_create_list: attioCreateListTool, attio_create_list_entry: attioCreateListEntryTool, @@ -6812,6 +6817,7 @@ export const tools: Record = { attio_delete_record: attioDeleteRecordTool, attio_delete_task: attioDeleteTaskTool, attio_delete_webhook: attioDeleteWebhookTool, + attio_get_attribute: attioGetAttributeTool, attio_get_comment: attioGetCommentTool, attio_get_list: attioGetListTool, attio_get_list_entry: attioGetListEntryTool, @@ -6822,6 +6828,7 @@ export const tools: Record = { attio_get_task: attioGetTaskTool, attio_get_thread: attioGetThreadTool, attio_get_webhook: attioGetWebhookTool, + attio_list_attributes: attioListAttributesTool, attio_list_lists: attioListListsTool, attio_list_members: attioListMembersTool, attio_list_notes: attioListNotesTool, @@ -6832,6 +6839,7 @@ export const tools: Record = { attio_list_webhooks: attioListWebhooksTool, attio_query_list_entries: attioQueryListEntriesTool, attio_search_records: attioSearchRecordsTool, + attio_update_attribute: attioUpdateAttributeTool, attio_update_list: attioUpdateListTool, attio_update_list_entry: attioUpdateListEntryTool, attio_update_object: attioUpdateObjectTool, From f111de31500dee75781b9cd9262412427b3b2d10 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Mon, 6 Jul 2026 17:04:22 -0700 Subject: [PATCH 24/35] improvement(tables): show error message tooltip on errored workflow and enrichment cells (#5449) --- .../table-grid/cells/cell-render.tsx | 27 ++++++++++++++----- 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.tsx index b5bdccd21db..be7e6046336 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.tsx @@ -13,12 +13,12 @@ import { SimResourceCell, type SimResourceType } from './sim-resource-cell' export type CellRenderKind = // Workflow-output cells | { kind: 'value'; text: string } - | { kind: 'block-error' } + | { kind: 'block-error'; message: string } | { kind: 'running' } | { kind: 'pending-upstream' } | { kind: 'queued' } | { kind: 'cancelled' } - | { kind: 'error' } + | { kind: 'error'; message: string | null } | { kind: 'waiting'; labels: string[] } | { kind: 'not-found' } | { kind: 'no-output' } @@ -68,7 +68,7 @@ export function resolveCellRender({ const blockRunning = blockId ? (exec?.runningBlockIds?.includes(blockId) ?? false) : false const groupHasBlockErrors = !!(exec?.blockErrors && Object.keys(exec.blockErrors).length > 0) - if (blockError) return { kind: 'block-error' } + if (blockError) return { kind: 'block-error', message: blockError } const inFlight = exec?.status === 'running' || exec?.status === 'queued' || exec?.status === 'pending' @@ -103,7 +103,7 @@ export function resolveCellRender({ return { kind: 'waiting', labels: waitingOnLabels } } if (exec?.status === 'cancelled') return { kind: 'cancelled' } - if (exec?.status === 'error') return { kind: 'error' } + if (exec?.status === 'error') return { kind: 'error', message: exec.error } // Enrichment ran to completion but matched nothing → "Not found". if (isEnrichmentOutput && exec?.status === 'completed') return { kind: 'not-found' } // Workflow output: the group's run completed but this block produced no @@ -249,12 +249,27 @@ export function CellRender({ kind, isEditing }: CellRenderProps): React.ReactEle ) case 'block-error': - case 'error': + case 'error': { + if (!kind.message) { + return ( + + + + ) + } return ( - + + + + + + + {kind.message} + ) + } case 'running': return ( From fb3f95d5dc2d4c98f0a4ff82a9e57994eaba8bbc Mon Sep 17 00:00:00 2001 From: Waleed Date: Mon, 6 Jul 2026 17:26:44 -0700 Subject: [PATCH 25/35] feat(aws): expand SES/STS/Secrets Manager tool coverage, fix API alignment gaps (#5450) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(aws): expand SES/STS/Secrets Manager tool coverage, fix API alignment gaps - SES: add suppression list management, email identity CRUD, template update, configuration set creation, custom verification email (10 new tools); fix silent httpsPolicy drop in create_configuration_set and unvalidated suppression reason enum in list_suppressed_destinations - STS: add AssumeRoleWithWebIdentity and AssumeRoleWithSAML (unsigned, no static credentials required); extend assume_role with policyArns/tags/transitiveTagKeys session params - Secrets Manager: add describe_secret, tag_resource, untag_resource, restore_secret, rotate_secret; fix list_secrets dropping rotation/version metadata fields; normalize tool versions to 1.0.0 and alphabetize registry entries All 31 tools verified param-by-param against live AWS API docs across two independent audit passes. * fix(aws): address review findings on SES config/identity and Secrets Manager rotation - ses_create_configuration_set: validate suppressedReasons against BOUNCE/COMPLAINT enum before calling AWS (was silently reaching AWS as a generic 500 for bad values); tags now a proper Zod array schema instead of a string with route-side JSON.parse - ses_create_email_identity: dkimSigningAttributes and tags now proper Zod object/array schemas instead of strings with route-side JSON.parse, matching the pattern used elsewhere (e.g. sts_assume_role tags, secrets_manager_tag_resource) - secrets_manager_rotate_secret: reject automaticallyAfterDays and scheduleExpression when both are supplied — AWS RotationRules accepts only one - sts createUnauthenticatedSTSClient: corrected a misleading comment claiming these calls are fully unsigned; the SDK still falls through its default credential provider chain * fix(ses): correct json input types for tags/dkimSigningAttributes The SES block declared the tags and dkimSigningAttributes block inputs as 'string' instead of 'json', so the generic block executor never parsed the JSON code-editor value before forwarding it — workflow runs sent a raw JSON string where the contract now expects a structured object/array, failing validation. Also corrected the corresponding tool param TypeScript types, which were still typed as string | null. * fix(ses): stop coercing switch string 'false' to true in create_configuration_set Boolean('false') evaluates to true, so turning off the reputationMetricsEnabled or sendingEnabled switch sent the opposite of the user's choice to SES. Match the established === 'true' string comparison pattern used elsewhere in the codebase. * fix(sts): stop double-parsing assume_role session tags input tags was declared as a 'json' block input, so the generic executor JSON.parse'd it before the switch-case handler ran — but that handler already converts the raw table-rows array (or a passthrough string) into the JSON string the sts_assume_role contract expects. Declaring it 'json' broke that conversion for non-string inputs. Reverted to 'string' so the handler's existing string/array disambiguation runs on the untouched raw value. * fix(sts): supply placeholder credentials to the unauthenticated client createUnauthenticatedSTSClient omitted credentials entirely, so the SDK's signing middleware fell through the default credential provider chain and threw CredentialsProviderError before the request was sent in any environment with no ambient AWS identity — even though AssumeRoleWithWebIdentity/AssumeRoleWithSAML never check the signature. Static placeholder credentials skip that resolution without granting or requiring any real IAM identity. --- .../docs/en/integrations/secrets_manager.mdx | 131 ++++- .../docs/content/docs/en/integrations/ses.mdx | 230 ++++++++- .../docs/content/docs/en/integrations/sts.mdx | 70 +++ .../secrets_manager/describe-secret/route.ts | 55 ++ .../secrets_manager/restore-secret/route.ts | 58 +++ .../secrets_manager/rotate-secret/route.ts | 66 +++ .../secrets_manager/tag-resource/route.ts | 59 +++ .../secrets_manager/untag-resource/route.ts | 55 ++ .../app/api/tools/secrets_manager/utils.ts | 127 ++++- .../ses/create-configuration-set/route.ts | 69 +++ .../tools/ses/create-email-identity/route.ts | 56 ++ .../tools/ses/delete-email-identity/route.ts | 51 ++ .../delete-suppressed-destination/route.ts | 51 ++ .../api/tools/ses/get-email-identity/route.ts | 51 ++ .../ses/get-suppressed-destination/route.ts | 51 ++ .../ses/list-suppressed-destinations/route.ts | 80 +++ .../ses/put-suppressed-destination/route.ts | 54 ++ .../send-custom-verification-email/route.ts | 55 ++ .../api/tools/ses/update-template/route.ts | 56 ++ apps/sim/app/api/tools/ses/utils.ts | 293 +++++++++++ .../tools/sts/assume-role-with-saml/route.ts | 55 ++ .../assume-role-with-web-identity/route.ts | 56 ++ .../app/api/tools/sts/assume-role/route.ts | 5 +- apps/sim/app/api/tools/sts/utils.ts | 147 +++++- apps/sim/blocks/blocks/secrets_manager.ts | 253 ++++++++- apps/sim/blocks/blocks/ses.ts | 479 +++++++++++++++++- apps/sim/blocks/blocks/sts.ts | 282 ++++++++++- .../aws/secrets-manager-describe-secret.ts | 65 +++ .../tools/aws/secrets-manager-list-secrets.ts | 11 + .../aws/secrets-manager-restore-secret.ts | 36 ++ .../aws/secrets-manager-rotate-secret.ts | 75 +++ .../tools/aws/secrets-manager-tag-resource.ts | 47 ++ .../aws/secrets-manager-untag-resource.ts | 44 ++ .../tools/aws/ses-create-configuration-set.ts | 89 ++++ .../tools/aws/ses-create-email-identity.ts | 70 +++ .../tools/aws/ses-delete-email-identity.ts | 38 ++ .../aws/ses-delete-suppressed-destination.ts | 40 ++ .../tools/aws/ses-get-email-identity.ts | 68 +++ .../aws/ses-get-suppressed-destination.ts | 44 ++ .../aws/ses-list-suppressed-destinations.ts | 52 ++ .../aws/ses-put-suppressed-destination.ts | 43 ++ .../aws/ses-send-custom-verification-email.ts | 42 ++ .../tools/aws/ses-update-template.ts | 37 ++ .../tools/aws/sts-assume-role-with-saml.ts | 61 +++ .../aws/sts-assume-role-with-web-identity.ts | 62 +++ .../contracts/tools/aws/sts-assume-role.ts | 29 +- apps/sim/lib/integrations/integrations.json | 78 ++- apps/sim/tools/registry.ts | 38 +- .../tools/secrets_manager/create_secret.ts | 2 +- .../tools/secrets_manager/delete_secret.ts | 2 +- .../tools/secrets_manager/describe_secret.ts | 152 ++++++ apps/sim/tools/secrets_manager/get_secret.ts | 2 +- apps/sim/tools/secrets_manager/index.ts | 10 + .../sim/tools/secrets_manager/list_secrets.ts | 5 +- .../tools/secrets_manager/restore_secret.ts | 79 +++ .../tools/secrets_manager/rotate_secret.ts | 124 +++++ .../sim/tools/secrets_manager/tag_resource.ts | 83 +++ apps/sim/tools/secrets_manager/types.ts | 106 ++++ .../tools/secrets_manager/untag_resource.ts | 83 +++ .../tools/secrets_manager/update_secret.ts | 2 +- .../sim/tools/ses/create_configuration_set.ts | 131 +++++ apps/sim/tools/ses/create_email_identity.ts | 106 ++++ apps/sim/tools/ses/delete_email_identity.ts | 73 +++ .../ses/delete_suppressed_destination.ts | 73 +++ apps/sim/tools/ses/get_email_identity.ts | 120 +++++ .../tools/ses/get_suppressed_destination.ts | 93 ++++ apps/sim/tools/ses/index.ts | 20 + .../tools/ses/list_suppressed_destinations.ts | 112 ++++ .../tools/ses/put_suppressed_destination.ts | 80 +++ .../ses/send_custom_verification_email.ts | 88 ++++ apps/sim/tools/ses/types.ts | 172 +++++++ apps/sim/tools/ses/update_template.ts | 88 ++++ apps/sim/tools/sts/assume_role.ts | 23 + apps/sim/tools/sts/assume_role_with_saml.ts | 145 ++++++ .../sts/assume_role_with_web_identity.ts | 144 ++++++ apps/sim/tools/sts/index.ts | 4 + apps/sim/tools/sts/types.ts | 58 +++ scripts/check-api-validation-contracts.ts | 4 +- 78 files changed, 6182 insertions(+), 66 deletions(-) create mode 100644 apps/sim/app/api/tools/secrets_manager/describe-secret/route.ts create mode 100644 apps/sim/app/api/tools/secrets_manager/restore-secret/route.ts create mode 100644 apps/sim/app/api/tools/secrets_manager/rotate-secret/route.ts create mode 100644 apps/sim/app/api/tools/secrets_manager/tag-resource/route.ts create mode 100644 apps/sim/app/api/tools/secrets_manager/untag-resource/route.ts create mode 100644 apps/sim/app/api/tools/ses/create-configuration-set/route.ts create mode 100644 apps/sim/app/api/tools/ses/create-email-identity/route.ts create mode 100644 apps/sim/app/api/tools/ses/delete-email-identity/route.ts create mode 100644 apps/sim/app/api/tools/ses/delete-suppressed-destination/route.ts create mode 100644 apps/sim/app/api/tools/ses/get-email-identity/route.ts create mode 100644 apps/sim/app/api/tools/ses/get-suppressed-destination/route.ts create mode 100644 apps/sim/app/api/tools/ses/list-suppressed-destinations/route.ts create mode 100644 apps/sim/app/api/tools/ses/put-suppressed-destination/route.ts create mode 100644 apps/sim/app/api/tools/ses/send-custom-verification-email/route.ts create mode 100644 apps/sim/app/api/tools/ses/update-template/route.ts create mode 100644 apps/sim/app/api/tools/sts/assume-role-with-saml/route.ts create mode 100644 apps/sim/app/api/tools/sts/assume-role-with-web-identity/route.ts create mode 100644 apps/sim/lib/api/contracts/tools/aws/secrets-manager-describe-secret.ts create mode 100644 apps/sim/lib/api/contracts/tools/aws/secrets-manager-restore-secret.ts create mode 100644 apps/sim/lib/api/contracts/tools/aws/secrets-manager-rotate-secret.ts create mode 100644 apps/sim/lib/api/contracts/tools/aws/secrets-manager-tag-resource.ts create mode 100644 apps/sim/lib/api/contracts/tools/aws/secrets-manager-untag-resource.ts create mode 100644 apps/sim/lib/api/contracts/tools/aws/ses-create-configuration-set.ts create mode 100644 apps/sim/lib/api/contracts/tools/aws/ses-create-email-identity.ts create mode 100644 apps/sim/lib/api/contracts/tools/aws/ses-delete-email-identity.ts create mode 100644 apps/sim/lib/api/contracts/tools/aws/ses-delete-suppressed-destination.ts create mode 100644 apps/sim/lib/api/contracts/tools/aws/ses-get-email-identity.ts create mode 100644 apps/sim/lib/api/contracts/tools/aws/ses-get-suppressed-destination.ts create mode 100644 apps/sim/lib/api/contracts/tools/aws/ses-list-suppressed-destinations.ts create mode 100644 apps/sim/lib/api/contracts/tools/aws/ses-put-suppressed-destination.ts create mode 100644 apps/sim/lib/api/contracts/tools/aws/ses-send-custom-verification-email.ts create mode 100644 apps/sim/lib/api/contracts/tools/aws/ses-update-template.ts create mode 100644 apps/sim/lib/api/contracts/tools/aws/sts-assume-role-with-saml.ts create mode 100644 apps/sim/lib/api/contracts/tools/aws/sts-assume-role-with-web-identity.ts create mode 100644 apps/sim/tools/secrets_manager/describe_secret.ts create mode 100644 apps/sim/tools/secrets_manager/restore_secret.ts create mode 100644 apps/sim/tools/secrets_manager/rotate_secret.ts create mode 100644 apps/sim/tools/secrets_manager/tag_resource.ts create mode 100644 apps/sim/tools/secrets_manager/untag_resource.ts create mode 100644 apps/sim/tools/ses/create_configuration_set.ts create mode 100644 apps/sim/tools/ses/create_email_identity.ts create mode 100644 apps/sim/tools/ses/delete_email_identity.ts create mode 100644 apps/sim/tools/ses/delete_suppressed_destination.ts create mode 100644 apps/sim/tools/ses/get_email_identity.ts create mode 100644 apps/sim/tools/ses/get_suppressed_destination.ts create mode 100644 apps/sim/tools/ses/list_suppressed_destinations.ts create mode 100644 apps/sim/tools/ses/put_suppressed_destination.ts create mode 100644 apps/sim/tools/ses/send_custom_verification_email.ts create mode 100644 apps/sim/tools/ses/update_template.ts create mode 100644 apps/sim/tools/sts/assume_role_with_saml.ts create mode 100644 apps/sim/tools/sts/assume_role_with_web_identity.ts diff --git a/apps/docs/content/docs/en/integrations/secrets_manager.mdx b/apps/docs/content/docs/en/integrations/secrets_manager.mdx index ca685d9de1b..db33506e7c0 100644 --- a/apps/docs/content/docs/en/integrations/secrets_manager.mdx +++ b/apps/docs/content/docs/en/integrations/secrets_manager.mdx @@ -28,7 +28,7 @@ In Sim, the AWS Secrets Manager integration allows your workflows to securely re ## Usage Instructions -Integrate AWS Secrets Manager into the workflow. Can retrieve, create, update, list, and delete secrets. +Integrate AWS Secrets Manager into the workflow. Can retrieve, create, update, list, delete, describe, tag, untag, restore, and rotate secrets. @@ -78,7 +78,7 @@ List secrets stored in AWS Secrets Manager | Parameter | Type | Description | | --------- | ---- | ----------- | -| `secrets` | json | List of secrets with name, ARN, description, and dates | +| `secrets` | json | List of secrets with name, ARN, description, dates, rotation rules/window, and version-to-stage mappings | | `nextToken` | string | Pagination token for the next page of results | | `count` | number | Number of secrets returned | @@ -154,4 +154,131 @@ Delete a secret from AWS Secrets Manager | `arn` | string | ARN of the deleted secret | | `deletionDate` | string | Scheduled deletion date | +### `secrets_manager_describe_secret` + +Retrieve full metadata for a secret in AWS Secrets Manager, including rotation configuration and replication status, without exposing the secret value + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `region` | string | Yes | AWS region \(e.g., us-east-1\) | +| `accessKeyId` | string | Yes | AWS access key ID | +| `secretAccessKey` | string | Yes | AWS secret access key | +| `secretId` | string | Yes | The name or ARN of the secret to describe | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `name` | string | Name of the secret | +| `arn` | string | ARN of the secret | +| `description` | string | Description of the secret | +| `kmsKeyId` | string | KMS key ID used to encrypt the secret | +| `rotationEnabled` | boolean | Whether automatic rotation is enabled | +| `rotationLambdaARN` | string | ARN of the Lambda function used for rotation | +| `rotationRules` | json | Rotation schedule configuration | +| `lastRotatedDate` | string | Date the secret was last rotated | +| `lastChangedDate` | string | Date the secret was last changed | +| `lastAccessedDate` | string | Date the secret was last accessed | +| `deletedDate` | string | Scheduled deletion date | +| `nextRotationDate` | string | Date the secret is next scheduled to rotate | +| `tags` | array | Tags attached to the secret | +| `versionIdsToStages` | json | Map of version IDs to their staging labels | +| `owningService` | string | ID of the AWS service that manages this secret, if any | +| `createdDate` | string | Date the secret was created | +| `primaryRegion` | string | The primary region of the secret, if replicated | +| `replicationStatus` | array | Replication status for each region the secret is replicated to | + +### `secrets_manager_tag_resource` + +Attach tags to a secret in AWS Secrets Manager + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `region` | string | Yes | AWS region \(e.g., us-east-1\) | +| `accessKeyId` | string | Yes | AWS access key ID | +| `secretAccessKey` | string | Yes | AWS secret access key | +| `secretId` | string | Yes | The name or ARN of the secret to tag | +| `tags` | json | Yes | Tags to attach, as an array of \{key, value\} pairs \(max 50\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Operation status message | +| `name` | string | Name or ARN of the tagged secret | + +### `secrets_manager_untag_resource` + +Remove tags from a secret in AWS Secrets Manager + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `region` | string | Yes | AWS region \(e.g., us-east-1\) | +| `accessKeyId` | string | Yes | AWS access key ID | +| `secretAccessKey` | string | Yes | AWS secret access key | +| `secretId` | string | Yes | The name or ARN of the secret to untag | +| `tagKeys` | json | Yes | Tag keys to remove, as an array of strings \(max 50\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Operation status message | +| `name` | string | Name or ARN of the untagged secret | + +### `secrets_manager_restore_secret` + +Cancel a scheduled deletion for a secret in AWS Secrets Manager, restoring access to it + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `region` | string | Yes | AWS region \(e.g., us-east-1\) | +| `accessKeyId` | string | Yes | AWS access key ID | +| `secretAccessKey` | string | Yes | AWS secret access key | +| `secretId` | string | Yes | The name or ARN of the secret to restore | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Operation status message | +| `name` | string | Name of the restored secret | +| `arn` | string | ARN of the restored secret | + +### `secrets_manager_rotate_secret` + +Start or reconfigure rotation for a secret in AWS Secrets Manager + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `region` | string | Yes | AWS region \(e.g., us-east-1\) | +| `accessKeyId` | string | Yes | AWS access key ID | +| `secretAccessKey` | string | Yes | AWS secret access key | +| `secretId` | string | Yes | The name or ARN of the secret to rotate | +| `clientRequestToken` | string | No | Idempotency token for the new secret version \(32-64 characters\) | +| `rotationLambdaARN` | string | No | ARN of the Lambda function that performs rotation \(omit for managed rotation\) | +| `automaticallyAfterDays` | number | No | Number of days between rotations \(1-1000\). Mutually exclusive with schedule expression | +| `duration` | string | No | Length of the rotation window in hours, e.g. "3h" | +| `scheduleExpression` | string | No | A cron\(\) or rate\(\) expression defining the rotation schedule | +| `rotateImmediately` | boolean | No | Whether to rotate immediately \(default true\) or wait for the next scheduled window | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Operation status message | +| `name` | string | Name of the secret | +| `arn` | string | ARN of the secret | +| `versionId` | string | ID of the new secret version created by rotation | + diff --git a/apps/docs/content/docs/en/integrations/ses.mdx b/apps/docs/content/docs/en/integrations/ses.mdx index 71aab4dcb66..56c26ee5955 100644 --- a/apps/docs/content/docs/en/integrations/ses.mdx +++ b/apps/docs/content/docs/en/integrations/ses.mdx @@ -27,7 +27,7 @@ In Sim, the AWS SES integration is designed for workflows that need reliable, pr ## Usage Instructions -Integrate AWS SES v2 into the workflow. Send simple, templated, and bulk emails. Manage email templates and retrieve account sending quota and verified identity information. +Integrate AWS SES v2 into the workflow. Send simple, templated, and bulk emails. Manage email templates, identities, configuration sets, and the account suppression list, and retrieve account sending quota and verified identity information. @@ -238,4 +238,232 @@ Delete an existing SES email template | --------- | ---- | ----------- | | `message` | string | Confirmation message for the deleted template | +### `ses_update_template` + +Update the subject, HTML, and text content of an existing SES email template + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `region` | string | Yes | AWS region \(e.g., us-east-1\) | +| `accessKeyId` | string | Yes | AWS access key ID | +| `secretAccessKey` | string | Yes | AWS secret access key | +| `templateName` | string | Yes | The name of the template to update | +| `subjectPart` | string | Yes | The subject line of the template | +| `htmlPart` | string | No | The HTML body of the template | +| `textPart` | string | No | The plain text body of the template | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Confirmation message | + +### `ses_put_suppressed_destination` + +Add an email address to the account-level SES suppression list + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `region` | string | Yes | AWS region \(e.g., us-east-1\) | +| `accessKeyId` | string | Yes | AWS access key ID | +| `secretAccessKey` | string | Yes | AWS secret access key | +| `emailAddress` | string | Yes | The email address to add to the suppression list | +| `reason` | string | Yes | The reason the address is suppressed: BOUNCE or COMPLAINT | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Confirmation message | + +### `ses_delete_suppressed_destination` + +Remove an email address from the account-level SES suppression list + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `region` | string | Yes | AWS region \(e.g., us-east-1\) | +| `accessKeyId` | string | Yes | AWS access key ID | +| `secretAccessKey` | string | Yes | AWS secret access key | +| `emailAddress` | string | Yes | The email address to remove from the suppression list | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Confirmation message | + +### `ses_get_suppressed_destination` + +Retrieve details for a specific email address on the SES suppression list + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `region` | string | Yes | AWS region \(e.g., us-east-1\) | +| `accessKeyId` | string | Yes | AWS access key ID | +| `secretAccessKey` | string | Yes | AWS secret access key | +| `emailAddress` | string | Yes | The suppressed email address to look up | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `emailAddress` | string | The suppressed email address | +| `reason` | string | The reason the address is suppressed | +| `lastUpdateTime` | string | When the address was added to the suppression list | +| `messageId` | string | The message ID associated with the bounce or complaint event | +| `feedbackId` | string | The feedback ID associated with the bounce or complaint event | + +### `ses_list_suppressed_destinations` + +List email addresses on the account-level SES suppression list + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `region` | string | Yes | AWS region \(e.g., us-east-1\) | +| `accessKeyId` | string | Yes | AWS access key ID | +| `secretAccessKey` | string | Yes | AWS secret access key | +| `reasons` | string | No | Comma-separated suppression reasons to filter by: BOUNCE, COMPLAINT | +| `startDate` | string | No | Only include addresses suppressed after this ISO 8601 date | +| `endDate` | string | No | Only include addresses suppressed before this ISO 8601 date | +| `pageSize` | number | No | Maximum number of results to return | +| `nextToken` | string | No | Pagination token from a previous list response | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `destinations` | array | List of suppressed destinations with email address, reason, and last update | +| `nextToken` | string | Pagination token for the next page of results | +| `count` | number | Number of suppressed destinations returned | + +### `ses_create_email_identity` + +Start verification of a new SES email address or domain identity + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `region` | string | Yes | AWS region \(e.g., us-east-1\) | +| `accessKeyId` | string | Yes | AWS access key ID | +| `secretAccessKey` | string | Yes | AWS secret access key | +| `emailIdentity` | string | Yes | The email address or domain to verify | +| `dkimSigningAttributes` | json | No | Bring-your-own-DKIM signing attributes as JSON \(domainSigningSelector, domainSigningPrivateKey, nextSigningKeyLength\). Domain identities only. | +| `tags` | json | No | JSON array of tags to associate with the identity: \[\{"key":"","value":""\}\] | +| `configurationSetName` | string | No | Default configuration set to use when sending from this identity | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `identityType` | string | The identity type: EMAIL_ADDRESS or DOMAIN | +| `verifiedForSendingStatus` | boolean | Whether the identity is verified and can send email | +| `dkimAttributes` | json | DKIM signing status and CNAME tokens for the identity | + +### `ses_delete_email_identity` + +Delete a verified SES email address or domain identity + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `region` | string | Yes | AWS region \(e.g., us-east-1\) | +| `accessKeyId` | string | Yes | AWS access key ID | +| `secretAccessKey` | string | Yes | AWS secret access key | +| `emailIdentity` | string | Yes | The email address or domain identity to delete | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Confirmation message | + +### `ses_get_email_identity` + +Retrieve verification status, DKIM, Mail-From, and policy details for an SES identity + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `region` | string | Yes | AWS region \(e.g., us-east-1\) | +| `accessKeyId` | string | Yes | AWS access key ID | +| `secretAccessKey` | string | Yes | AWS secret access key | +| `emailIdentity` | string | Yes | The email address or domain identity to look up | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `identityType` | string | The identity type: EMAIL_ADDRESS or DOMAIN | +| `verifiedForSendingStatus` | boolean | Whether the identity is verified and can send email | +| `verificationStatus` | string | Verification status: PENDING, SUCCESS, FAILED, TEMPORARY_FAILURE, NOT_STARTED | +| `feedbackForwardingStatus` | boolean | Whether bounce/complaint notifications are forwarded by email | +| `configurationSetName` | string | Default configuration set for this identity | +| `dkimAttributes` | json | DKIM signing status and CNAME tokens for the identity | +| `mailFromAttributes` | json | Custom MAIL FROM domain configuration for the identity | +| `policies` | json | Sending authorization policies attached to the identity | +| `tags` | array | Tags associated with the identity | +| `verificationInfo` | json | Additional verification diagnostics \(error type, last checked/success time\) | + +### `ses_create_configuration_set` + +Create an SES configuration set to control tracking, delivery, reputation, sending, and suppression behavior for emails + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `region` | string | Yes | AWS region \(e.g., us-east-1\) | +| `accessKeyId` | string | Yes | AWS access key ID | +| `secretAccessKey` | string | Yes | AWS secret access key | +| `configurationSetName` | string | Yes | Name of the configuration set \(letters, numbers, hyphens, underscores\) | +| `customRedirectDomain` | string | No | Custom domain to use for open/click tracking links | +| `httpsPolicy` | string | No | HTTPS policy for tracking links: REQUIRE, REQUIRE_OPEN_ONLY, or OPTIONAL | +| `tlsPolicy` | string | No | Whether delivery requires TLS: REQUIRE or OPTIONAL | +| `sendingPoolName` | string | No | Dedicated IP pool to associate with the configuration set | +| `reputationMetricsEnabled` | boolean | No | Whether to collect reputation metrics for emails using this configuration set | +| `sendingEnabled` | boolean | No | Whether sending is enabled for this configuration set | +| `suppressedReasons` | string | No | Comma-separated reasons that trigger suppression: BOUNCE, COMPLAINT | +| `tags` | json | No | JSON array of tags to associate with the configuration set: \[\{"key":"","value":""\}\] | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Confirmation message | + +### `ses_send_custom_verification_email` + +Send a branded custom verification email to an address using a custom verification email template + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `region` | string | Yes | AWS region \(e.g., us-east-1\) | +| `accessKeyId` | string | Yes | AWS access key ID | +| `secretAccessKey` | string | Yes | AWS secret access key | +| `emailAddress` | string | Yes | The email address to verify | +| `templateName` | string | Yes | The name of the custom verification email template to use | +| `configurationSetName` | string | No | Configuration set to use when sending the verification email | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `messageId` | string | SES message ID for the sent verification email | + diff --git a/apps/docs/content/docs/en/integrations/sts.mdx b/apps/docs/content/docs/en/integrations/sts.mdx index 89c741398f8..4236096516b 100644 --- a/apps/docs/content/docs/en/integrations/sts.mdx +++ b/apps/docs/content/docs/en/integrations/sts.mdx @@ -50,6 +50,9 @@ Assume an IAM role and receive temporary security credentials | `externalId` | string | No | External ID for cross-account access | | `serialNumber` | string | No | MFA device serial number or ARN | | `tokenCode` | string | No | MFA token code \(6 digits\) | +| `policyArns` | string | No | Comma-separated ARNs of up to 10 IAM managed policies to use as session policies | +| `tags` | string | No | JSON object of up to 50 session tag key/value pairs for attribute-based access control | +| `transitiveTagKeys` | string | No | Comma-separated tag keys that propagate through role chaining | #### Output @@ -64,6 +67,73 @@ Assume an IAM role and receive temporary security credentials | `packedPolicySize` | number | Percentage of allowed policy size used | | `sourceIdentity` | string | Source identity set on the role session, if any | +### `sts_assume_role_with_web_identity` + +Assume an IAM role using an OIDC/OAuth 2.0 web identity token (e.g. GitHub Actions OIDC, EKS IRSA, Google/Facebook federation) and receive temporary security credentials + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `region` | string | Yes | AWS region \(e.g., us-east-1\) | +| `roleArn` | string | Yes | ARN of the IAM role to assume | +| `roleSessionName` | string | Yes | Identifier for the assumed role session | +| `webIdentityToken` | string | Yes | OAuth 2.0 access token or OpenID Connect ID token from the identity provider \(up to 20000 chars\) | +| `providerId` | string | No | Fully qualified host of a legacy OAuth 2.0 provider \(e.g. www.amazon.com\); omit for OpenID Connect providers | +| `policyArns` | string | No | Comma-separated ARNs of up to 10 IAM managed policies to use as session policies | +| `policy` | string | No | JSON IAM policy to further restrict session permissions \(max 2048 chars\) | +| `durationSeconds` | number | No | Duration of the session in seconds \(900-43200, default 3600\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `accessKeyId` | string | Temporary access key ID | +| `secretAccessKey` | string | Temporary secret access key | +| `sessionToken` | string | Temporary session token | +| `expiration` | string | Credential expiration timestamp | +| `assumedRoleArn` | string | ARN of the assumed role | +| `assumedRoleId` | string | Assumed role ID with session name | +| `subjectFromWebIdentityToken` | string | Unique user identifier from the identity provider's token subject claim | +| `audience` | string | Intended audience \(client ID\) of the web identity token | +| `provider` | string | Issuing authority of the presented web identity token | +| `packedPolicySize` | number | Percentage of allowed policy size used | +| `sourceIdentity` | string | Source identity set on the role session, if any | + +### `sts_assume_role_with_saml` + +Assume an IAM role using a SAML 2.0 authentication response from an enterprise identity provider and receive temporary security credentials + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `region` | string | Yes | AWS region \(e.g., us-east-1\) | +| `roleArn` | string | Yes | ARN of the IAM role to assume | +| `principalArn` | string | Yes | ARN of the SAML provider in IAM that describes the identity provider | +| `samlAssertion` | string | Yes | Base64-encoded SAML authentication response from the identity provider | +| `policyArns` | string | No | Comma-separated ARNs of up to 10 IAM managed policies to use as session policies | +| `policy` | string | No | JSON IAM policy to further restrict session permissions \(max 2048 chars\) | +| `durationSeconds` | number | No | Duration of the session in seconds \(900-43200, default 3600\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `accessKeyId` | string | Temporary access key ID | +| `secretAccessKey` | string | Temporary secret access key | +| `sessionToken` | string | Temporary session token | +| `expiration` | string | Credential expiration timestamp | +| `assumedRoleArn` | string | ARN of the assumed role | +| `assumedRoleId` | string | Assumed role ID with session name | +| `subject` | string | Value of the NameID element in the Subject of the SAML assertion | +| `subjectType` | string | Format of the name ID \(e.g. transient, persistent\) | +| `issuer` | string | Value of the Issuer element of the SAML assertion | +| `audience` | string | Value of the SAML assertion's SubjectConfirmationData Recipient attribute | +| `nameQualifier` | string | Hash uniquely identifying the issuer, account, and SAML provider | +| `packedPolicySize` | number | Percentage of allowed policy size used | +| `sourceIdentity` | string | Source identity set on the role session, if any | + ### `sts_get_caller_identity` Get details about the IAM user or role whose credentials are used to call the API diff --git a/apps/sim/app/api/tools/secrets_manager/describe-secret/route.ts b/apps/sim/app/api/tools/secrets_manager/describe-secret/route.ts new file mode 100644 index 00000000000..793f8ce802b --- /dev/null +++ b/apps/sim/app/api/tools/secrets_manager/describe-secret/route.ts @@ -0,0 +1,55 @@ +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 { awsSecretsManagerDescribeSecretContract } from '@/lib/api/contracts/tools/aws/secrets-manager-describe-secret' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { createSecretsManagerClient, describeSecret } from '../utils' + +const logger = createLogger('SecretsManagerDescribeSecretAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateId().slice(0, 8) + + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + try { + const parsed = await parseToolRequest(awsSecretsManagerDescribeSecretContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const params = parsed.data.body + + logger.info(`[${requestId}] Describing secret ${params.secretId}`) + + const client = createSecretsManagerClient({ + region: params.region, + accessKeyId: params.accessKeyId, + secretAccessKey: params.secretAccessKey, + }) + + try { + const result = await describeSecret(client, params.secretId) + + logger.info(`[${requestId}] Described secret: ${result.name}`) + + return NextResponse.json(result) + } finally { + client.destroy() + } + } catch (error) { + const errorMessage = getErrorMessage(error, 'Unknown error occurred') + logger.error(`[${requestId}] Failed to describe secret:`, error) + + return NextResponse.json( + { error: `Failed to describe secret: ${errorMessage}` }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/secrets_manager/restore-secret/route.ts b/apps/sim/app/api/tools/secrets_manager/restore-secret/route.ts new file mode 100644 index 00000000000..e73da9582a9 --- /dev/null +++ b/apps/sim/app/api/tools/secrets_manager/restore-secret/route.ts @@ -0,0 +1,58 @@ +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 { awsSecretsManagerRestoreSecretContract } from '@/lib/api/contracts/tools/aws/secrets-manager-restore-secret' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { createSecretsManagerClient, restoreSecret } from '../utils' + +const logger = createLogger('SecretsManagerRestoreSecretAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateId().slice(0, 8) + + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + try { + const parsed = await parseToolRequest(awsSecretsManagerRestoreSecretContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const params = parsed.data.body + + logger.info(`[${requestId}] Restoring secret ${params.secretId}`) + + const client = createSecretsManagerClient({ + region: params.region, + accessKeyId: params.accessKeyId, + secretAccessKey: params.secretAccessKey, + }) + + try { + const result = await restoreSecret(client, params.secretId) + + logger.info(`[${requestId}] Restored secret: ${result.name}`) + + return NextResponse.json({ + message: `Secret "${result.name}" restored successfully`, + ...result, + }) + } finally { + client.destroy() + } + } catch (error) { + const errorMessage = getErrorMessage(error, 'Unknown error occurred') + logger.error(`[${requestId}] Failed to restore secret:`, error) + + return NextResponse.json( + { error: `Failed to restore secret: ${errorMessage}` }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/secrets_manager/rotate-secret/route.ts b/apps/sim/app/api/tools/secrets_manager/rotate-secret/route.ts new file mode 100644 index 00000000000..3a0718c4b90 --- /dev/null +++ b/apps/sim/app/api/tools/secrets_manager/rotate-secret/route.ts @@ -0,0 +1,66 @@ +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 { awsSecretsManagerRotateSecretContract } from '@/lib/api/contracts/tools/aws/secrets-manager-rotate-secret' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { createSecretsManagerClient, rotateSecret } from '../utils' + +const logger = createLogger('SecretsManagerRotateSecretAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateId().slice(0, 8) + + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + try { + const parsed = await parseToolRequest(awsSecretsManagerRotateSecretContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const params = parsed.data.body + + logger.info(`[${requestId}] Rotating secret ${params.secretId}`) + + const client = createSecretsManagerClient({ + region: params.region, + accessKeyId: params.accessKeyId, + secretAccessKey: params.secretAccessKey, + }) + + try { + const result = await rotateSecret( + client, + params.secretId, + params.clientRequestToken, + params.rotationLambdaARN, + { + automaticallyAfterDays: params.automaticallyAfterDays, + duration: params.duration, + scheduleExpression: params.scheduleExpression, + }, + params.rotateImmediately + ) + + logger.info(`[${requestId}] Rotation started for secret: ${result.name}`) + + return NextResponse.json({ + message: `Rotation started for secret "${result.name}"`, + ...result, + }) + } finally { + client.destroy() + } + } catch (error) { + const errorMessage = getErrorMessage(error, 'Unknown error occurred') + logger.error(`[${requestId}] Failed to rotate secret:`, error) + + return NextResponse.json({ error: `Failed to rotate secret: ${errorMessage}` }, { status: 500 }) + } +}) diff --git a/apps/sim/app/api/tools/secrets_manager/tag-resource/route.ts b/apps/sim/app/api/tools/secrets_manager/tag-resource/route.ts new file mode 100644 index 00000000000..6c4bec10989 --- /dev/null +++ b/apps/sim/app/api/tools/secrets_manager/tag-resource/route.ts @@ -0,0 +1,59 @@ +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 { awsSecretsManagerTagResourceContract } from '@/lib/api/contracts/tools/aws/secrets-manager-tag-resource' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { createSecretsManagerClient, tagResource } from '../utils' + +const logger = createLogger('SecretsManagerTagResourceAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateId().slice(0, 8) + + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + try { + const parsed = await parseToolRequest(awsSecretsManagerTagResourceContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const params = parsed.data.body + + logger.info(`[${requestId}] Tagging secret ${params.secretId}`) + + const client = createSecretsManagerClient({ + region: params.region, + accessKeyId: params.accessKeyId, + secretAccessKey: params.secretAccessKey, + }) + + try { + const result = await tagResource( + client, + params.secretId, + params.tags.map((t) => ({ Key: t.key, Value: t.value })) + ) + + logger.info(`[${requestId}] Tagged secret: ${result.name}`) + + return NextResponse.json({ + message: `Secret "${result.name}" tagged successfully`, + ...result, + }) + } finally { + client.destroy() + } + } catch (error) { + const errorMessage = getErrorMessage(error, 'Unknown error occurred') + logger.error(`[${requestId}] Failed to tag secret:`, error) + + return NextResponse.json({ error: `Failed to tag secret: ${errorMessage}` }, { status: 500 }) + } +}) diff --git a/apps/sim/app/api/tools/secrets_manager/untag-resource/route.ts b/apps/sim/app/api/tools/secrets_manager/untag-resource/route.ts new file mode 100644 index 00000000000..9300e81008f --- /dev/null +++ b/apps/sim/app/api/tools/secrets_manager/untag-resource/route.ts @@ -0,0 +1,55 @@ +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 { awsSecretsManagerUntagResourceContract } from '@/lib/api/contracts/tools/aws/secrets-manager-untag-resource' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { createSecretsManagerClient, untagResource } from '../utils' + +const logger = createLogger('SecretsManagerUntagResourceAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateId().slice(0, 8) + + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + try { + const parsed = await parseToolRequest(awsSecretsManagerUntagResourceContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const params = parsed.data.body + + logger.info(`[${requestId}] Untagging secret ${params.secretId}`) + + const client = createSecretsManagerClient({ + region: params.region, + accessKeyId: params.accessKeyId, + secretAccessKey: params.secretAccessKey, + }) + + try { + const result = await untagResource(client, params.secretId, params.tagKeys) + + logger.info(`[${requestId}] Untagged secret: ${result.name}`) + + return NextResponse.json({ + message: `Secret "${result.name}" untagged successfully`, + ...result, + }) + } finally { + client.destroy() + } + } catch (error) { + const errorMessage = getErrorMessage(error, 'Unknown error occurred') + logger.error(`[${requestId}] Failed to untag secret:`, error) + + return NextResponse.json({ error: `Failed to untag secret: ${errorMessage}` }, { status: 500 }) + } +}) diff --git a/apps/sim/app/api/tools/secrets_manager/utils.ts b/apps/sim/app/api/tools/secrets_manager/utils.ts index dacbbc30be1..fc18d95b2fd 100644 --- a/apps/sim/app/api/tools/secrets_manager/utils.ts +++ b/apps/sim/app/api/tools/secrets_manager/utils.ts @@ -1,14 +1,28 @@ -import type { SecretListEntry, Tag } from '@aws-sdk/client-secrets-manager' +import type { RotationRulesType, SecretListEntry, Tag } from '@aws-sdk/client-secrets-manager' import { CreateSecretCommand, DeleteSecretCommand, + DescribeSecretCommand, GetSecretValueCommand, ListSecretsCommand, + RestoreSecretCommand, + RotateSecretCommand, SecretsManagerClient, + TagResourceCommand, + UntagResourceCommand, UpdateSecretCommand, } from '@aws-sdk/client-secrets-manager' import type { SecretsManagerConnectionConfig } from '@/tools/secrets_manager/types' +function mapRotationRules(rules: RotationRulesType | undefined) { + if (!rules) return null + return { + automaticallyAfterDays: rules.AutomaticallyAfterDays ?? null, + duration: rules.Duration ?? null, + scheduleExpression: rules.ScheduleExpression ?? null, + } +} + export function createSecretsManagerClient( config: SecretsManagerConnectionConfig ): SecretsManagerClient { @@ -71,6 +85,11 @@ export async function listSecrets( lastAccessedDate: secret.LastAccessedDate?.toISOString() ?? null, rotationEnabled: secret.RotationEnabled ?? false, tags: secret.Tags?.map((t: Tag) => ({ key: t.Key ?? '', value: t.Value ?? '' })) ?? [], + rotationRules: mapRotationRules(secret.RotationRules), + lastRotatedDate: secret.LastRotatedDate?.toISOString() ?? null, + nextRotationDate: secret.NextRotationDate?.toISOString() ?? null, + deletedDate: secret.DeletedDate?.toISOString() ?? null, + secretVersionsToStages: secret.SecretVersionsToStages ?? null, })) return { @@ -139,3 +158,109 @@ export async function deleteSecret( deletionDate: response.DeletionDate?.toISOString() ?? null, } } + +export async function describeSecret(client: SecretsManagerClient, secretId: string) { + const command = new DescribeSecretCommand({ SecretId: secretId }) + const response = await client.send(command) + + return { + name: response.Name ?? '', + arn: response.ARN ?? '', + description: response.Description ?? null, + kmsKeyId: response.KmsKeyId ?? null, + rotationEnabled: response.RotationEnabled ?? false, + rotationLambdaARN: response.RotationLambdaARN ?? null, + rotationRules: mapRotationRules(response.RotationRules), + lastRotatedDate: response.LastRotatedDate?.toISOString() ?? null, + lastChangedDate: response.LastChangedDate?.toISOString() ?? null, + lastAccessedDate: response.LastAccessedDate?.toISOString() ?? null, + deletedDate: response.DeletedDate?.toISOString() ?? null, + nextRotationDate: response.NextRotationDate?.toISOString() ?? null, + tags: response.Tags?.map((t: Tag) => ({ key: t.Key ?? '', value: t.Value ?? '' })) ?? [], + versionIdsToStages: response.VersionIdsToStages ?? null, + owningService: response.OwningService ?? null, + createdDate: response.CreatedDate?.toISOString() ?? null, + primaryRegion: response.PrimaryRegion ?? null, + replicationStatus: + response.ReplicationStatus?.map((r) => ({ + region: r.Region ?? '', + kmsKeyId: r.KmsKeyId ?? null, + status: r.Status ?? null, + statusMessage: r.StatusMessage ?? null, + lastAccessedDate: r.LastAccessedDate?.toISOString() ?? null, + })) ?? [], + } +} + +export async function tagResource(client: SecretsManagerClient, secretId: string, tags: Tag[]) { + const command = new TagResourceCommand({ SecretId: secretId, Tags: tags }) + await client.send(command) + return { name: secretId } +} + +export async function untagResource( + client: SecretsManagerClient, + secretId: string, + tagKeys: string[] +) { + const command = new UntagResourceCommand({ SecretId: secretId, TagKeys: tagKeys }) + await client.send(command) + return { name: secretId } +} + +export async function restoreSecret(client: SecretsManagerClient, secretId: string) { + const command = new RestoreSecretCommand({ SecretId: secretId }) + const response = await client.send(command) + return { + name: response.Name ?? '', + arn: response.ARN ?? '', + } +} + +export async function rotateSecret( + client: SecretsManagerClient, + secretId: string, + clientRequestToken?: string | null, + rotationLambdaARN?: string | null, + rotationRules?: { + automaticallyAfterDays?: number | null + duration?: string | null + scheduleExpression?: string | null + } | null, + rotateImmediately?: boolean | null +) { + const hasRotationRules = Boolean( + rotationRules?.automaticallyAfterDays || + rotationRules?.duration || + rotationRules?.scheduleExpression + ) + + const command = new RotateSecretCommand({ + SecretId: secretId, + ...(clientRequestToken ? { ClientRequestToken: clientRequestToken } : {}), + ...(rotationLambdaARN ? { RotationLambdaARN: rotationLambdaARN } : {}), + ...(hasRotationRules + ? { + RotationRules: { + ...(rotationRules?.automaticallyAfterDays + ? { AutomaticallyAfterDays: rotationRules.automaticallyAfterDays } + : {}), + ...(rotationRules?.duration ? { Duration: rotationRules.duration } : {}), + ...(rotationRules?.scheduleExpression + ? { ScheduleExpression: rotationRules.scheduleExpression } + : {}), + }, + } + : {}), + ...(rotateImmediately === undefined || rotateImmediately === null + ? {} + : { RotateImmediately: rotateImmediately }), + }) + + const response = await client.send(command) + return { + name: response.Name ?? '', + arn: response.ARN ?? '', + versionId: response.VersionId ?? '', + } +} diff --git a/apps/sim/app/api/tools/ses/create-configuration-set/route.ts b/apps/sim/app/api/tools/ses/create-configuration-set/route.ts new file mode 100644 index 00000000000..f905aff437f --- /dev/null +++ b/apps/sim/app/api/tools/ses/create-configuration-set/route.ts @@ -0,0 +1,69 @@ +import type { SuppressionListReason } from '@aws-sdk/client-sesv2' +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { awsSesCreateConfigurationSetContract } from '@/lib/api/contracts/tools/aws/ses-create-configuration-set' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { createConfigurationSet, createSESClient } from '../utils' + +const logger = createLogger('SESCreateConfigurationSetAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + try { + const parsed = await parseToolRequest(awsSesCreateConfigurationSetContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const params = parsed.data.body + + logger.info('Creating SES configuration set') + + const client = createSESClient({ + region: params.region, + accessKeyId: params.accessKeyId, + secretAccessKey: params.secretAccessKey, + }) + + try { + const suppressedReasons = params.suppressedReasons + ? (params.suppressedReasons + .split(',') + .map((r) => r.trim()) + .filter(Boolean) as SuppressionListReason[]) + : null + + const result = await createConfigurationSet(client, { + configurationSetName: params.configurationSetName, + customRedirectDomain: params.customRedirectDomain, + httpsPolicy: params.httpsPolicy, + tlsPolicy: params.tlsPolicy, + sendingPoolName: params.sendingPoolName, + reputationMetricsEnabled: params.reputationMetricsEnabled, + sendingEnabled: params.sendingEnabled, + suppressedReasons, + tags: params.tags, + }) + + logger.info(`Created configuration set '${params.configurationSetName}'`) + + return NextResponse.json(result) + } finally { + client.destroy() + } + } catch (error) { + logger.error('Failed to create configuration set:', error) + + return NextResponse.json( + { error: `Failed to create configuration set: ${toError(error).message}` }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/ses/create-email-identity/route.ts b/apps/sim/app/api/tools/ses/create-email-identity/route.ts new file mode 100644 index 00000000000..9f58228b173 --- /dev/null +++ b/apps/sim/app/api/tools/ses/create-email-identity/route.ts @@ -0,0 +1,56 @@ +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { awsSesCreateEmailIdentityContract } from '@/lib/api/contracts/tools/aws/ses-create-email-identity' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { createEmailIdentity, createSESClient } from '../utils' + +const logger = createLogger('SESCreateEmailIdentityAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + try { + const parsed = await parseToolRequest(awsSesCreateEmailIdentityContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const params = parsed.data.body + + logger.info('Creating SES email identity') + + const client = createSESClient({ + region: params.region, + accessKeyId: params.accessKeyId, + secretAccessKey: params.secretAccessKey, + }) + + try { + const result = await createEmailIdentity(client, { + emailIdentity: params.emailIdentity, + dkimSigningAttributes: params.dkimSigningAttributes, + tags: params.tags, + configurationSetName: params.configurationSetName, + }) + + logger.info(`Created email identity '${params.emailIdentity}'`) + + return NextResponse.json(result) + } finally { + client.destroy() + } + } catch (error) { + logger.error('Failed to create email identity:', error) + + return NextResponse.json( + { error: `Failed to create email identity: ${toError(error).message}` }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/ses/delete-email-identity/route.ts b/apps/sim/app/api/tools/ses/delete-email-identity/route.ts new file mode 100644 index 00000000000..a185fc769ca --- /dev/null +++ b/apps/sim/app/api/tools/ses/delete-email-identity/route.ts @@ -0,0 +1,51 @@ +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { awsSesDeleteEmailIdentityContract } from '@/lib/api/contracts/tools/aws/ses-delete-email-identity' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { createSESClient, deleteEmailIdentity } from '../utils' + +const logger = createLogger('SESDeleteEmailIdentityAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + try { + const parsed = await parseToolRequest(awsSesDeleteEmailIdentityContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const params = parsed.data.body + + logger.info('Deleting SES email identity') + + const client = createSESClient({ + region: params.region, + accessKeyId: params.accessKeyId, + secretAccessKey: params.secretAccessKey, + }) + + try { + const result = await deleteEmailIdentity(client, params.emailIdentity) + + logger.info(`Deleted email identity '${params.emailIdentity}'`) + + return NextResponse.json(result) + } finally { + client.destroy() + } + } catch (error) { + logger.error('Failed to delete email identity:', error) + + return NextResponse.json( + { error: `Failed to delete email identity: ${toError(error).message}` }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/ses/delete-suppressed-destination/route.ts b/apps/sim/app/api/tools/ses/delete-suppressed-destination/route.ts new file mode 100644 index 00000000000..c0a047279e6 --- /dev/null +++ b/apps/sim/app/api/tools/ses/delete-suppressed-destination/route.ts @@ -0,0 +1,51 @@ +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { awsSesDeleteSuppressedDestinationContract } from '@/lib/api/contracts/tools/aws/ses-delete-suppressed-destination' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { createSESClient, deleteSuppressedDestination } from '../utils' + +const logger = createLogger('SESDeleteSuppressedDestinationAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + try { + const parsed = await parseToolRequest(awsSesDeleteSuppressedDestinationContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const params = parsed.data.body + + logger.info('Removing email address from SES suppression list') + + const client = createSESClient({ + region: params.region, + accessKeyId: params.accessKeyId, + secretAccessKey: params.secretAccessKey, + }) + + try { + const result = await deleteSuppressedDestination(client, params.emailAddress) + + logger.info('Removed email address from suppression list') + + return NextResponse.json(result) + } finally { + client.destroy() + } + } catch (error) { + logger.error('Failed to remove suppressed destination:', error) + + return NextResponse.json( + { error: `Failed to remove suppressed destination: ${toError(error).message}` }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/ses/get-email-identity/route.ts b/apps/sim/app/api/tools/ses/get-email-identity/route.ts new file mode 100644 index 00000000000..c13a11c6fe9 --- /dev/null +++ b/apps/sim/app/api/tools/ses/get-email-identity/route.ts @@ -0,0 +1,51 @@ +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { awsSesGetEmailIdentityContract } from '@/lib/api/contracts/tools/aws/ses-get-email-identity' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { createSESClient, getEmailIdentity } from '../utils' + +const logger = createLogger('SESGetEmailIdentityAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + try { + const parsed = await parseToolRequest(awsSesGetEmailIdentityContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const params = parsed.data.body + + logger.info('Fetching SES email identity') + + const client = createSESClient({ + region: params.region, + accessKeyId: params.accessKeyId, + secretAccessKey: params.secretAccessKey, + }) + + try { + const result = await getEmailIdentity(client, params.emailIdentity) + + logger.info(`Fetched email identity '${params.emailIdentity}'`) + + return NextResponse.json(result) + } finally { + client.destroy() + } + } catch (error) { + logger.error('Failed to get email identity:', error) + + return NextResponse.json( + { error: `Failed to get email identity: ${toError(error).message}` }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/ses/get-suppressed-destination/route.ts b/apps/sim/app/api/tools/ses/get-suppressed-destination/route.ts new file mode 100644 index 00000000000..71022496733 --- /dev/null +++ b/apps/sim/app/api/tools/ses/get-suppressed-destination/route.ts @@ -0,0 +1,51 @@ +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { awsSesGetSuppressedDestinationContract } from '@/lib/api/contracts/tools/aws/ses-get-suppressed-destination' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { createSESClient, getSuppressedDestination } from '../utils' + +const logger = createLogger('SESGetSuppressedDestinationAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + try { + const parsed = await parseToolRequest(awsSesGetSuppressedDestinationContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const params = parsed.data.body + + logger.info('Fetching SES suppressed destination') + + const client = createSESClient({ + region: params.region, + accessKeyId: params.accessKeyId, + secretAccessKey: params.secretAccessKey, + }) + + try { + const result = await getSuppressedDestination(client, params.emailAddress) + + logger.info('Fetched suppressed destination') + + return NextResponse.json(result) + } finally { + client.destroy() + } + } catch (error) { + logger.error('Failed to get suppressed destination:', error) + + return NextResponse.json( + { error: `Failed to get suppressed destination: ${toError(error).message}` }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/ses/list-suppressed-destinations/route.ts b/apps/sim/app/api/tools/ses/list-suppressed-destinations/route.ts new file mode 100644 index 00000000000..639704da110 --- /dev/null +++ b/apps/sim/app/api/tools/ses/list-suppressed-destinations/route.ts @@ -0,0 +1,80 @@ +import type { SuppressionListReason } from '@aws-sdk/client-sesv2' +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { awsSesListSuppressedDestinationsContract } from '@/lib/api/contracts/tools/aws/ses-list-suppressed-destinations' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { createSESClient, listSuppressedDestinations } from '../utils' + +const logger = createLogger('SESListSuppressedDestinationsAPI') + +const VALID_SUPPRESSION_REASONS: SuppressionListReason[] = ['BOUNCE', 'COMPLAINT'] + +export const POST = withRouteHandler(async (request: NextRequest) => { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + try { + const parsed = await parseToolRequest(awsSesListSuppressedDestinationsContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const params = parsed.data.body + + let reasons: SuppressionListReason[] | null = null + if (params.reasons) { + const candidates = params.reasons + .split(',') + .map((r) => r.trim()) + .filter(Boolean) + const invalid = candidates.filter( + (r) => !VALID_SUPPRESSION_REASONS.includes(r as SuppressionListReason) + ) + if (invalid.length > 0) { + return NextResponse.json( + { + error: `Invalid suppression reason(s): ${invalid.join(', ')}. Must be one of: ${VALID_SUPPRESSION_REASONS.join(', ')}`, + }, + { status: 400 } + ) + } + reasons = candidates as SuppressionListReason[] + } + + logger.info('Listing SES suppressed destinations') + + const client = createSESClient({ + region: params.region, + accessKeyId: params.accessKeyId, + secretAccessKey: params.secretAccessKey, + }) + + try { + const result = await listSuppressedDestinations(client, { + reasons, + startDate: params.startDate ? new Date(params.startDate) : null, + endDate: params.endDate ? new Date(params.endDate) : null, + pageSize: params.pageSize, + nextToken: params.nextToken, + }) + + logger.info(`Listed ${result.count} suppressed destinations`) + + return NextResponse.json(result) + } finally { + client.destroy() + } + } catch (error) { + logger.error('Failed to list suppressed destinations:', error) + + return NextResponse.json( + { error: `Failed to list suppressed destinations: ${toError(error).message}` }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/ses/put-suppressed-destination/route.ts b/apps/sim/app/api/tools/ses/put-suppressed-destination/route.ts new file mode 100644 index 00000000000..453ba387893 --- /dev/null +++ b/apps/sim/app/api/tools/ses/put-suppressed-destination/route.ts @@ -0,0 +1,54 @@ +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { awsSesPutSuppressedDestinationContract } from '@/lib/api/contracts/tools/aws/ses-put-suppressed-destination' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { createSESClient, putSuppressedDestination } from '../utils' + +const logger = createLogger('SESPutSuppressedDestinationAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + try { + const parsed = await parseToolRequest(awsSesPutSuppressedDestinationContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const params = parsed.data.body + + logger.info('Adding email address to SES suppression list') + + const client = createSESClient({ + region: params.region, + accessKeyId: params.accessKeyId, + secretAccessKey: params.secretAccessKey, + }) + + try { + const result = await putSuppressedDestination(client, { + emailAddress: params.emailAddress, + reason: params.reason, + }) + + logger.info('Added email address to suppression list') + + return NextResponse.json(result) + } finally { + client.destroy() + } + } catch (error) { + logger.error('Failed to add suppressed destination:', error) + + return NextResponse.json( + { error: `Failed to add suppressed destination: ${toError(error).message}` }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/ses/send-custom-verification-email/route.ts b/apps/sim/app/api/tools/ses/send-custom-verification-email/route.ts new file mode 100644 index 00000000000..dd4bf5387f9 --- /dev/null +++ b/apps/sim/app/api/tools/ses/send-custom-verification-email/route.ts @@ -0,0 +1,55 @@ +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { awsSesSendCustomVerificationEmailContract } from '@/lib/api/contracts/tools/aws/ses-send-custom-verification-email' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { createSESClient, sendCustomVerificationEmail } from '../utils' + +const logger = createLogger('SESSendCustomVerificationEmailAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + try { + const parsed = await parseToolRequest(awsSesSendCustomVerificationEmailContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const params = parsed.data.body + + logger.info('Sending SES custom verification email') + + const client = createSESClient({ + region: params.region, + accessKeyId: params.accessKeyId, + secretAccessKey: params.secretAccessKey, + }) + + try { + const result = await sendCustomVerificationEmail(client, { + emailAddress: params.emailAddress, + templateName: params.templateName, + configurationSetName: params.configurationSetName, + }) + + logger.info(`Sent custom verification email to '${params.emailAddress}'`) + + return NextResponse.json(result) + } finally { + client.destroy() + } + } catch (error) { + logger.error('Failed to send custom verification email:', error) + + return NextResponse.json( + { error: `Failed to send custom verification email: ${toError(error).message}` }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/ses/update-template/route.ts b/apps/sim/app/api/tools/ses/update-template/route.ts new file mode 100644 index 00000000000..4bebcff2100 --- /dev/null +++ b/apps/sim/app/api/tools/ses/update-template/route.ts @@ -0,0 +1,56 @@ +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { awsSesUpdateTemplateContract } from '@/lib/api/contracts/tools/aws/ses-update-template' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { createSESClient, updateTemplate } from '../utils' + +const logger = createLogger('SESUpdateTemplateAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + try { + const parsed = await parseToolRequest(awsSesUpdateTemplateContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const params = parsed.data.body + + logger.info('Updating SES email template') + + const client = createSESClient({ + region: params.region, + accessKeyId: params.accessKeyId, + secretAccessKey: params.secretAccessKey, + }) + + try { + const result = await updateTemplate(client, { + templateName: params.templateName, + subjectPart: params.subjectPart, + textPart: params.textPart, + htmlPart: params.htmlPart, + }) + + logger.info(`Updated template '${params.templateName}'`) + + return NextResponse.json(result) + } finally { + client.destroy() + } + } catch (error) { + logger.error('Failed to update template:', error) + + return NextResponse.json( + { error: `Failed to update template: ${toError(error).message}` }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/ses/utils.ts b/apps/sim/app/api/tools/ses/utils.ts index 8e15e2d3d84..62fec2b74a0 100644 --- a/apps/sim/app/api/tools/ses/utils.ts +++ b/apps/sim/app/api/tools/ses/utils.ts @@ -1,13 +1,25 @@ import { + CreateConfigurationSetCommand, + CreateEmailIdentityCommand, CreateEmailTemplateCommand, + DeleteEmailIdentityCommand, DeleteEmailTemplateCommand, + DeleteSuppressedDestinationCommand, GetAccountCommand, + GetEmailIdentityCommand, GetEmailTemplateCommand, + GetSuppressedDestinationCommand, ListEmailIdentitiesCommand, ListEmailTemplatesCommand, + ListSuppressedDestinationsCommand, + PutSuppressedDestinationCommand, SESv2Client, SendBulkEmailCommand, + SendCustomVerificationEmailCommand, SendEmailCommand, + type SuppressionListReason, + type TlsPolicy, + UpdateEmailTemplateCommand, } from '@aws-sdk/client-sesv2' import { z } from 'zod' import type { SESConnectionConfig } from '@/tools/ses/types' @@ -268,3 +280,284 @@ export async function deleteTemplate(client: SESv2Client, templateName: string) message: `Template '${templateName}' deleted successfully`, } } + +export async function updateTemplate( + client: SESv2Client, + params: { + templateName: string + subjectPart: string + textPart?: string | null + htmlPart?: string | null + } +) { + const command = new UpdateEmailTemplateCommand({ + TemplateName: params.templateName, + TemplateContent: { + Subject: params.subjectPart, + ...(params.textPart ? { Text: params.textPart } : {}), + ...(params.htmlPart ? { Html: params.htmlPart } : {}), + }, + }) + + await client.send(command) + + return { + message: `Template '${params.templateName}' updated successfully`, + } +} + +export async function putSuppressedDestination( + client: SESv2Client, + params: { emailAddress: string; reason: SuppressionListReason } +) { + const command = new PutSuppressedDestinationCommand({ + EmailAddress: params.emailAddress, + Reason: params.reason, + }) + + await client.send(command) + + return { + message: `Email address '${params.emailAddress}' added to the suppression list`, + } +} + +export async function deleteSuppressedDestination(client: SESv2Client, emailAddress: string) { + const command = new DeleteSuppressedDestinationCommand({ EmailAddress: emailAddress }) + await client.send(command) + + return { + message: `Email address '${emailAddress}' removed from the suppression list`, + } +} + +export async function getSuppressedDestination(client: SESv2Client, emailAddress: string) { + const command = new GetSuppressedDestinationCommand({ EmailAddress: emailAddress }) + const response = await client.send(command) + const destination = response.SuppressedDestination + + return { + emailAddress: destination?.EmailAddress ?? emailAddress, + reason: destination?.Reason ?? '', + lastUpdateTime: destination?.LastUpdateTime?.toISOString() ?? null, + messageId: destination?.Attributes?.MessageId ?? null, + feedbackId: destination?.Attributes?.FeedbackId ?? null, + } +} + +export async function listSuppressedDestinations( + client: SESv2Client, + params: { + reasons?: SuppressionListReason[] | null + startDate?: Date | null + endDate?: Date | null + pageSize?: number | null + nextToken?: string | null + } +) { + const command = new ListSuppressedDestinationsCommand({ + ...(params.reasons?.length ? { Reasons: params.reasons } : {}), + ...(params.startDate ? { StartDate: params.startDate } : {}), + ...(params.endDate ? { EndDate: params.endDate } : {}), + ...(params.pageSize != null ? { PageSize: params.pageSize } : {}), + ...(params.nextToken ? { NextToken: params.nextToken } : {}), + }) + + const response = await client.send(command) + + const destinations = (response.SuppressedDestinationSummaries ?? []).map((d) => ({ + emailAddress: d.EmailAddress ?? '', + reason: d.Reason ?? '', + lastUpdateTime: d.LastUpdateTime?.toISOString() ?? null, + })) + + return { + destinations, + nextToken: response.NextToken ?? null, + count: destinations.length, + } +} + +export async function createEmailIdentity( + client: SESv2Client, + params: { + emailIdentity: string + dkimSigningAttributes?: { + domainSigningSelector?: string + domainSigningPrivateKey?: string + nextSigningKeyLength?: 'RSA_1024_BIT' | 'RSA_2048_BIT' + } | null + tags?: Array<{ key: string; value: string }> | null + configurationSetName?: string | null + } +) { + const command = new CreateEmailIdentityCommand({ + EmailIdentity: params.emailIdentity, + ...(params.dkimSigningAttributes + ? { + DkimSigningAttributes: { + ...(params.dkimSigningAttributes.domainSigningSelector + ? { DomainSigningSelector: params.dkimSigningAttributes.domainSigningSelector } + : {}), + ...(params.dkimSigningAttributes.domainSigningPrivateKey + ? { DomainSigningPrivateKey: params.dkimSigningAttributes.domainSigningPrivateKey } + : {}), + ...(params.dkimSigningAttributes.nextSigningKeyLength + ? { NextSigningKeyLength: params.dkimSigningAttributes.nextSigningKeyLength } + : {}), + }, + } + : {}), + ...(params.tags?.length + ? { Tags: params.tags.map((t) => ({ Key: t.key, Value: t.value })) } + : {}), + ...(params.configurationSetName ? { ConfigurationSetName: params.configurationSetName } : {}), + }) + + const response = await client.send(command) + + return { + identityType: response.IdentityType ?? '', + verifiedForSendingStatus: response.VerifiedForSendingStatus ?? false, + dkimAttributes: response.DkimAttributes + ? { + signingEnabled: response.DkimAttributes.SigningEnabled ?? null, + status: response.DkimAttributes.Status ?? null, + tokens: response.DkimAttributes.Tokens ?? [], + signingAttributesOrigin: response.DkimAttributes.SigningAttributesOrigin ?? null, + nextSigningKeyLength: response.DkimAttributes.NextSigningKeyLength ?? null, + currentSigningKeyLength: response.DkimAttributes.CurrentSigningKeyLength ?? null, + lastKeyGenerationTimestamp: + response.DkimAttributes.LastKeyGenerationTimestamp?.toISOString() ?? null, + signingHostedZone: response.DkimAttributes.SigningHostedZone ?? null, + } + : null, + } +} + +export async function deleteEmailIdentity(client: SESv2Client, emailIdentity: string) { + const command = new DeleteEmailIdentityCommand({ EmailIdentity: emailIdentity }) + await client.send(command) + + return { + message: `Email identity '${emailIdentity}' deleted successfully`, + } +} + +export async function getEmailIdentity(client: SESv2Client, emailIdentity: string) { + const command = new GetEmailIdentityCommand({ EmailIdentity: emailIdentity }) + const response = await client.send(command) + + return { + identityType: response.IdentityType ?? '', + verifiedForSendingStatus: response.VerifiedForSendingStatus ?? false, + verificationStatus: response.VerificationStatus ?? null, + feedbackForwardingStatus: response.FeedbackForwardingStatus ?? null, + configurationSetName: response.ConfigurationSetName ?? null, + dkimAttributes: response.DkimAttributes + ? { + signingEnabled: response.DkimAttributes.SigningEnabled ?? null, + status: response.DkimAttributes.Status ?? null, + tokens: response.DkimAttributes.Tokens ?? [], + signingAttributesOrigin: response.DkimAttributes.SigningAttributesOrigin ?? null, + nextSigningKeyLength: response.DkimAttributes.NextSigningKeyLength ?? null, + currentSigningKeyLength: response.DkimAttributes.CurrentSigningKeyLength ?? null, + lastKeyGenerationTimestamp: + response.DkimAttributes.LastKeyGenerationTimestamp?.toISOString() ?? null, + signingHostedZone: response.DkimAttributes.SigningHostedZone ?? null, + } + : null, + mailFromAttributes: response.MailFromAttributes + ? { + mailFromDomain: response.MailFromAttributes.MailFromDomain ?? null, + mailFromDomainStatus: response.MailFromAttributes.MailFromDomainStatus ?? null, + behaviorOnMxFailure: response.MailFromAttributes.BehaviorOnMxFailure ?? null, + } + : null, + policies: response.Policies ?? null, + tags: (response.Tags ?? []).map((t) => ({ key: t.Key ?? '', value: t.Value ?? '' })), + verificationInfo: response.VerificationInfo + ? { + errorType: response.VerificationInfo.ErrorType ?? null, + lastCheckedTimestamp: + response.VerificationInfo.LastCheckedTimestamp?.toISOString() ?? null, + lastSuccessTimestamp: + response.VerificationInfo.LastSuccessTimestamp?.toISOString() ?? null, + } + : null, + } +} + +export async function createConfigurationSet( + client: SESv2Client, + params: { + configurationSetName: string + customRedirectDomain?: string | null + httpsPolicy?: 'REQUIRE' | 'REQUIRE_OPEN_ONLY' | 'OPTIONAL' | null + tlsPolicy?: TlsPolicy | null + sendingPoolName?: string | null + reputationMetricsEnabled?: boolean | null + sendingEnabled?: boolean | null + suppressedReasons?: SuppressionListReason[] | null + tags?: Array<{ key: string; value: string }> | null + } +) { + const command = new CreateConfigurationSetCommand({ + ConfigurationSetName: params.configurationSetName, + ...(params.customRedirectDomain + ? { + TrackingOptions: { + CustomRedirectDomain: params.customRedirectDomain, + ...(params.httpsPolicy ? { HttpsPolicy: params.httpsPolicy } : {}), + }, + } + : {}), + ...(params.tlsPolicy || params.sendingPoolName + ? { + DeliveryOptions: { + ...(params.tlsPolicy ? { TlsPolicy: params.tlsPolicy } : {}), + ...(params.sendingPoolName ? { SendingPoolName: params.sendingPoolName } : {}), + }, + } + : {}), + ...(params.reputationMetricsEnabled != null + ? { ReputationOptions: { ReputationMetricsEnabled: params.reputationMetricsEnabled } } + : {}), + ...(params.sendingEnabled != null + ? { SendingOptions: { SendingEnabled: params.sendingEnabled } } + : {}), + ...(params.suppressedReasons?.length + ? { SuppressionOptions: { SuppressedReasons: params.suppressedReasons } } + : {}), + ...(params.tags?.length + ? { Tags: params.tags.map((t) => ({ Key: t.key, Value: t.value })) } + : {}), + }) + + await client.send(command) + + return { + message: `Configuration set '${params.configurationSetName}' created successfully`, + } +} + +export async function sendCustomVerificationEmail( + client: SESv2Client, + params: { + emailAddress: string + templateName: string + configurationSetName?: string | null + } +) { + const command = new SendCustomVerificationEmailCommand({ + EmailAddress: params.emailAddress, + TemplateName: params.templateName, + ...(params.configurationSetName ? { ConfigurationSetName: params.configurationSetName } : {}), + }) + + const response = await client.send(command) + + return { + messageId: response.MessageId ?? '', + } +} diff --git a/apps/sim/app/api/tools/sts/assume-role-with-saml/route.ts b/apps/sim/app/api/tools/sts/assume-role-with-saml/route.ts new file mode 100644 index 00000000000..d8762ee4e52 --- /dev/null +++ b/apps/sim/app/api/tools/sts/assume-role-with-saml/route.ts @@ -0,0 +1,55 @@ +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { awsStsAssumeRoleWithSAMLContract } from '@/lib/api/contracts/tools/aws/sts-assume-role-with-saml' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { assumeRoleWithSAML, createUnauthenticatedSTSClient } from '../utils' + +const logger = createLogger('STSAssumeRoleWithSAMLAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + try { + const parsed = await parseToolRequest(awsStsAssumeRoleWithSAMLContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const params = parsed.data.body + + logger.info(`Assuming role ${params.roleArn} with SAML`) + + const client = createUnauthenticatedSTSClient(params.region) + + try { + const result = await assumeRoleWithSAML( + client, + params.roleArn, + params.principalArn, + params.samlAssertion, + params.policyArns, + params.policy, + params.durationSeconds + ) + + logger.info('Role assumed successfully with SAML') + + return NextResponse.json(result) + } finally { + client.destroy() + } + } catch (error) { + logger.error('Failed to assume role with SAML', { error: toError(error).message }) + + return NextResponse.json( + { error: `Failed to assume role with SAML: ${toError(error).message}` }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/sts/assume-role-with-web-identity/route.ts b/apps/sim/app/api/tools/sts/assume-role-with-web-identity/route.ts new file mode 100644 index 00000000000..bd951266bad --- /dev/null +++ b/apps/sim/app/api/tools/sts/assume-role-with-web-identity/route.ts @@ -0,0 +1,56 @@ +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { awsStsAssumeRoleWithWebIdentityContract } from '@/lib/api/contracts/tools/aws/sts-assume-role-with-web-identity' +import { parseToolRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { assumeRoleWithWebIdentity, createUnauthenticatedSTSClient } from '../utils' + +const logger = createLogger('STSAssumeRoleWithWebIdentityAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const auth = await checkInternalAuth(request) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 }) + } + + try { + const parsed = await parseToolRequest(awsStsAssumeRoleWithWebIdentityContract, request, { + errorFormat: 'details', + logger, + }) + if (!parsed.success) return parsed.response + const params = parsed.data.body + + logger.info(`Assuming role ${params.roleArn} with web identity`) + + const client = createUnauthenticatedSTSClient(params.region) + + try { + const result = await assumeRoleWithWebIdentity( + client, + params.roleArn, + params.roleSessionName, + params.webIdentityToken, + params.providerId, + params.policyArns, + params.policy, + params.durationSeconds + ) + + logger.info('Role assumed successfully with web identity') + + return NextResponse.json(result) + } finally { + client.destroy() + } + } catch (error) { + logger.error('Failed to assume role with web identity', { error: toError(error).message }) + + return NextResponse.json( + { error: `Failed to assume role with web identity: ${toError(error).message}` }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/sts/assume-role/route.ts b/apps/sim/app/api/tools/sts/assume-role/route.ts index 442250b02ea..a66513a96b9 100644 --- a/apps/sim/app/api/tools/sts/assume-role/route.ts +++ b/apps/sim/app/api/tools/sts/assume-role/route.ts @@ -40,7 +40,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => { params.policy, params.externalId, params.serialNumber, - params.tokenCode + params.tokenCode, + params.policyArns, + params.tags, + params.transitiveTagKeys ) logger.info('Role assumed successfully') diff --git a/apps/sim/app/api/tools/sts/utils.ts b/apps/sim/app/api/tools/sts/utils.ts index cf4bee12b68..a0caec02260 100644 --- a/apps/sim/app/api/tools/sts/utils.ts +++ b/apps/sim/app/api/tools/sts/utils.ts @@ -1,9 +1,13 @@ import { AssumeRoleCommand, + AssumeRoleWithSAMLCommand, + AssumeRoleWithWebIdentityCommand, GetAccessKeyInfoCommand, GetCallerIdentityCommand, GetSessionTokenCommand, + type PolicyDescriptorType, STSClient, + type Tag, } from '@aws-sdk/client-sts' import type { STSConnectionConfig } from '@/tools/sts/types' @@ -17,6 +21,52 @@ export function createSTSClient(config: STSConnectionConfig): STSClient { }) } +/** + * Creates an STS client for AssumeRoleWithWebIdentity / AssumeRoleWithSAML, + * which authenticate the caller via the supplied token/assertion rather than + * an IAM access key — AWS does not check the request signature for these two + * operations. The SDK's signing middleware still requires a `credentials` + * value to be resolvable, though, so static placeholder credentials are + * supplied explicitly to skip the default credential provider chain (env + * vars, shared config, container/IMDS role). Without this, the client would + * throw a CredentialsProviderError before the request is even sent in + * environments with no ambient AWS identity, even though a real IAM identity + * was never required. + */ +export function createUnauthenticatedSTSClient(region: string): STSClient { + return new STSClient({ + region, + credentials: { accessKeyId: 'anonymous', secretAccessKey: 'anonymous' }, + }) +} + +function parsePolicyArns(policyArns?: string | null): PolicyDescriptorType[] | undefined { + if (!policyArns) return undefined + const arns = policyArns + .split(',') + .map((arn) => arn.trim()) + .filter((arn) => arn.length > 0) + return arns.length > 0 ? arns.map((arn) => ({ arn })) : undefined +} + +function parseTags(tags?: string | null): Tag[] | undefined { + if (!tags) return undefined + const parsed = JSON.parse(tags) as Record + const entries = Object.entries(parsed) + return entries.length > 0 + ? entries.map(([Key, Value]) => ({ Key, Value: String(Value) })) + : undefined +} + +function parseTransitiveTagKeys(transitiveTagKeys?: string | null): string[] | undefined { + if (!transitiveTagKeys) return undefined + const keys = transitiveTagKeys + .split(',') + .map((key) => key.trim()) + .filter((key) => key.length > 0) + return keys.length > 0 ? keys : undefined +} + export async function assumeRole( client: STSClient, roleArn: string, @@ -25,7 +75,10 @@ export async function assumeRole( policy?: string | null, externalId?: string | null, serialNumber?: string | null, - tokenCode?: string | null + tokenCode?: string | null, + policyArns?: string | null, + tags?: string | null, + transitiveTagKeys?: string | null ) { const command = new AssumeRoleCommand({ RoleArn: roleArn, @@ -35,6 +88,93 @@ export async function assumeRole( ...(externalId ? { ExternalId: externalId } : {}), ...(serialNumber ? { SerialNumber: serialNumber } : {}), ...(tokenCode ? { TokenCode: tokenCode } : {}), + ...(() => { + const arns = parsePolicyArns(policyArns) + return arns ? { PolicyArns: arns } : {} + })(), + ...(() => { + const sessionTags = parseTags(tags) + return sessionTags ? { Tags: sessionTags } : {} + })(), + ...(() => { + const keys = parseTransitiveTagKeys(transitiveTagKeys) + return keys ? { TransitiveTagKeys: keys } : {} + })(), + }) + + const response = await client.send(command) + + return { + accessKeyId: response.Credentials?.AccessKeyId ?? '', + secretAccessKey: response.Credentials?.SecretAccessKey ?? '', + sessionToken: response.Credentials?.SessionToken ?? '', + expiration: response.Credentials?.Expiration?.toISOString() ?? null, + assumedRoleArn: response.AssumedRoleUser?.Arn ?? '', + assumedRoleId: response.AssumedRoleUser?.AssumedRoleId ?? '', + packedPolicySize: response.PackedPolicySize ?? null, + sourceIdentity: response.SourceIdentity ?? null, + } +} + +export async function assumeRoleWithWebIdentity( + client: STSClient, + roleArn: string, + roleSessionName: string, + webIdentityToken: string, + providerId?: string | null, + policyArns?: string | null, + policy?: string | null, + durationSeconds?: number | null +) { + const command = new AssumeRoleWithWebIdentityCommand({ + RoleArn: roleArn, + RoleSessionName: roleSessionName, + WebIdentityToken: webIdentityToken, + ...(providerId ? { ProviderId: providerId } : {}), + ...(policy ? { Policy: policy } : {}), + ...(durationSeconds ? { DurationSeconds: durationSeconds } : {}), + ...(() => { + const arns = parsePolicyArns(policyArns) + return arns ? { PolicyArns: arns } : {} + })(), + }) + + const response = await client.send(command) + + return { + accessKeyId: response.Credentials?.AccessKeyId ?? '', + secretAccessKey: response.Credentials?.SecretAccessKey ?? '', + sessionToken: response.Credentials?.SessionToken ?? '', + expiration: response.Credentials?.Expiration?.toISOString() ?? null, + assumedRoleArn: response.AssumedRoleUser?.Arn ?? '', + assumedRoleId: response.AssumedRoleUser?.AssumedRoleId ?? '', + subjectFromWebIdentityToken: response.SubjectFromWebIdentityToken ?? '', + audience: response.Audience ?? null, + provider: response.Provider ?? null, + packedPolicySize: response.PackedPolicySize ?? null, + sourceIdentity: response.SourceIdentity ?? null, + } +} + +export async function assumeRoleWithSAML( + client: STSClient, + roleArn: string, + principalArn: string, + samlAssertion: string, + policyArns?: string | null, + policy?: string | null, + durationSeconds?: number | null +) { + const command = new AssumeRoleWithSAMLCommand({ + RoleArn: roleArn, + PrincipalArn: principalArn, + SAMLAssertion: samlAssertion, + ...(policy ? { Policy: policy } : {}), + ...(durationSeconds ? { DurationSeconds: durationSeconds } : {}), + ...(() => { + const arns = parsePolicyArns(policyArns) + return arns ? { PolicyArns: arns } : {} + })(), }) const response = await client.send(command) @@ -46,6 +186,11 @@ export async function assumeRole( expiration: response.Credentials?.Expiration?.toISOString() ?? null, assumedRoleArn: response.AssumedRoleUser?.Arn ?? '', assumedRoleId: response.AssumedRoleUser?.AssumedRoleId ?? '', + subject: response.Subject ?? null, + subjectType: response.SubjectType ?? null, + issuer: response.Issuer ?? null, + audience: response.Audience ?? null, + nameQualifier: response.NameQualifier ?? null, packedPolicySize: response.PackedPolicySize ?? null, sourceIdentity: response.SourceIdentity ?? null, } diff --git a/apps/sim/blocks/blocks/secrets_manager.ts b/apps/sim/blocks/blocks/secrets_manager.ts index ccf2c4ae230..19fda449390 100644 --- a/apps/sim/blocks/blocks/secrets_manager.ts +++ b/apps/sim/blocks/blocks/secrets_manager.ts @@ -8,7 +8,7 @@ export const SecretsManagerBlock: BlockConfig = { name: 'AWS Secrets Manager', description: 'Connect to AWS Secrets Manager', longDescription: - 'Integrate AWS Secrets Manager into the workflow. Can retrieve, create, update, list, and delete secrets.', + 'Integrate AWS Secrets Manager into the workflow. Can retrieve, create, update, list, delete, describe, tag, untag, restore, and rotate secrets.', docsLink: 'https://docs.sim.ai/integrations/secrets_manager', category: 'tools', integrationType: IntegrationType.Security, @@ -25,6 +25,11 @@ export const SecretsManagerBlock: BlockConfig = { { label: 'Create Secret', id: 'create_secret' }, { label: 'Update Secret', id: 'update_secret' }, { label: 'Delete Secret', id: 'delete_secret' }, + { label: 'Describe Secret', id: 'describe_secret' }, + { label: 'Tag Secret', id: 'tag_resource' }, + { label: 'Untag Secret', id: 'untag_resource' }, + { label: 'Restore Secret', id: 'restore_secret' }, + { label: 'Rotate Secret', id: 'rotate_secret' }, ], value: () => 'get_secret', }, @@ -56,8 +61,32 @@ export const SecretsManagerBlock: BlockConfig = { title: 'Secret Name or ARN', type: 'short-input', placeholder: 'my-app/database-password', - condition: { field: 'operation', value: ['get_secret', 'update_secret', 'delete_secret'] }, - required: { field: 'operation', value: ['get_secret', 'update_secret', 'delete_secret'] }, + condition: { + field: 'operation', + value: [ + 'get_secret', + 'update_secret', + 'delete_secret', + 'describe_secret', + 'tag_resource', + 'untag_resource', + 'restore_secret', + 'rotate_secret', + ], + }, + required: { + field: 'operation', + value: [ + 'get_secret', + 'update_secret', + 'delete_secret', + 'describe_secret', + 'tag_resource', + 'untag_resource', + 'restore_secret', + 'rotate_secret', + ], + }, }, { id: 'name', @@ -142,6 +171,81 @@ export const SecretsManagerBlock: BlockConfig = { required: false, mode: 'advanced', }, + { + id: 'tags', + title: 'Tags', + type: 'code', + placeholder: '[{"key":"env","value":"prod"}]', + condition: { field: 'operation', value: 'tag_resource' }, + required: { field: 'operation', value: 'tag_resource' }, + }, + { + id: 'tagKeys', + title: 'Tag Keys', + type: 'code', + placeholder: '["env","team"]', + condition: { field: 'operation', value: 'untag_resource' }, + required: { field: 'operation', value: 'untag_resource' }, + }, + { + id: 'rotationLambdaARN', + title: 'Rotation Lambda ARN', + type: 'short-input', + placeholder: + 'arn:aws:lambda:us-east-1:123456789012:function:my-rotation-fn (omit for managed rotation)', + condition: { field: 'operation', value: 'rotate_secret' }, + required: false, + mode: 'advanced', + }, + { + id: 'automaticallyAfterDays', + title: 'Automatically After Days', + type: 'short-input', + placeholder: '30', + condition: { field: 'operation', value: 'rotate_secret' }, + required: false, + mode: 'advanced', + }, + { + id: 'duration', + title: 'Rotation Window Duration', + type: 'short-input', + placeholder: '3h', + condition: { field: 'operation', value: 'rotate_secret' }, + required: false, + mode: 'advanced', + }, + { + id: 'scheduleExpression', + title: 'Schedule Expression', + type: 'short-input', + placeholder: 'cron(0 16 1,15 * ? *) or rate(10 days)', + condition: { field: 'operation', value: 'rotate_secret' }, + required: false, + mode: 'advanced', + }, + { + id: 'rotateImmediately', + title: 'Rotate Immediately', + type: 'dropdown', + options: [ + { label: 'Yes', id: 'true' }, + { label: 'No', id: 'false' }, + ], + value: () => 'true', + condition: { field: 'operation', value: 'rotate_secret' }, + required: false, + mode: 'advanced', + }, + { + id: 'clientRequestToken', + title: 'Client Request Token', + type: 'short-input', + placeholder: 'Idempotency token (32-64 chars, optional)', + condition: { field: 'operation', value: 'rotate_secret' }, + required: false, + mode: 'advanced', + }, ], tools: { access: [ @@ -150,6 +254,11 @@ export const SecretsManagerBlock: BlockConfig = { 'secrets_manager_create_secret', 'secrets_manager_update_secret', 'secrets_manager_delete_secret', + 'secrets_manager_describe_secret', + 'secrets_manager_tag_resource', + 'secrets_manager_untag_resource', + 'secrets_manager_restore_secret', + 'secrets_manager_rotate_secret', ], config: { tool: (params) => { @@ -164,12 +273,30 @@ export const SecretsManagerBlock: BlockConfig = { return 'secrets_manager_update_secret' case 'delete_secret': return 'secrets_manager_delete_secret' + case 'describe_secret': + return 'secrets_manager_describe_secret' + case 'tag_resource': + return 'secrets_manager_tag_resource' + case 'untag_resource': + return 'secrets_manager_untag_resource' + case 'restore_secret': + return 'secrets_manager_restore_secret' + case 'rotate_secret': + return 'secrets_manager_rotate_secret' default: throw new Error(`Invalid Secrets Manager operation: ${params.operation}`) } }, params: (params) => { - const { operation, forceDelete, recoveryWindowInDays, maxResults, ...rest } = params + const { + operation, + forceDelete, + recoveryWindowInDays, + maxResults, + automaticallyAfterDays, + rotateImmediately, + ...rest + } = params const connectionConfig = { region: rest.region, @@ -210,6 +337,37 @@ export const SecretsManagerBlock: BlockConfig = { } if (forceDelete === 'true' || forceDelete === true) result.forceDelete = true break + case 'describe_secret': + result.secretId = rest.secretId + break + case 'tag_resource': + result.secretId = rest.secretId + result.tags = typeof rest.tags === 'string' ? JSON.parse(rest.tags) : rest.tags + break + case 'untag_resource': + result.secretId = rest.secretId + result.tagKeys = + typeof rest.tagKeys === 'string' ? JSON.parse(rest.tagKeys) : rest.tagKeys + break + case 'restore_secret': + result.secretId = rest.secretId + break + case 'rotate_secret': + result.secretId = rest.secretId + if (rest.clientRequestToken) result.clientRequestToken = rest.clientRequestToken + if (rest.rotationLambdaARN) result.rotationLambdaARN = rest.rotationLambdaARN + if (automaticallyAfterDays) { + const parsed = Number.parseInt(String(automaticallyAfterDays), 10) + if (!Number.isNaN(parsed)) result.automaticallyAfterDays = parsed + } + if (rest.duration) result.duration = rest.duration + if (rest.scheduleExpression) result.scheduleExpression = rest.scheduleExpression + if (rotateImmediately === 'false' || rotateImmediately === false) { + result.rotateImmediately = false + } else if (rotateImmediately === 'true' || rotateImmediately === true) { + result.rotateImmediately = true + } + break } return result @@ -231,6 +389,14 @@ export const SecretsManagerBlock: BlockConfig = { nextToken: { type: 'string', description: 'Pagination token' }, recoveryWindowInDays: { type: 'number', description: 'Days before permanent deletion' }, forceDelete: { type: 'string', description: 'Force immediate deletion' }, + tags: { type: 'json', description: 'Tags to attach, as an array of {key, value} pairs' }, + tagKeys: { type: 'json', description: 'Tag keys to remove, as an array of strings' }, + rotationLambdaARN: { type: 'string', description: 'ARN of the Lambda rotation function' }, + automaticallyAfterDays: { type: 'number', description: 'Days between automatic rotations' }, + duration: { type: 'string', description: 'Rotation window duration (e.g., 3h)' }, + scheduleExpression: { type: 'string', description: 'cron() or rate() rotation schedule' }, + rotateImmediately: { type: 'string', description: 'Whether to rotate immediately' }, + clientRequestToken: { type: 'string', description: 'Idempotency token for rotation' }, }, outputs: { message: { @@ -277,6 +443,55 @@ export const SecretsManagerBlock: BlockConfig = { type: 'string', description: 'Scheduled deletion date', }, + description: { + type: 'string', + description: 'Description of the secret', + }, + kmsKeyId: { + type: 'string', + description: 'KMS key ID used to encrypt the secret', + }, + rotationEnabled: { + type: 'boolean', + description: 'Whether automatic rotation is enabled', + }, + rotationLambdaARN: { + type: 'string', + description: 'ARN of the Lambda function used for rotation', + }, + rotationRules: { + type: 'json', + description: + 'Rotation schedule configuration (automaticallyAfterDays, duration, scheduleExpression)', + }, + lastRotatedDate: { + type: 'string', + description: 'Date the secret was last rotated', + }, + nextRotationDate: { + type: 'string', + description: 'Date the secret is next scheduled to rotate', + }, + deletedDate: { + type: 'string', + description: 'Date the secret is scheduled for deletion, if any', + }, + tags: { + type: 'json', + description: 'Tags attached to the secret', + }, + owningService: { + type: 'string', + description: 'ID of the AWS service that manages this secret, if any', + }, + primaryRegion: { + type: 'string', + description: 'The primary region of the secret, if replicated', + }, + replicationStatus: { + type: 'json', + description: 'Replication status for each region the secret is replicated to', + }, }, } @@ -353,6 +568,15 @@ export const SecretsManagerBlockMeta = { tags: ['devops', 'monitoring'], alsoIntegrations: ['slack'], }, + { + icon: SecretsManagerIcon, + title: 'Secrets Manager native rotation kickoff', + prompt: + 'Build a scheduled workflow that describes AWS Secrets Manager secrets nearing their next rotation date, starts native rotation for the ones that are due, and writes the outcome to a compliance table.', + modules: ['scheduled', 'tables', 'agent', 'workflows'], + category: 'operations', + tags: ['devops', 'enterprise'], + }, ], skills: [ { @@ -383,5 +607,26 @@ export const SecretsManagerBlockMeta = { content: '# Decommission Secret\n\nRetire a secret without permanently losing it immediately.\n\n## Steps\n1. Confirm the target secret and that nothing still depends on it.\n2. Schedule deletion with a recovery window (e.g. 30 days) so it can be restored during that period; reserve force delete for confirmed-orphaned secrets only.\n3. Record the scheduled deletion date.\n4. Re-list secrets to confirm it is marked for deletion.\n\n## Output\nConfirm the secret name and the scheduled deletion date, or that immediate deletion was requested. Do not print the secret value.', }, + { + name: 'start-native-rotation', + description: + 'Start or reconfigure AWS Secrets Manager native rotation for a secret that already has a rotation Lambda configured. Use when compliance requires automatic, scheduled rotation rather than manual updates.', + content: + '# Start Native Rotation\n\nConfigure or trigger built-in Secrets Manager rotation.\n\n## Steps\n1. Describe the target secret to confirm it has a rotation Lambda ARN configured (or supply one).\n2. Decide the rotation schedule: a fixed interval in days, or a cron/rate schedule expression, plus an optional rotation window duration.\n3. Start rotation with the chosen schedule; rotation runs immediately unless configured to wait for the next window.\n4. Re-describe the secret afterward to confirm the next rotation date and rotation-enabled status.\n\n## Output\nConfirm the secret name, whether rotation is now enabled, and the next scheduled rotation date. Never print the secret value.', + }, + { + name: 'restore-deleted-secret', + description: + 'Cancel a scheduled deletion in AWS Secrets Manager to restore access to a secret before its recovery window elapses. Use to reverse an accidental or premature delete.', + content: + '# Restore Deleted Secret\n\nUndo a pending deletion while the recovery window is still open.\n\n## Steps\n1. Describe the secret to confirm it has a scheduled deletion date.\n2. Restore the secret, which clears the scheduled deletion.\n3. Re-describe the secret to confirm the deletion date is cleared.\n\n## Output\nConfirm the secret name and ARN, and that it is no longer scheduled for deletion.', + }, + { + name: 'tag-secret-for-governance', + description: + 'Attach or remove ownership, environment, or cost-center tags on an AWS Secrets Manager secret for governance and cost allocation. Use to keep secret metadata consistent with tagging policy.', + content: + '# Tag Secret for Governance\n\nKeep secret tags aligned with organizational tagging policy.\n\n## Steps\n1. Describe the secret to see its current tags.\n2. Attach the required tags (e.g. owner, environment, cost-center) as key/value pairs, or remove tags that no longer apply by key.\n3. Re-describe the secret to confirm the tag set matches policy.\n\n## Output\nConfirm the secret name and the resulting tag keys. Do not print the secret value.', + }, ], } as const satisfies BlockMeta diff --git a/apps/sim/blocks/blocks/ses.ts b/apps/sim/blocks/blocks/ses.ts index 2f7ad5ff10f..30661319b4c 100644 --- a/apps/sim/blocks/blocks/ses.ts +++ b/apps/sim/blocks/blocks/ses.ts @@ -8,7 +8,7 @@ export const SESBlock: BlockConfig = { name: 'AWS SES', description: 'Send emails and manage templates with AWS Simple Email Service', longDescription: - 'Integrate AWS SES v2 into the workflow. Send simple, templated, and bulk emails. Manage email templates and retrieve account sending quota and verified identity information.', + 'Integrate AWS SES v2 into the workflow. Send simple, templated, and bulk emails. Manage email templates, identities, configuration sets, and the account suppression list, and retrieve account sending quota and verified identity information.', docsLink: 'https://docs.sim.ai/integrations/ses', category: 'tools', integrationType: IntegrationType.Email, @@ -30,6 +30,16 @@ export const SESBlock: BlockConfig = { { label: 'Get Template', id: 'get_template' }, { label: 'List Templates', id: 'list_templates' }, { label: 'Delete Template', id: 'delete_template' }, + { label: 'Update Template', id: 'update_template' }, + { label: 'Send Custom Verification Email', id: 'send_custom_verification_email' }, + { label: 'Create Email Identity', id: 'create_email_identity' }, + { label: 'Get Email Identity', id: 'get_email_identity' }, + { label: 'Delete Email Identity', id: 'delete_email_identity' }, + { label: 'Put Suppressed Destination', id: 'put_suppressed_destination' }, + { label: 'Get Suppressed Destination', id: 'get_suppressed_destination' }, + { label: 'List Suppressed Destinations', id: 'list_suppressed_destinations' }, + { label: 'Delete Suppressed Destination', id: 'delete_suppressed_destination' }, + { label: 'Create Configuration Set', id: 'create_configuration_set' }, ], value: () => 'send_email', }, @@ -128,6 +138,8 @@ export const SESBlock: BlockConfig = { 'get_template', 'create_template', 'delete_template', + 'update_template', + 'send_custom_verification_email', ], }, required: { @@ -138,6 +150,8 @@ export const SESBlock: BlockConfig = { 'get_template', 'create_template', 'delete_template', + 'update_template', + 'send_custom_verification_email', ], }, }, @@ -165,15 +179,15 @@ export const SESBlock: BlockConfig = { title: 'Subject', type: 'short-input', placeholder: 'Hello, {{name}}!', - condition: { field: 'operation', value: 'create_template' }, - required: { field: 'operation', value: 'create_template' }, + condition: { field: 'operation', value: ['create_template', 'update_template'] }, + required: { field: 'operation', value: ['create_template', 'update_template'] }, }, { id: 'htmlPart', title: 'HTML Body', type: 'long-input', placeholder: '

Hello, {{name}}!

', - condition: { field: 'operation', value: 'create_template' }, + condition: { field: 'operation', value: ['create_template', 'update_template'] }, required: false, }, { @@ -181,7 +195,7 @@ export const SESBlock: BlockConfig = { title: 'Plain Text Body', type: 'long-input', placeholder: 'Hello, {{name}}!', - condition: { field: 'operation', value: 'create_template' }, + condition: { field: 'operation', value: ['create_template', 'update_template'] }, required: false, mode: 'advanced', }, @@ -235,7 +249,13 @@ export const SESBlock: BlockConfig = { placeholder: 'my-configuration-set', condition: { field: 'operation', - value: ['send_email', 'send_templated_email', 'send_bulk_email'], + value: [ + 'send_email', + 'send_templated_email', + 'send_bulk_email', + 'send_custom_verification_email', + 'create_email_identity', + ], }, required: false, mode: 'advanced', @@ -247,7 +267,7 @@ export const SESBlock: BlockConfig = { placeholder: '100', condition: { field: 'operation', - value: ['list_identities', 'list_templates'], + value: ['list_identities', 'list_templates', 'list_suppressed_destinations'], }, required: false, mode: 'advanced', @@ -259,11 +279,198 @@ export const SESBlock: BlockConfig = { placeholder: 'Pagination token from previous response', condition: { field: 'operation', - value: ['list_identities', 'list_templates'], + value: ['list_identities', 'list_templates', 'list_suppressed_destinations'], + }, + required: false, + mode: 'advanced', + }, + { + id: 'emailAddress', + title: 'Email Address', + type: 'short-input', + placeholder: 'recipient@example.com', + condition: { + field: 'operation', + value: [ + 'put_suppressed_destination', + 'get_suppressed_destination', + 'delete_suppressed_destination', + 'send_custom_verification_email', + ], + }, + required: { + field: 'operation', + value: [ + 'put_suppressed_destination', + 'get_suppressed_destination', + 'delete_suppressed_destination', + 'send_custom_verification_email', + ], + }, + }, + { + id: 'reason', + title: 'Suppression Reason', + type: 'dropdown', + options: [ + { label: 'Bounce', id: 'BOUNCE' }, + { label: 'Complaint', id: 'COMPLAINT' }, + ], + condition: { field: 'operation', value: 'put_suppressed_destination' }, + required: { field: 'operation', value: 'put_suppressed_destination' }, + value: () => 'BOUNCE', + }, + { + id: 'reasons', + title: 'Reasons Filter', + type: 'short-input', + placeholder: 'BOUNCE, COMPLAINT', + condition: { field: 'operation', value: 'list_suppressed_destinations' }, + required: false, + mode: 'advanced', + }, + { + id: 'startDate', + title: 'Start Date', + type: 'short-input', + placeholder: 'ISO 8601 timestamp', + condition: { field: 'operation', value: 'list_suppressed_destinations' }, + required: false, + mode: 'advanced', + wandConfig: { + enabled: true, + prompt: + 'Generate an ISO 8601 timestamp based on the user description. Return ONLY the timestamp string.', + generationType: 'timestamp', + }, + }, + { + id: 'endDate', + title: 'End Date', + type: 'short-input', + placeholder: 'ISO 8601 timestamp', + condition: { field: 'operation', value: 'list_suppressed_destinations' }, + required: false, + mode: 'advanced', + wandConfig: { + enabled: true, + prompt: + 'Generate an ISO 8601 timestamp based on the user description. Return ONLY the timestamp string.', + generationType: 'timestamp', + }, + }, + { + id: 'emailIdentity', + title: 'Email Identity', + type: 'short-input', + placeholder: 'example.com or sender@example.com', + condition: { + field: 'operation', + value: ['create_email_identity', 'get_email_identity', 'delete_email_identity'], + }, + required: { + field: 'operation', + value: ['create_email_identity', 'get_email_identity', 'delete_email_identity'], + }, + }, + { + id: 'dkimSigningAttributes', + title: 'DKIM Signing Attributes (JSON)', + type: 'code', + language: 'json', + placeholder: + '{"domainSigningSelector": "selector1", "domainSigningPrivateKey": "base64-key"}', + condition: { field: 'operation', value: 'create_email_identity' }, + required: false, + mode: 'advanced', + }, + { + id: 'tags', + title: 'Tags (JSON)', + type: 'code', + language: 'json', + placeholder: '[{"key": "team", "value": "growth"}]', + condition: { + field: 'operation', + value: ['create_email_identity', 'create_configuration_set'], }, required: false, mode: 'advanced', }, + { + id: 'newConfigurationSetName', + title: 'Configuration Set Name', + type: 'short-input', + placeholder: 'my-configuration-set', + condition: { field: 'operation', value: 'create_configuration_set' }, + required: { field: 'operation', value: 'create_configuration_set' }, + }, + { + id: 'customRedirectDomain', + title: 'Custom Redirect Domain', + type: 'short-input', + placeholder: 'links.example.com', + condition: { field: 'operation', value: 'create_configuration_set' }, + required: false, + mode: 'advanced', + }, + { + id: 'httpsPolicy', + title: 'HTTPS Policy', + type: 'dropdown', + options: [ + { label: 'Require', id: 'REQUIRE' }, + { label: 'Require Open Only', id: 'REQUIRE_OPEN_ONLY' }, + { label: 'Optional', id: 'OPTIONAL' }, + ], + condition: { field: 'operation', value: 'create_configuration_set' }, + required: false, + mode: 'advanced', + }, + { + id: 'tlsPolicy', + title: 'TLS Policy', + type: 'dropdown', + options: [ + { label: 'Require', id: 'REQUIRE' }, + { label: 'Optional', id: 'OPTIONAL' }, + ], + condition: { field: 'operation', value: 'create_configuration_set' }, + required: false, + mode: 'advanced', + }, + { + id: 'sendingPoolName', + title: 'Dedicated IP Pool', + type: 'short-input', + placeholder: 'my-ip-pool', + condition: { field: 'operation', value: 'create_configuration_set' }, + required: false, + mode: 'advanced', + }, + { + id: 'reputationMetricsEnabled', + title: 'Enable Reputation Metrics', + type: 'switch', + condition: { field: 'operation', value: 'create_configuration_set' }, + mode: 'advanced', + }, + { + id: 'sendingEnabled', + title: 'Enable Sending', + type: 'switch', + condition: { field: 'operation', value: 'create_configuration_set' }, + mode: 'advanced', + }, + { + id: 'suppressedReasons', + title: 'Auto-Suppress Reasons', + type: 'short-input', + placeholder: 'BOUNCE, COMPLAINT', + condition: { field: 'operation', value: 'create_configuration_set' }, + required: false, + mode: 'advanced', + }, ], tools: { access: [ @@ -276,6 +483,16 @@ export const SESBlock: BlockConfig = { 'ses_get_template', 'ses_list_templates', 'ses_delete_template', + 'ses_update_template', + 'ses_put_suppressed_destination', + 'ses_delete_suppressed_destination', + 'ses_get_suppressed_destination', + 'ses_list_suppressed_destinations', + 'ses_create_email_identity', + 'ses_delete_email_identity', + 'ses_get_email_identity', + 'ses_create_configuration_set', + 'ses_send_custom_verification_email', ], config: { tool: (params) => { @@ -298,6 +515,26 @@ export const SESBlock: BlockConfig = { return 'ses_list_templates' case 'delete_template': return 'ses_delete_template' + case 'update_template': + return 'ses_update_template' + case 'put_suppressed_destination': + return 'ses_put_suppressed_destination' + case 'delete_suppressed_destination': + return 'ses_delete_suppressed_destination' + case 'get_suppressed_destination': + return 'ses_get_suppressed_destination' + case 'list_suppressed_destinations': + return 'ses_list_suppressed_destinations' + case 'create_email_identity': + return 'ses_create_email_identity' + case 'delete_email_identity': + return 'ses_delete_email_identity' + case 'get_email_identity': + return 'ses_get_email_identity' + case 'create_configuration_set': + return 'ses_create_configuration_set' + case 'send_custom_verification_email': + return 'ses_send_custom_verification_email' default: throw new Error(`Invalid SES operation: ${params.operation}`) } @@ -314,6 +551,60 @@ export const SESBlock: BlockConfig = { const result: Record = { ...connectionConfig } switch (operation) { + case 'update_template': + result.templateName = rest.templateName + result.subjectPart = rest.subjectPart + if (rest.htmlPart) result.htmlPart = rest.htmlPart + if (rest.textPart) result.textPart = rest.textPart + break + case 'put_suppressed_destination': + result.emailAddress = rest.emailAddress + result.reason = rest.reason + break + case 'delete_suppressed_destination': + case 'get_suppressed_destination': + result.emailAddress = rest.emailAddress + break + case 'list_suppressed_destinations': + if (rest.reasons) result.reasons = rest.reasons + if (rest.startDate) result.startDate = rest.startDate + if (rest.endDate) result.endDate = rest.endDate + if (pageSize != null) { + const parsed = Number.parseInt(String(pageSize), 10) + if (!Number.isNaN(parsed)) result.pageSize = parsed + } + if (rest.nextToken) result.nextToken = rest.nextToken + break + case 'create_email_identity': + result.emailIdentity = rest.emailIdentity + if (rest.dkimSigningAttributes) + result.dkimSigningAttributes = rest.dkimSigningAttributes + if (rest.tags) result.tags = rest.tags + if (rest.configurationSetName) result.configurationSetName = rest.configurationSetName + break + case 'delete_email_identity': + case 'get_email_identity': + result.emailIdentity = rest.emailIdentity + break + case 'create_configuration_set': + result.configurationSetName = rest.newConfigurationSetName + if (rest.customRedirectDomain) result.customRedirectDomain = rest.customRedirectDomain + if (rest.httpsPolicy) result.httpsPolicy = rest.httpsPolicy + if (rest.tlsPolicy) result.tlsPolicy = rest.tlsPolicy + if (rest.sendingPoolName) result.sendingPoolName = rest.sendingPoolName + if (rest.reputationMetricsEnabled != null) + result.reputationMetricsEnabled = + rest.reputationMetricsEnabled === true || rest.reputationMetricsEnabled === 'true' + if (rest.sendingEnabled != null) + result.sendingEnabled = rest.sendingEnabled === true || rest.sendingEnabled === 'true' + if (rest.suppressedReasons) result.suppressedReasons = rest.suppressedReasons + if (rest.tags) result.tags = rest.tags + break + case 'send_custom_verification_email': + result.emailAddress = rest.emailAddress + result.templateName = rest.templateName + if (rest.configurationSetName) result.configurationSetName = rest.configurationSetName + break case 'send_email': result.fromAddress = rest.fromAddress result.toAddresses = rest.toAddresses @@ -407,12 +698,38 @@ export const SESBlock: BlockConfig = { configurationSetName: { type: 'string', description: 'SES configuration set name' }, pageSize: { type: 'number', description: 'Maximum number of results to return' }, nextToken: { type: 'string', description: 'Pagination token from previous response' }, + emailAddress: { + type: 'string', + description: 'Email address for suppression or verification operations', + }, + reason: { type: 'string', description: 'Suppression reason: BOUNCE or COMPLAINT' }, + reasons: { type: 'string', description: 'Comma-separated suppression reasons filter' }, + startDate: { type: 'string', description: 'Suppression list filter start date (ISO 8601)' }, + endDate: { type: 'string', description: 'Suppression list filter end date (ISO 8601)' }, + emailIdentity: { type: 'string', description: 'Email address or domain identity' }, + dkimSigningAttributes: { type: 'json', description: 'JSON BYODKIM signing attributes' }, + tags: { type: 'json', description: 'JSON array of key/value tags' }, + newConfigurationSetName: { type: 'string', description: 'Name for a new configuration set' }, + customRedirectDomain: { type: 'string', description: 'Custom domain for open/click tracking' }, + httpsPolicy: { type: 'string', description: 'HTTPS policy for tracking links' }, + tlsPolicy: { type: 'string', description: 'TLS policy for delivery' }, + sendingPoolName: { type: 'string', description: 'Dedicated IP pool name' }, + reputationMetricsEnabled: { + type: 'boolean', + description: 'Whether to collect reputation metrics', + }, + sendingEnabled: { type: 'boolean', description: 'Whether sending is enabled' }, + suppressedReasons: { type: 'string', description: 'Comma-separated auto-suppression reasons' }, }, outputs: { messageId: { type: 'string', - description: 'SES message ID (send_email, send_templated_email)', - condition: { field: 'operation', value: ['send_email', 'send_templated_email'] }, + description: + 'SES message ID (send_email, send_templated_email, send_custom_verification_email)', + condition: { + field: 'operation', + value: ['send_email', 'send_templated_email', 'send_custom_verification_email'], + }, }, results: { type: 'array', @@ -436,13 +753,89 @@ export const SESBlock: BlockConfig = { }, nextToken: { type: 'string', - description: 'Pagination token for the next page (list_identities, list_templates)', - condition: { field: 'operation', value: ['list_identities', 'list_templates'] }, + description: + 'Pagination token for the next page (list_identities, list_templates, list_suppressed_destinations)', + condition: { + field: 'operation', + value: ['list_identities', 'list_templates', 'list_suppressed_destinations'], + }, }, count: { type: 'number', - description: 'Number of items returned (list_identities, list_templates)', - condition: { field: 'operation', value: ['list_identities', 'list_templates'] }, + description: + 'Number of items returned (list_identities, list_templates, list_suppressed_destinations)', + condition: { + field: 'operation', + value: ['list_identities', 'list_templates', 'list_suppressed_destinations'], + }, + }, + destinations: { + type: 'array', + description: 'List of suppressed destinations (list_suppressed_destinations)', + condition: { field: 'operation', value: 'list_suppressed_destinations' }, + }, + emailAddress: { + type: 'string', + description: 'The suppressed email address (get_suppressed_destination)', + condition: { field: 'operation', value: 'get_suppressed_destination' }, + }, + reason: { + type: 'string', + description: 'The suppression reason (get_suppressed_destination)', + condition: { field: 'operation', value: 'get_suppressed_destination' }, + }, + lastUpdateTime: { + type: 'string', + description: + 'When the address was added to the suppression list (get_suppressed_destination)', + condition: { field: 'operation', value: 'get_suppressed_destination' }, + }, + feedbackId: { + type: 'string', + description: 'Feedback ID for the bounce/complaint event (get_suppressed_destination)', + condition: { field: 'operation', value: 'get_suppressed_destination' }, + }, + identityType: { + type: 'string', + description: + 'Identity type: EMAIL_ADDRESS or DOMAIN (create_email_identity, get_email_identity)', + condition: { field: 'operation', value: ['create_email_identity', 'get_email_identity'] }, + }, + verifiedForSendingStatus: { + type: 'boolean', + description: + 'Whether the identity is verified for sending (create_email_identity, get_email_identity)', + condition: { field: 'operation', value: ['create_email_identity', 'get_email_identity'] }, + }, + dkimAttributes: { + type: 'json', + description: 'DKIM signing status and tokens (create_email_identity, get_email_identity)', + condition: { field: 'operation', value: ['create_email_identity', 'get_email_identity'] }, + }, + verificationStatus: { + type: 'string', + description: 'Identity verification status (get_email_identity)', + condition: { field: 'operation', value: 'get_email_identity' }, + }, + feedbackForwardingStatus: { + type: 'boolean', + description: 'Whether bounce/complaint feedback is forwarded by email (get_email_identity)', + condition: { field: 'operation', value: 'get_email_identity' }, + }, + mailFromAttributes: { + type: 'json', + description: 'Custom MAIL FROM domain configuration (get_email_identity)', + condition: { field: 'operation', value: 'get_email_identity' }, + }, + policies: { + type: 'json', + description: 'Sending authorization policies (get_email_identity)', + condition: { field: 'operation', value: 'get_email_identity' }, + }, + verificationInfo: { + type: 'json', + description: 'Additional verification diagnostics (get_email_identity)', + condition: { field: 'operation', value: 'get_email_identity' }, }, sendingEnabled: { type: 'boolean', @@ -489,10 +882,27 @@ export const SESBlock: BlockConfig = { description: 'List of email templates (list_templates)', condition: { field: 'operation', value: 'list_templates' }, }, + tags: { + type: 'array', + description: 'Tags associated with the identity (get_email_identity)', + condition: { field: 'operation', value: 'get_email_identity' }, + }, message: { type: 'string', - description: 'Confirmation message (create_template, delete_template)', - condition: { field: 'operation', value: ['create_template', 'delete_template'] }, + description: + 'Confirmation message (create_template, delete_template, update_template, put_suppressed_destination, delete_suppressed_destination, delete_email_identity, create_configuration_set)', + condition: { + field: 'operation', + value: [ + 'create_template', + 'delete_template', + 'update_template', + 'put_suppressed_destination', + 'delete_suppressed_destination', + 'delete_email_identity', + 'create_configuration_set', + ], + }, }, }, } @@ -549,6 +959,25 @@ export const SESBlockMeta = { tags: ['support', 'communication', 'automation'], alsoIntegrations: ['agentmail'], }, + { + icon: SESIcon, + title: 'SES suppression list sync', + prompt: + 'Build a workflow that reads bounce and complaint webhook events, adds the affected addresses to the SES account suppression list with the matching reason, and periodically lists suppressed destinations to reconcile a marketing contacts table so unreachable addresses are excluded from future sends.', + modules: ['tables', 'agent', 'workflows'], + category: 'marketing', + tags: ['marketing', 'automation', 'analysis'], + }, + { + icon: SESIcon, + title: 'SES new-domain onboarding', + prompt: + 'Create a workflow that takes a new sending domain from a table, creates the SES email identity, polls get email identity until DKIM verification succeeds, creates a dedicated configuration set with open and click tracking enabled, and posts the DNS tokens to Slack for the infrastructure team to add.', + modules: ['tables', 'agent', 'workflows'], + category: 'operations', + tags: ['devops', 'infrastructure', 'automation'], + alsoIntegrations: ['slack'], + }, { icon: SESIcon, title: 'SES domain reputation monitor', @@ -586,9 +1015,23 @@ export const SESBlockMeta = { { name: 'manage-email-templates', description: - 'Create, fetch, list, and delete reusable email templates in AWS SES. Use to maintain a consistent, version-controlled template library.', + 'Create, fetch, list, update, and delete reusable email templates in AWS SES. Use to maintain a consistent, version-controlled template library.', + content: + '# Manage Email Templates\n\nMaintain the SES template library.\n\n## Steps\n1. To add a template, create it with a name, subject, and HTML and text parts using placeholder variables.\n2. To review, get a template by name or list templates.\n3. To revise copy without changing the name, update the template with new subject, HTML, or text content.\n4. To retire one, delete the template by name.\n5. Keep template names descriptive so they are easy to reference when sending.\n\n## Output\nReport the template name affected and the action taken, or the template contents for a fetch.', + }, + { + name: 'manage-suppression-list', + description: + 'Add, remove, look up, and list addresses on the AWS SES account-level suppression list. Use to keep bounced or complained addresses out of future sends.', + content: + '# Manage Suppression List\n\nKeep the SES suppression list accurate so future sends skip unreachable or unwilling recipients.\n\n## Steps\n1. To suppress an address after a bounce or complaint, put a suppressed destination with the matching reason (BOUNCE or COMPLAINT).\n2. To check whether an address is already suppressed, get the suppressed destination by email address.\n3. To audit the list, list suppressed destinations, optionally filtered by reason or a date range.\n4. To re-enable sending to an address (for example, after a customer confirms a new inbox), delete the suppressed destination.\n\n## Output\nReport the email address affected and the action taken. For a list, report the count of suppressed addresses and their reasons.', + }, + { + name: 'onboard-sending-domain', + description: + 'Verify a new sending domain or address in AWS SES and check DKIM and verification status. Use before sending from a new identity.', content: - '# Manage Email Templates\n\nMaintain the SES template library.\n\n## Steps\n1. To add a template, create it with a name, subject, and HTML and text parts using placeholder variables.\n2. To review, get a template by name or list templates.\n3. To retire one, delete the template by name.\n4. Keep template names descriptive so they are easy to reference when sending.\n\n## Output\nReport the template name affected and the action taken, or the template contents for a fetch.', + '# Onboard Sending Domain\n\nVerify a new SES identity before sending from it.\n\n## Steps\n1. Create the email identity for the domain or address you want to send from.\n2. If DKIM tokens are returned, hand them to whoever manages DNS to add as CNAME records.\n3. Get the email identity periodically to check verification status and DKIM signing status until it reports success.\n4. Once verified, optionally create a configuration set to control tracking, delivery, and reputation options for emails sent from the identity.\n5. If the identity is no longer needed, delete the email identity.\n\n## Output\nReport the identity, its verification status, and DKIM status. If verification is pending, report the DNS records that still need to be added.', }, { name: 'check-sending-health', diff --git a/apps/sim/blocks/blocks/sts.ts b/apps/sim/blocks/blocks/sts.ts index 4ad25a9eb03..7fdbc9faa45 100644 --- a/apps/sim/blocks/blocks/sts.ts +++ b/apps/sim/blocks/blocks/sts.ts @@ -22,6 +22,8 @@ export const STSBlock: BlockConfig = { type: 'dropdown', options: [ { label: 'Assume Role', id: 'assume_role' }, + { label: 'Assume Role With Web Identity', id: 'assume_role_with_web_identity' }, + { label: 'Assume Role With SAML', id: 'assume_role_with_saml' }, { label: 'Get Caller Identity', id: 'get_caller_identity' }, { label: 'Get Session Token', id: 'get_session_token' }, { label: 'Get Access Key Info', id: 'get_access_key_info' }, @@ -41,7 +43,16 @@ export const STSBlock: BlockConfig = { type: 'short-input', placeholder: 'AKIA...', password: true, - required: true, + condition: { + field: 'operation', + value: ['assume_role_with_web_identity', 'assume_role_with_saml'], + not: true, + }, + required: { + field: 'operation', + value: ['assume_role_with_web_identity', 'assume_role_with_saml'], + not: true, + }, }, { id: 'secretAccessKey', @@ -49,30 +60,92 @@ export const STSBlock: BlockConfig = { type: 'short-input', placeholder: 'Your secret access key', password: true, - required: true, + condition: { + field: 'operation', + value: ['assume_role_with_web_identity', 'assume_role_with_saml'], + not: true, + }, + required: { + field: 'operation', + value: ['assume_role_with_web_identity', 'assume_role_with_saml'], + not: true, + }, }, { id: 'roleArn', title: 'Role ARN', type: 'short-input', placeholder: 'arn:aws:iam::123456789012:role/MyRole', - condition: { field: 'operation', value: 'assume_role' }, - required: { field: 'operation', value: 'assume_role' }, + condition: { + field: 'operation', + value: ['assume_role', 'assume_role_with_web_identity', 'assume_role_with_saml'], + }, + required: { + field: 'operation', + value: ['assume_role', 'assume_role_with_web_identity', 'assume_role_with_saml'], + }, }, { id: 'roleSessionName', title: 'Session Name', type: 'short-input', placeholder: 'my-session', - condition: { field: 'operation', value: 'assume_role' }, - required: { field: 'operation', value: 'assume_role' }, + condition: { + field: 'operation', + value: ['assume_role', 'assume_role_with_web_identity'], + }, + required: { + field: 'operation', + value: ['assume_role', 'assume_role_with_web_identity'], + }, + }, + { + id: 'webIdentityToken', + title: 'Web Identity Token', + type: 'long-input', + placeholder: 'OIDC/OAuth 2.0 token from the identity provider', + condition: { field: 'operation', value: 'assume_role_with_web_identity' }, + required: { field: 'operation', value: 'assume_role_with_web_identity' }, + }, + { + id: 'providerId', + title: 'Provider ID', + type: 'short-input', + placeholder: 'www.amazon.com (legacy OAuth 2.0 providers only)', + condition: { field: 'operation', value: 'assume_role_with_web_identity' }, + required: false, + mode: 'advanced', + }, + { + id: 'principalArn', + title: 'SAML Provider ARN', + type: 'short-input', + placeholder: 'arn:aws:iam::123456789012:saml-provider/MyProvider', + condition: { field: 'operation', value: 'assume_role_with_saml' }, + required: { field: 'operation', value: 'assume_role_with_saml' }, + }, + { + id: 'samlAssertion', + title: 'SAML Assertion', + type: 'long-input', + placeholder: 'Base64-encoded SAML authentication response', + condition: { field: 'operation', value: 'assume_role_with_saml' }, + required: { field: 'operation', value: 'assume_role_with_saml' }, }, { id: 'durationSeconds', title: 'Duration (Seconds)', type: 'short-input', placeholder: '3600', - condition: { field: 'operation', value: ['assume_role', 'get_session_token'] }, + condition: { + field: 'operation', + value: [ + 'assume_role', + 'assume_role_with_web_identity', + 'assume_role_with_saml', + 'get_session_token', + ], + }, required: false, mode: 'advanced', }, @@ -81,6 +154,39 @@ export const STSBlock: BlockConfig = { title: 'Session Policy (JSON)', type: 'long-input', placeholder: '{"Version":"2012-10-17","Statement":[...]}', + condition: { + field: 'operation', + value: ['assume_role', 'assume_role_with_web_identity', 'assume_role_with_saml'], + }, + required: false, + mode: 'advanced', + }, + { + id: 'policyArns', + title: 'Managed Policy ARNs', + type: 'short-input', + placeholder: 'arn:aws:iam::123456789012:policy/One, arn:aws:iam::123456789012:policy/Two', + condition: { + field: 'operation', + value: ['assume_role', 'assume_role_with_web_identity', 'assume_role_with_saml'], + }, + required: false, + mode: 'advanced', + }, + { + id: 'tags', + title: 'Session Tags', + type: 'table', + columns: ['key', 'value'], + condition: { field: 'operation', value: 'assume_role' }, + required: false, + mode: 'advanced', + }, + { + id: 'transitiveTagKeys', + title: 'Transitive Tag Keys', + type: 'short-input', + placeholder: 'Project, Cost-Center', condition: { field: 'operation', value: 'assume_role' }, required: false, mode: 'advanced', @@ -124,6 +230,8 @@ export const STSBlock: BlockConfig = { tools: { access: [ 'sts_assume_role', + 'sts_assume_role_with_web_identity', + 'sts_assume_role_with_saml', 'sts_get_caller_identity', 'sts_get_session_token', 'sts_get_access_key_info', @@ -133,6 +241,10 @@ export const STSBlock: BlockConfig = { switch (params.operation) { case 'assume_role': return 'sts_assume_role' + case 'assume_role_with_web_identity': + return 'sts_assume_role_with_web_identity' + case 'assume_role_with_saml': + return 'sts_assume_role_with_saml' case 'get_caller_identity': return 'sts_get_caller_identity' case 'get_session_token': @@ -146,16 +258,12 @@ export const STSBlock: BlockConfig = { params: (params) => { const { operation, durationSeconds, ...rest } = params - const connectionConfig = { - region: rest.region, - accessKeyId: rest.accessKeyId, - secretAccessKey: rest.secretAccessKey, - } - - const result: Record = { ...connectionConfig } + const result: Record = { region: rest.region } switch (operation) { case 'assume_role': + result.accessKeyId = rest.accessKeyId + result.secretAccessKey = rest.secretAccessKey result.roleArn = rest.roleArn result.roleSessionName = rest.roleSessionName if (durationSeconds) { @@ -166,10 +274,53 @@ export const STSBlock: BlockConfig = { if (rest.externalId) result.externalId = rest.externalId if (rest.serialNumber) result.serialNumber = rest.serialNumber if (rest.tokenCode) result.tokenCode = rest.tokenCode + if (rest.policyArns) result.policyArns = rest.policyArns + if (rest.transitiveTagKeys) result.transitiveTagKeys = rest.transitiveTagKeys + if (rest.tags) { + const rows = rest.tags + if (typeof rows === 'string') { + result.tags = rows + } else if (Array.isArray(rows)) { + const obj: Record = {} + for (const row of rows) { + const key = row.cells?.key + const value = row.cells?.value + if (key && value !== undefined) obj[key] = String(value) + } + if (Object.keys(obj).length > 0) result.tags = JSON.stringify(obj) + } + } + break + case 'assume_role_with_web_identity': + result.roleArn = rest.roleArn + result.roleSessionName = rest.roleSessionName + result.webIdentityToken = rest.webIdentityToken + if (rest.providerId) result.providerId = rest.providerId + if (durationSeconds) { + const parsed = Number.parseInt(String(durationSeconds), 10) + if (!Number.isNaN(parsed)) result.durationSeconds = parsed + } + if (rest.policy) result.policy = rest.policy + if (rest.policyArns) result.policyArns = rest.policyArns + break + case 'assume_role_with_saml': + result.roleArn = rest.roleArn + result.principalArn = rest.principalArn + result.samlAssertion = rest.samlAssertion + if (durationSeconds) { + const parsed = Number.parseInt(String(durationSeconds), 10) + if (!Number.isNaN(parsed)) result.durationSeconds = parsed + } + if (rest.policy) result.policy = rest.policy + if (rest.policyArns) result.policyArns = rest.policyArns break case 'get_caller_identity': + result.accessKeyId = rest.accessKeyId + result.secretAccessKey = rest.secretAccessKey break case 'get_session_token': + result.accessKeyId = rest.accessKeyId + result.secretAccessKey = rest.secretAccessKey if (durationSeconds) { const parsed = Number.parseInt(String(durationSeconds), 10) if (!Number.isNaN(parsed)) result.durationSeconds = parsed @@ -178,6 +329,8 @@ export const STSBlock: BlockConfig = { if (rest.tokenCode) result.tokenCode = rest.tokenCode break case 'get_access_key_info': + result.accessKeyId = rest.accessKeyId + result.secretAccessKey = rest.secretAccessKey result.targetAccessKeyId = rest.targetAccessKeyId break } @@ -193,8 +346,21 @@ export const STSBlock: BlockConfig = { secretAccessKey: { type: 'string', description: 'AWS secret access key' }, roleArn: { type: 'string', description: 'ARN of the role to assume' }, roleSessionName: { type: 'string', description: 'Session name for the assumed role' }, + webIdentityToken: { + type: 'string', + description: 'OIDC/OAuth 2.0 web identity token from the identity provider', + }, + providerId: { type: 'string', description: 'Legacy OAuth 2.0 provider host' }, + principalArn: { type: 'string', description: 'ARN of the SAML provider in IAM' }, + samlAssertion: { type: 'string', description: 'Base64-encoded SAML authentication response' }, durationSeconds: { type: 'string', description: 'Session duration in seconds' }, policy: { type: 'string', description: 'JSON IAM session policy to restrict permissions' }, + policyArns: { type: 'string', description: 'Comma-separated managed policy ARNs' }, + tags: { type: 'string', description: 'Session tags (Key/Value pairs) for ABAC' }, + transitiveTagKeys: { + type: 'string', + description: 'Comma-separated tag keys that propagate through role chaining', + }, externalId: { type: 'string', description: 'External ID for cross-account access' }, serialNumber: { type: 'string', description: 'MFA device serial number' }, tokenCode: { type: 'string', description: 'MFA token code' }, @@ -203,35 +369,75 @@ export const STSBlock: BlockConfig = { outputs: { accessKeyId: { type: 'string', - description: 'Temporary access key ID (assume_role, get_session_token)', + description: + 'Temporary access key ID (assume_role, assume_role_with_web_identity, assume_role_with_saml, get_session_token)', }, secretAccessKey: { type: 'string', - description: 'Temporary secret access key (assume_role, get_session_token)', + description: + 'Temporary secret access key (assume_role, assume_role_with_web_identity, assume_role_with_saml, get_session_token)', }, sessionToken: { type: 'string', - description: 'Temporary session token (assume_role, get_session_token)', + description: + 'Temporary session token (assume_role, assume_role_with_web_identity, assume_role_with_saml, get_session_token)', }, expiration: { type: 'string', - description: 'Credential expiration timestamp (assume_role, get_session_token)', + description: + 'Credential expiration timestamp (assume_role, assume_role_with_web_identity, assume_role_with_saml, get_session_token)', }, assumedRoleArn: { type: 'string', - description: 'ARN of the assumed role (assume_role only)', + description: + 'ARN of the assumed role (assume_role, assume_role_with_web_identity, assume_role_with_saml)', }, assumedRoleId: { type: 'string', - description: 'Assumed role ID with session name (assume_role only)', + description: + 'Assumed role ID with session name (assume_role, assume_role_with_web_identity, assume_role_with_saml)', }, packedPolicySize: { type: 'number', - description: 'Percentage of allowed policy size used (assume_role only)', + description: + 'Percentage of allowed policy size used (assume_role, assume_role_with_web_identity, assume_role_with_saml)', }, sourceIdentity: { type: 'string', - description: 'Source identity set on the role session (assume_role only)', + description: + 'Source identity set on the role session (assume_role, assume_role_with_web_identity, assume_role_with_saml)', + }, + subjectFromWebIdentityToken: { + type: 'string', + description: + 'Unique user identifier from the web identity token subject claim (assume_role_with_web_identity only)', + }, + audience: { + type: 'string', + description: + 'Intended audience of the token/assertion (assume_role_with_web_identity, assume_role_with_saml)', + }, + provider: { + type: 'string', + description: + 'Issuing authority of the web identity token (assume_role_with_web_identity only)', + }, + subject: { + type: 'string', + description: 'NameID from the SAML assertion subject (assume_role_with_saml only)', + }, + subjectType: { + type: 'string', + description: 'SAML NameID format (assume_role_with_saml only)', + }, + issuer: { + type: 'string', + description: 'Issuer element of the SAML assertion (assume_role_with_saml only)', + }, + nameQualifier: { + type: 'string', + description: + 'Hash uniquely identifying the issuer, account, and SAML provider (assume_role_with_saml only)', }, account: { type: 'string', @@ -319,6 +525,24 @@ export const STSBlockMeta = { tags: ['devops', 'monitoring'], alsoIntegrations: ['identity_center', 'slack'], }, + { + icon: STSIcon, + title: 'CI/CD OIDC credential broker', + prompt: + 'Build a workflow that receives an OIDC token from a CI/CD pipeline (e.g. GitHub Actions), calls AWS STS assume role with web identity to mint short-lived deployment credentials, and writes the issuance to an audit log.', + modules: ['agent', 'workflows'], + category: 'operations', + tags: ['devops', 'enterprise'], + }, + { + icon: STSIcon, + title: 'Enterprise SSO SAML role broker', + prompt: + 'Create a workflow that takes a SAML assertion from a corporate identity provider, calls AWS STS assume role with SAML to grant scoped temporary credentials, and logs the session details for compliance review.', + modules: ['agent', 'workflows'], + category: 'operations', + tags: ['legal', 'enterprise'], + }, ], skills: [ { @@ -349,5 +573,19 @@ export const STSBlockMeta = { content: '# Identify Access Key Owner\n\nFind out which account owns an access key ID.\n\n## Steps\n1. Take the access key ID under investigation (for example, one found in a leak or an unexpected log).\n2. Call get access key info to resolve the owning AWS account ID.\n3. Compare the account against your known and expected accounts.\n4. If it belongs to an unexpected account, escalate for investigation.\n\n## Output\nReport the access key ID (never the secret) and its owning account ID, plus whether that account is expected.', }, + { + name: 'assume-role-with-oidc-token', + description: + 'Use AWS STS assume role with web identity to exchange an OIDC/OAuth 2.0 token (e.g. from GitHub Actions, EKS IRSA, or Google/Facebook federation) for short-lived AWS credentials without any static IAM keys.', + content: + '# Assume Role with OIDC Token\n\nExchange a federated identity token for temporary AWS credentials.\n\n## Steps\n1. Obtain the OIDC/OAuth 2.0 token issued by the identity provider (CI/CD pipeline, Kubernetes service account, or social login).\n2. Identify the role ARN whose trust policy trusts that identity provider, and choose a descriptive session name.\n3. Call assume role with web identity, passing the token and any session policy to further restrict permissions.\n4. Pass the returned temporary credentials to the downstream step that needs AWS access.\n\n## Output\nConfirm the assumed role ARN, the token subject, and credential expiration. Never print the secret access key or session token in plain logs.', + }, + { + name: 'assume-role-with-saml-assertion', + description: + 'Use AWS STS assume role with SAML to exchange a SAML 2.0 assertion from an enterprise identity provider for short-lived AWS credentials, for enterprise SSO access patterns.', + content: + '# Assume Role with SAML Assertion\n\nExchange a SAML authentication response for temporary AWS credentials.\n\n## Steps\n1. Obtain the base64-encoded SAML assertion issued by the corporate identity provider after user sign-in.\n2. Identify the role ARN and the ARN of the SAML provider entity configured in IAM.\n3. Call assume role with SAML, passing the assertion and any session policy to further restrict permissions.\n4. Pass the returned temporary credentials to the downstream step that needs AWS access.\n\n## Output\nConfirm the assumed role ARN, the assertion subject, and credential expiration. Never print the secret access key or session token in plain logs.', + }, ], } as const satisfies BlockMeta diff --git a/apps/sim/lib/api/contracts/tools/aws/secrets-manager-describe-secret.ts b/apps/sim/lib/api/contracts/tools/aws/secrets-manager-describe-secret.ts new file mode 100644 index 00000000000..4fa4c964107 --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/secrets-manager-describe-secret.ts @@ -0,0 +1,65 @@ +import { z } from 'zod' +import type { + ContractBody, + ContractBodyInput, + ContractJsonResponse, +} from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' + +const DescribeSecretSchema = z.object({ + region: z.string().min(1, 'AWS region is required'), + accessKeyId: z.string().min(1, 'AWS access key ID is required'), + secretAccessKey: z.string().min(1, 'AWS secret access key is required'), + secretId: z.string().min(1, 'Secret ID is required'), +}) + +const RotationRulesResponseSchema = z.object({ + automaticallyAfterDays: z.number().nullable(), + duration: z.string().nullable(), + scheduleExpression: z.string().nullable(), +}) + +const ReplicationStatusResponseSchema = z.object({ + region: z.string(), + kmsKeyId: z.string().nullable(), + status: z.string().nullable(), + statusMessage: z.string().nullable(), + lastAccessedDate: z.string().nullable(), +}) + +const DescribeSecretResponseSchema = z.object({ + name: z.string(), + arn: z.string(), + description: z.string().nullable(), + kmsKeyId: z.string().nullable(), + rotationEnabled: z.boolean(), + rotationLambdaARN: z.string().nullable(), + rotationRules: RotationRulesResponseSchema.nullable(), + lastRotatedDate: z.string().nullable(), + lastChangedDate: z.string().nullable(), + lastAccessedDate: z.string().nullable(), + deletedDate: z.string().nullable(), + nextRotationDate: z.string().nullable(), + tags: z.array(z.object({ key: z.string(), value: z.string() })), + versionIdsToStages: z.record(z.string(), z.array(z.string())).nullable(), + owningService: z.string().nullable(), + createdDate: z.string().nullable(), + primaryRegion: z.string().nullable(), + replicationStatus: z.array(ReplicationStatusResponseSchema), +}) + +export const awsSecretsManagerDescribeSecretContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/secrets_manager/describe-secret', + body: DescribeSecretSchema, + response: { mode: 'json', schema: DescribeSecretResponseSchema }, +}) +export type AwsSecretsManagerDescribeSecretRequest = ContractBodyInput< + typeof awsSecretsManagerDescribeSecretContract +> +export type AwsSecretsManagerDescribeSecretBody = ContractBody< + typeof awsSecretsManagerDescribeSecretContract +> +export type AwsSecretsManagerDescribeSecretResponse = ContractJsonResponse< + typeof awsSecretsManagerDescribeSecretContract +> diff --git a/apps/sim/lib/api/contracts/tools/aws/secrets-manager-list-secrets.ts b/apps/sim/lib/api/contracts/tools/aws/secrets-manager-list-secrets.ts index c92e1b55258..198e751949a 100644 --- a/apps/sim/lib/api/contracts/tools/aws/secrets-manager-list-secrets.ts +++ b/apps/sim/lib/api/contracts/tools/aws/secrets-manager-list-secrets.ts @@ -14,6 +14,12 @@ const ListSecretsSchema = z.object({ nextToken: z.string().nullish(), }) +const RotationRulesResponseSchema = z.object({ + automaticallyAfterDays: z.number().nullable(), + duration: z.string().nullable(), + scheduleExpression: z.string().nullable(), +}) + const ListSecretsResponseSchema = z.object({ secrets: z.array( z.object({ @@ -25,6 +31,11 @@ const ListSecretsResponseSchema = z.object({ lastAccessedDate: z.string().nullable(), rotationEnabled: z.boolean(), tags: z.array(z.object({ key: z.string(), value: z.string() })), + rotationRules: RotationRulesResponseSchema.nullable(), + lastRotatedDate: z.string().nullable(), + nextRotationDate: z.string().nullable(), + deletedDate: z.string().nullable(), + secretVersionsToStages: z.record(z.string(), z.array(z.string())).nullable(), }) ), nextToken: z.string().nullable(), diff --git a/apps/sim/lib/api/contracts/tools/aws/secrets-manager-restore-secret.ts b/apps/sim/lib/api/contracts/tools/aws/secrets-manager-restore-secret.ts new file mode 100644 index 00000000000..68af536b83e --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/secrets-manager-restore-secret.ts @@ -0,0 +1,36 @@ +import { z } from 'zod' +import type { + ContractBody, + ContractBodyInput, + ContractJsonResponse, +} from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' + +const RestoreSecretSchema = z.object({ + region: z.string().min(1, 'AWS region is required'), + accessKeyId: z.string().min(1, 'AWS access key ID is required'), + secretAccessKey: z.string().min(1, 'AWS secret access key is required'), + secretId: z.string().min(1, 'Secret ID is required'), +}) + +const RestoreSecretResponseSchema = z.object({ + message: z.string(), + name: z.string(), + arn: z.string(), +}) + +export const awsSecretsManagerRestoreSecretContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/secrets_manager/restore-secret', + body: RestoreSecretSchema, + response: { mode: 'json', schema: RestoreSecretResponseSchema }, +}) +export type AwsSecretsManagerRestoreSecretRequest = ContractBodyInput< + typeof awsSecretsManagerRestoreSecretContract +> +export type AwsSecretsManagerRestoreSecretBody = ContractBody< + typeof awsSecretsManagerRestoreSecretContract +> +export type AwsSecretsManagerRestoreSecretResponse = ContractJsonResponse< + typeof awsSecretsManagerRestoreSecretContract +> diff --git a/apps/sim/lib/api/contracts/tools/aws/secrets-manager-rotate-secret.ts b/apps/sim/lib/api/contracts/tools/aws/secrets-manager-rotate-secret.ts new file mode 100644 index 00000000000..8e9313245d4 --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/secrets-manager-rotate-secret.ts @@ -0,0 +1,75 @@ +import { z } from 'zod' +import type { + ContractBody, + ContractBodyInput, + ContractJsonResponse, +} from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' + +const RotateSecretSchema = z + .object({ + region: z.string().min(1, 'AWS region is required'), + accessKeyId: z.string().min(1, 'AWS access key ID is required'), + secretAccessKey: z.string().min(1, 'AWS secret access key is required'), + secretId: z.string().min(1, 'Secret ID is required'), + clientRequestToken: z + .string() + .min(32, 'Client request token must be at least 32 characters') + .max(64, 'Client request token must be at most 64 characters') + .nullish(), + rotationLambdaARN: z.string().nullish(), + automaticallyAfterDays: z + .number() + .min(1, 'Rotation interval must be at least 1 day') + .max(1000, 'Rotation interval must be at most 1000 days') + .nullish(), + duration: z + .string() + .min(2, 'Duration must be 2-3 characters, e.g. "3h"') + .max(3, 'Duration must be 2-3 characters, e.g. "3h"') + .regex(/^[0-9]+h$/, 'Duration must match the pattern h, e.g. "3h"') + .nullish(), + scheduleExpression: z + .string() + .min(1, 'Schedule expression cannot be empty') + .max(256, 'Schedule expression must be at most 256 characters') + .regex( + /^[0-9A-Za-z()#?*\-/, ]+$/, + 'Schedule expression may only contain alphanumerics and ()#?*-/, characters' + ) + .nullish(), + rotateImmediately: z.boolean().nullish(), + }) + .superRefine((data, ctx) => { + if (data.scheduleExpression && data.automaticallyAfterDays) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + 'automaticallyAfterDays and scheduleExpression are mutually exclusive — AWS RotationRules accepts only one', + path: ['scheduleExpression'], + }) + } + }) + +const RotateSecretResponseSchema = z.object({ + message: z.string(), + name: z.string(), + arn: z.string(), + versionId: z.string(), +}) + +export const awsSecretsManagerRotateSecretContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/secrets_manager/rotate-secret', + body: RotateSecretSchema, + response: { mode: 'json', schema: RotateSecretResponseSchema }, +}) +export type AwsSecretsManagerRotateSecretRequest = ContractBodyInput< + typeof awsSecretsManagerRotateSecretContract +> +export type AwsSecretsManagerRotateSecretBody = ContractBody< + typeof awsSecretsManagerRotateSecretContract +> +export type AwsSecretsManagerRotateSecretResponse = ContractJsonResponse< + typeof awsSecretsManagerRotateSecretContract +> diff --git a/apps/sim/lib/api/contracts/tools/aws/secrets-manager-tag-resource.ts b/apps/sim/lib/api/contracts/tools/aws/secrets-manager-tag-resource.ts new file mode 100644 index 00000000000..66d1606a5d5 --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/secrets-manager-tag-resource.ts @@ -0,0 +1,47 @@ +import { z } from 'zod' +import type { + ContractBody, + ContractBodyInput, + ContractJsonResponse, +} from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' + +const TagResourceSchema = z.object({ + region: z.string().min(1, 'AWS region is required'), + accessKeyId: z.string().min(1, 'AWS access key ID is required'), + secretAccessKey: z.string().min(1, 'AWS secret access key is required'), + secretId: z.string().min(1, 'Secret ID is required'), + tags: z + .array( + z.object({ + key: z + .string() + .min(1, 'Tag key is required') + .max(128, 'Tag key must be at most 128 characters'), + value: z.string().max(256, 'Tag value must be at most 256 characters'), + }) + ) + .min(1, 'At least one tag is required') + .max(50, 'A maximum of 50 tags can be attached in a single request'), +}) + +const TagResourceResponseSchema = z.object({ + message: z.string(), + name: z.string(), +}) + +export const awsSecretsManagerTagResourceContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/secrets_manager/tag-resource', + body: TagResourceSchema, + response: { mode: 'json', schema: TagResourceResponseSchema }, +}) +export type AwsSecretsManagerTagResourceRequest = ContractBodyInput< + typeof awsSecretsManagerTagResourceContract +> +export type AwsSecretsManagerTagResourceBody = ContractBody< + typeof awsSecretsManagerTagResourceContract +> +export type AwsSecretsManagerTagResourceResponse = ContractJsonResponse< + typeof awsSecretsManagerTagResourceContract +> diff --git a/apps/sim/lib/api/contracts/tools/aws/secrets-manager-untag-resource.ts b/apps/sim/lib/api/contracts/tools/aws/secrets-manager-untag-resource.ts new file mode 100644 index 00000000000..9ec57057e0a --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/secrets-manager-untag-resource.ts @@ -0,0 +1,44 @@ +import { z } from 'zod' +import type { + ContractBody, + ContractBodyInput, + ContractJsonResponse, +} from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' + +const UntagResourceSchema = z.object({ + region: z.string().min(1, 'AWS region is required'), + accessKeyId: z.string().min(1, 'AWS access key ID is required'), + secretAccessKey: z.string().min(1, 'AWS secret access key is required'), + secretId: z.string().min(1, 'Secret ID is required'), + tagKeys: z + .array( + z + .string() + .min(1, 'Tag key cannot be empty') + .max(128, 'Tag key must be at most 128 characters') + ) + .min(1, 'At least one tag key is required') + .max(50, 'A maximum of 50 tag keys can be removed in a single request'), +}) + +const UntagResourceResponseSchema = z.object({ + message: z.string(), + name: z.string(), +}) + +export const awsSecretsManagerUntagResourceContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/secrets_manager/untag-resource', + body: UntagResourceSchema, + response: { mode: 'json', schema: UntagResourceResponseSchema }, +}) +export type AwsSecretsManagerUntagResourceRequest = ContractBodyInput< + typeof awsSecretsManagerUntagResourceContract +> +export type AwsSecretsManagerUntagResourceBody = ContractBody< + typeof awsSecretsManagerUntagResourceContract +> +export type AwsSecretsManagerUntagResourceResponse = ContractJsonResponse< + typeof awsSecretsManagerUntagResourceContract +> diff --git a/apps/sim/lib/api/contracts/tools/aws/ses-create-configuration-set.ts b/apps/sim/lib/api/contracts/tools/aws/ses-create-configuration-set.ts new file mode 100644 index 00000000000..7900265c160 --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/ses-create-configuration-set.ts @@ -0,0 +1,89 @@ +import { z } from 'zod' +import type { + ContractBody, + ContractBodyInput, + ContractJsonResponse, +} from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { validateAwsRegion } from '@/lib/core/security/input-validation' + +const CreateConfigurationSetSchema = z + .object({ + region: z + .string() + .min(1, 'AWS region is required') + .refine((v) => validateAwsRegion(v).isValid, { + message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', + }), + accessKeyId: z.string().min(1, 'AWS access key ID is required'), + secretAccessKey: z.string().min(1, 'AWS secret access key is required'), + configurationSetName: z + .string() + .min(1, 'Configuration set name is required') + .max(64, 'Configuration set name must be 64 characters or fewer') + .regex( + /^[a-zA-Z0-9_-]+$/, + 'Configuration set name may only contain letters, numbers, hyphens, and underscores' + ), + customRedirectDomain: z.string().nullish(), + httpsPolicy: z.enum(['REQUIRE', 'REQUIRE_OPEN_ONLY', 'OPTIONAL']).nullish(), + tlsPolicy: z.enum(['REQUIRE', 'OPTIONAL']).nullish(), + sendingPoolName: z.string().nullish(), + reputationMetricsEnabled: z.boolean().nullish(), + sendingEnabled: z.boolean().nullish(), + suppressedReasons: z + .string() + .nullish() + .refine( + (v) => { + if (!v) return true + return v + .split(',') + .map((r) => r.trim()) + .filter(Boolean) + .every((r) => r === 'BOUNCE' || r === 'COMPLAINT') + }, + { message: 'suppressedReasons must be a comma-separated list of BOUNCE, COMPLAINT' } + ), + tags: z + .array( + z.object({ + key: z + .string() + .min(1, 'Tag key is required') + .max(128, 'Tag key must be at most 128 characters'), + value: z.string().max(256, 'Tag value must be at most 256 characters'), + }) + ) + .nullish(), + }) + .superRefine((data, ctx) => { + if (data.httpsPolicy && !data.customRedirectDomain) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + 'customRedirectDomain is required when httpsPolicy is set (AWS TrackingOptions requires a redirect domain)', + path: ['customRedirectDomain'], + }) + } + }) + +const CreateConfigurationSetResponseSchema = z.object({ + message: z.string(), +}) + +export const awsSesCreateConfigurationSetContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/ses/create-configuration-set', + body: CreateConfigurationSetSchema, + response: { mode: 'json', schema: CreateConfigurationSetResponseSchema }, +}) +export type AwsSesCreateConfigurationSetRequest = ContractBodyInput< + typeof awsSesCreateConfigurationSetContract +> +export type AwsSesCreateConfigurationSetBody = ContractBody< + typeof awsSesCreateConfigurationSetContract +> +export type AwsSesCreateConfigurationSetResponse = ContractJsonResponse< + typeof awsSesCreateConfigurationSetContract +> diff --git a/apps/sim/lib/api/contracts/tools/aws/ses-create-email-identity.ts b/apps/sim/lib/api/contracts/tools/aws/ses-create-email-identity.ts new file mode 100644 index 00000000000..e30e508bd79 --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/ses-create-email-identity.ts @@ -0,0 +1,70 @@ +import { z } from 'zod' +import type { + ContractBody, + ContractBodyInput, + ContractJsonResponse, +} from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { validateAwsRegion } from '@/lib/core/security/input-validation' + +const CreateEmailIdentitySchema = z.object({ + region: z + .string() + .min(1, 'AWS region is required') + .refine((v) => validateAwsRegion(v).isValid, { + message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', + }), + accessKeyId: z.string().min(1, 'AWS access key ID is required'), + secretAccessKey: z.string().min(1, 'AWS secret access key is required'), + emailIdentity: z.string().min(1, 'Email identity (domain or address) is required'), + dkimSigningAttributes: z + .object({ + domainSigningSelector: z.string().optional(), + domainSigningPrivateKey: z.string().optional(), + nextSigningKeyLength: z.enum(['RSA_1024_BIT', 'RSA_2048_BIT']).optional(), + }) + .nullish(), + tags: z + .array( + z.object({ + key: z + .string() + .min(1, 'Tag key is required') + .max(128, 'Tag key must be at most 128 characters'), + value: z.string().max(256, 'Tag value must be at most 256 characters'), + }) + ) + .nullish(), + configurationSetName: z.string().nullish(), +}) + +const DkimAttributesSchema = z.object({ + signingEnabled: z.boolean().nullable(), + status: z.string().nullable(), + tokens: z.array(z.string()), + signingAttributesOrigin: z.string().nullable(), + nextSigningKeyLength: z.string().nullable(), + currentSigningKeyLength: z.string().nullable(), + lastKeyGenerationTimestamp: z.string().nullable(), + signingHostedZone: z.string().nullable(), +}) + +const CreateEmailIdentityResponseSchema = z.object({ + identityType: z.string(), + verifiedForSendingStatus: z.boolean(), + dkimAttributes: DkimAttributesSchema.nullable(), +}) + +export const awsSesCreateEmailIdentityContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/ses/create-email-identity', + body: CreateEmailIdentitySchema, + response: { mode: 'json', schema: CreateEmailIdentityResponseSchema }, +}) +export type AwsSesCreateEmailIdentityRequest = ContractBodyInput< + typeof awsSesCreateEmailIdentityContract +> +export type AwsSesCreateEmailIdentityBody = ContractBody +export type AwsSesCreateEmailIdentityResponse = ContractJsonResponse< + typeof awsSesCreateEmailIdentityContract +> diff --git a/apps/sim/lib/api/contracts/tools/aws/ses-delete-email-identity.ts b/apps/sim/lib/api/contracts/tools/aws/ses-delete-email-identity.ts new file mode 100644 index 00000000000..139dbf6415e --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/ses-delete-email-identity.ts @@ -0,0 +1,38 @@ +import { z } from 'zod' +import type { + ContractBody, + ContractBodyInput, + ContractJsonResponse, +} from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { validateAwsRegion } from '@/lib/core/security/input-validation' + +const DeleteEmailIdentitySchema = z.object({ + region: z + .string() + .min(1, 'AWS region is required') + .refine((v) => validateAwsRegion(v).isValid, { + message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', + }), + accessKeyId: z.string().min(1, 'AWS access key ID is required'), + secretAccessKey: z.string().min(1, 'AWS secret access key is required'), + emailIdentity: z.string().min(1, 'Email identity (domain or address) is required'), +}) + +const DeleteEmailIdentityResponseSchema = z.object({ + message: z.string(), +}) + +export const awsSesDeleteEmailIdentityContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/ses/delete-email-identity', + body: DeleteEmailIdentitySchema, + response: { mode: 'json', schema: DeleteEmailIdentityResponseSchema }, +}) +export type AwsSesDeleteEmailIdentityRequest = ContractBodyInput< + typeof awsSesDeleteEmailIdentityContract +> +export type AwsSesDeleteEmailIdentityBody = ContractBody +export type AwsSesDeleteEmailIdentityResponse = ContractJsonResponse< + typeof awsSesDeleteEmailIdentityContract +> diff --git a/apps/sim/lib/api/contracts/tools/aws/ses-delete-suppressed-destination.ts b/apps/sim/lib/api/contracts/tools/aws/ses-delete-suppressed-destination.ts new file mode 100644 index 00000000000..4702207987f --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/ses-delete-suppressed-destination.ts @@ -0,0 +1,40 @@ +import { z } from 'zod' +import type { + ContractBody, + ContractBodyInput, + ContractJsonResponse, +} from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { validateAwsRegion } from '@/lib/core/security/input-validation' + +const DeleteSuppressedDestinationSchema = z.object({ + region: z + .string() + .min(1, 'AWS region is required') + .refine((v) => validateAwsRegion(v).isValid, { + message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', + }), + accessKeyId: z.string().min(1, 'AWS access key ID is required'), + secretAccessKey: z.string().min(1, 'AWS secret access key is required'), + emailAddress: z.string().email('A valid email address is required'), +}) + +const DeleteSuppressedDestinationResponseSchema = z.object({ + message: z.string(), +}) + +export const awsSesDeleteSuppressedDestinationContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/ses/delete-suppressed-destination', + body: DeleteSuppressedDestinationSchema, + response: { mode: 'json', schema: DeleteSuppressedDestinationResponseSchema }, +}) +export type AwsSesDeleteSuppressedDestinationRequest = ContractBodyInput< + typeof awsSesDeleteSuppressedDestinationContract +> +export type AwsSesDeleteSuppressedDestinationBody = ContractBody< + typeof awsSesDeleteSuppressedDestinationContract +> +export type AwsSesDeleteSuppressedDestinationResponse = ContractJsonResponse< + typeof awsSesDeleteSuppressedDestinationContract +> diff --git a/apps/sim/lib/api/contracts/tools/aws/ses-get-email-identity.ts b/apps/sim/lib/api/contracts/tools/aws/ses-get-email-identity.ts new file mode 100644 index 00000000000..9702b359a7b --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/ses-get-email-identity.ts @@ -0,0 +1,68 @@ +import { z } from 'zod' +import type { + ContractBody, + ContractBodyInput, + ContractJsonResponse, +} from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { validateAwsRegion } from '@/lib/core/security/input-validation' + +const GetEmailIdentitySchema = z.object({ + region: z + .string() + .min(1, 'AWS region is required') + .refine((v) => validateAwsRegion(v).isValid, { + message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', + }), + accessKeyId: z.string().min(1, 'AWS access key ID is required'), + secretAccessKey: z.string().min(1, 'AWS secret access key is required'), + emailIdentity: z.string().min(1, 'Email identity (domain or address) is required'), +}) + +const DkimAttributesSchema = z.object({ + signingEnabled: z.boolean().nullable(), + status: z.string().nullable(), + tokens: z.array(z.string()), + signingAttributesOrigin: z.string().nullable(), + nextSigningKeyLength: z.string().nullable(), + currentSigningKeyLength: z.string().nullable(), + lastKeyGenerationTimestamp: z.string().nullable(), + signingHostedZone: z.string().nullable(), +}) + +const GetEmailIdentityResponseSchema = z.object({ + identityType: z.string(), + verifiedForSendingStatus: z.boolean(), + verificationStatus: z.string().nullable(), + feedbackForwardingStatus: z.boolean().nullable(), + configurationSetName: z.string().nullable(), + dkimAttributes: DkimAttributesSchema.nullable(), + mailFromAttributes: z + .object({ + mailFromDomain: z.string().nullable(), + mailFromDomainStatus: z.string().nullable(), + behaviorOnMxFailure: z.string().nullable(), + }) + .nullable(), + policies: z.record(z.string(), z.string()).nullable(), + tags: z.array(z.object({ key: z.string(), value: z.string() })), + verificationInfo: z + .object({ + errorType: z.string().nullable(), + lastCheckedTimestamp: z.string().nullable(), + lastSuccessTimestamp: z.string().nullable(), + }) + .nullable(), +}) + +export const awsSesGetEmailIdentityContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/ses/get-email-identity', + body: GetEmailIdentitySchema, + response: { mode: 'json', schema: GetEmailIdentityResponseSchema }, +}) +export type AwsSesGetEmailIdentityRequest = ContractBodyInput +export type AwsSesGetEmailIdentityBody = ContractBody +export type AwsSesGetEmailIdentityResponse = ContractJsonResponse< + typeof awsSesGetEmailIdentityContract +> diff --git a/apps/sim/lib/api/contracts/tools/aws/ses-get-suppressed-destination.ts b/apps/sim/lib/api/contracts/tools/aws/ses-get-suppressed-destination.ts new file mode 100644 index 00000000000..ce500ae604e --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/ses-get-suppressed-destination.ts @@ -0,0 +1,44 @@ +import { z } from 'zod' +import type { + ContractBody, + ContractBodyInput, + ContractJsonResponse, +} from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { validateAwsRegion } from '@/lib/core/security/input-validation' + +const GetSuppressedDestinationSchema = z.object({ + region: z + .string() + .min(1, 'AWS region is required') + .refine((v) => validateAwsRegion(v).isValid, { + message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', + }), + accessKeyId: z.string().min(1, 'AWS access key ID is required'), + secretAccessKey: z.string().min(1, 'AWS secret access key is required'), + emailAddress: z.string().email('A valid email address is required'), +}) + +const GetSuppressedDestinationResponseSchema = z.object({ + emailAddress: z.string(), + reason: z.string(), + lastUpdateTime: z.string().nullable(), + messageId: z.string().nullable(), + feedbackId: z.string().nullable(), +}) + +export const awsSesGetSuppressedDestinationContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/ses/get-suppressed-destination', + body: GetSuppressedDestinationSchema, + response: { mode: 'json', schema: GetSuppressedDestinationResponseSchema }, +}) +export type AwsSesGetSuppressedDestinationRequest = ContractBodyInput< + typeof awsSesGetSuppressedDestinationContract +> +export type AwsSesGetSuppressedDestinationBody = ContractBody< + typeof awsSesGetSuppressedDestinationContract +> +export type AwsSesGetSuppressedDestinationResponse = ContractJsonResponse< + typeof awsSesGetSuppressedDestinationContract +> diff --git a/apps/sim/lib/api/contracts/tools/aws/ses-list-suppressed-destinations.ts b/apps/sim/lib/api/contracts/tools/aws/ses-list-suppressed-destinations.ts new file mode 100644 index 00000000000..db8f90d40d6 --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/ses-list-suppressed-destinations.ts @@ -0,0 +1,52 @@ +import { z } from 'zod' +import type { + ContractBody, + ContractBodyInput, + ContractJsonResponse, +} from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { validateAwsRegion } from '@/lib/core/security/input-validation' + +const ListSuppressedDestinationsSchema = z.object({ + region: z + .string() + .min(1, 'AWS region is required') + .refine((v) => validateAwsRegion(v).isValid, { + message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', + }), + accessKeyId: z.string().min(1, 'AWS access key ID is required'), + secretAccessKey: z.string().min(1, 'AWS secret access key is required'), + reasons: z.string().nullish(), + startDate: z.string().nullish(), + endDate: z.string().nullish(), + pageSize: z.number().int().min(1).max(1000).nullish(), + nextToken: z.string().nullish(), +}) + +const ListSuppressedDestinationsResponseSchema = z.object({ + destinations: z.array( + z.object({ + emailAddress: z.string(), + reason: z.string(), + lastUpdateTime: z.string().nullable(), + }) + ), + nextToken: z.string().nullable(), + count: z.number(), +}) + +export const awsSesListSuppressedDestinationsContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/ses/list-suppressed-destinations', + body: ListSuppressedDestinationsSchema, + response: { mode: 'json', schema: ListSuppressedDestinationsResponseSchema }, +}) +export type AwsSesListSuppressedDestinationsRequest = ContractBodyInput< + typeof awsSesListSuppressedDestinationsContract +> +export type AwsSesListSuppressedDestinationsBody = ContractBody< + typeof awsSesListSuppressedDestinationsContract +> +export type AwsSesListSuppressedDestinationsResponse = ContractJsonResponse< + typeof awsSesListSuppressedDestinationsContract +> diff --git a/apps/sim/lib/api/contracts/tools/aws/ses-put-suppressed-destination.ts b/apps/sim/lib/api/contracts/tools/aws/ses-put-suppressed-destination.ts new file mode 100644 index 00000000000..7f8a71f04f1 --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/ses-put-suppressed-destination.ts @@ -0,0 +1,43 @@ +import { z } from 'zod' +import type { + ContractBody, + ContractBodyInput, + ContractJsonResponse, +} from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { validateAwsRegion } from '@/lib/core/security/input-validation' + +const PutSuppressedDestinationSchema = z.object({ + region: z + .string() + .min(1, 'AWS region is required') + .refine((v) => validateAwsRegion(v).isValid, { + message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', + }), + accessKeyId: z.string().min(1, 'AWS access key ID is required'), + secretAccessKey: z.string().min(1, 'AWS secret access key is required'), + emailAddress: z.string().email('A valid email address is required'), + reason: z.enum(['BOUNCE', 'COMPLAINT'], { + message: 'Reason must be BOUNCE or COMPLAINT', + }), +}) + +const PutSuppressedDestinationResponseSchema = z.object({ + message: z.string(), +}) + +export const awsSesPutSuppressedDestinationContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/ses/put-suppressed-destination', + body: PutSuppressedDestinationSchema, + response: { mode: 'json', schema: PutSuppressedDestinationResponseSchema }, +}) +export type AwsSesPutSuppressedDestinationRequest = ContractBodyInput< + typeof awsSesPutSuppressedDestinationContract +> +export type AwsSesPutSuppressedDestinationBody = ContractBody< + typeof awsSesPutSuppressedDestinationContract +> +export type AwsSesPutSuppressedDestinationResponse = ContractJsonResponse< + typeof awsSesPutSuppressedDestinationContract +> diff --git a/apps/sim/lib/api/contracts/tools/aws/ses-send-custom-verification-email.ts b/apps/sim/lib/api/contracts/tools/aws/ses-send-custom-verification-email.ts new file mode 100644 index 00000000000..8bfe523a8bf --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/ses-send-custom-verification-email.ts @@ -0,0 +1,42 @@ +import { z } from 'zod' +import type { + ContractBody, + ContractBodyInput, + ContractJsonResponse, +} from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { validateAwsRegion } from '@/lib/core/security/input-validation' + +const SendCustomVerificationEmailSchema = z.object({ + region: z + .string() + .min(1, 'AWS region is required') + .refine((v) => validateAwsRegion(v).isValid, { + message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', + }), + accessKeyId: z.string().min(1, 'AWS access key ID is required'), + secretAccessKey: z.string().min(1, 'AWS secret access key is required'), + emailAddress: z.string().email('A valid email address is required'), + templateName: z.string().min(1, 'Custom verification template name is required'), + configurationSetName: z.string().nullish(), +}) + +const SendCustomVerificationEmailResponseSchema = z.object({ + messageId: z.string(), +}) + +export const awsSesSendCustomVerificationEmailContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/ses/send-custom-verification-email', + body: SendCustomVerificationEmailSchema, + response: { mode: 'json', schema: SendCustomVerificationEmailResponseSchema }, +}) +export type AwsSesSendCustomVerificationEmailRequest = ContractBodyInput< + typeof awsSesSendCustomVerificationEmailContract +> +export type AwsSesSendCustomVerificationEmailBody = ContractBody< + typeof awsSesSendCustomVerificationEmailContract +> +export type AwsSesSendCustomVerificationEmailResponse = ContractJsonResponse< + typeof awsSesSendCustomVerificationEmailContract +> diff --git a/apps/sim/lib/api/contracts/tools/aws/ses-update-template.ts b/apps/sim/lib/api/contracts/tools/aws/ses-update-template.ts new file mode 100644 index 00000000000..57534e4d668 --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/ses-update-template.ts @@ -0,0 +1,37 @@ +import { z } from 'zod' +import type { + ContractBody, + ContractBodyInput, + ContractJsonResponse, +} from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { validateAwsRegion } from '@/lib/core/security/input-validation' + +const UpdateTemplateSchema = z.object({ + region: z + .string() + .min(1, 'AWS region is required') + .refine((v) => validateAwsRegion(v).isValid, { + message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', + }), + accessKeyId: z.string().min(1, 'AWS access key ID is required'), + secretAccessKey: z.string().min(1, 'AWS secret access key is required'), + templateName: z.string().min(1, 'Template name is required'), + subjectPart: z.string().min(1, 'Template subject is required'), + textPart: z.string().nullish(), + htmlPart: z.string().nullish(), +}) + +const UpdateTemplateResponseSchema = z.object({ + message: z.string(), +}) + +export const awsSesUpdateTemplateContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/ses/update-template', + body: UpdateTemplateSchema, + response: { mode: 'json', schema: UpdateTemplateResponseSchema }, +}) +export type AwsSesUpdateTemplateRequest = ContractBodyInput +export type AwsSesUpdateTemplateBody = ContractBody +export type AwsSesUpdateTemplateResponse = ContractJsonResponse diff --git a/apps/sim/lib/api/contracts/tools/aws/sts-assume-role-with-saml.ts b/apps/sim/lib/api/contracts/tools/aws/sts-assume-role-with-saml.ts new file mode 100644 index 00000000000..fdf7d9ff984 --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/sts-assume-role-with-saml.ts @@ -0,0 +1,61 @@ +import { z } from 'zod' +import type { + ContractBody, + ContractBodyInput, + ContractJsonResponse, +} from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { validateAwsRegion } from '@/lib/core/security/input-validation' + +const AssumeRoleWithSAMLSchema = z.object({ + region: z + .string() + .min(1, 'AWS region is required') + .refine((v) => validateAwsRegion(v).isValid, { + message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', + }), + roleArn: z.string().min(20, 'Role ARN is required').max(2048), + principalArn: z.string().min(20, 'SAML provider ARN is required').max(2048), + samlAssertion: z + .string() + .min(4, 'SAML assertion is required') + .max(100000, 'SAML assertion must not exceed 100000 characters'), + policy: z.string().max(2048).nullish(), + policyArns: z + .string() + .nullish() + .refine((v) => !v || v.split(',').filter((arn) => arn.trim().length > 0).length <= 10, { + message: 'A maximum of 10 policy ARNs can be provided', + }), + durationSeconds: z.number().int().min(900).max(43200).nullish(), +}) + +const AssumeRoleWithSAMLResponseSchema = z.object({ + accessKeyId: z.string(), + secretAccessKey: z.string(), + sessionToken: z.string(), + expiration: z.string().nullable(), + assumedRoleArn: z.string(), + assumedRoleId: z.string(), + subject: z.string().nullable(), + subjectType: z.string().nullable(), + issuer: z.string().nullable(), + audience: z.string().nullable(), + nameQualifier: z.string().nullable(), + packedPolicySize: z.number().nullable(), + sourceIdentity: z.string().nullable(), +}) + +export const awsStsAssumeRoleWithSAMLContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/sts/assume-role-with-saml', + body: AssumeRoleWithSAMLSchema, + response: { mode: 'json', schema: AssumeRoleWithSAMLResponseSchema }, +}) +export type AwsStsAssumeRoleWithSAMLRequest = ContractBodyInput< + typeof awsStsAssumeRoleWithSAMLContract +> +export type AwsStsAssumeRoleWithSAMLBody = ContractBody +export type AwsStsAssumeRoleWithSAMLResponse = ContractJsonResponse< + typeof awsStsAssumeRoleWithSAMLContract +> diff --git a/apps/sim/lib/api/contracts/tools/aws/sts-assume-role-with-web-identity.ts b/apps/sim/lib/api/contracts/tools/aws/sts-assume-role-with-web-identity.ts new file mode 100644 index 00000000000..9a08299b9ce --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/aws/sts-assume-role-with-web-identity.ts @@ -0,0 +1,62 @@ +import { z } from 'zod' +import type { + ContractBody, + ContractBodyInput, + ContractJsonResponse, +} from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { validateAwsRegion } from '@/lib/core/security/input-validation' + +const AssumeRoleWithWebIdentitySchema = z.object({ + region: z + .string() + .min(1, 'AWS region is required') + .refine((v) => validateAwsRegion(v).isValid, { + message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', + }), + roleArn: z.string().min(20, 'Role ARN is required').max(2048), + roleSessionName: z.string().min(2, 'Role session name is required').max(64), + webIdentityToken: z + .string() + .min(4, 'Web identity token is required') + .max(20000, 'Web identity token must not exceed 20000 characters'), + providerId: z.string().min(4).max(2048).nullish(), + policy: z.string().max(2048).nullish(), + policyArns: z + .string() + .nullish() + .refine((v) => !v || v.split(',').filter((arn) => arn.trim().length > 0).length <= 10, { + message: 'A maximum of 10 policy ARNs can be provided', + }), + durationSeconds: z.number().int().min(900).max(43200).nullish(), +}) + +const AssumeRoleWithWebIdentityResponseSchema = z.object({ + accessKeyId: z.string(), + secretAccessKey: z.string(), + sessionToken: z.string(), + expiration: z.string().nullable(), + assumedRoleArn: z.string(), + assumedRoleId: z.string(), + subjectFromWebIdentityToken: z.string(), + audience: z.string().nullable(), + provider: z.string().nullable(), + packedPolicySize: z.number().nullable(), + sourceIdentity: z.string().nullable(), +}) + +export const awsStsAssumeRoleWithWebIdentityContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/sts/assume-role-with-web-identity', + body: AssumeRoleWithWebIdentitySchema, + response: { mode: 'json', schema: AssumeRoleWithWebIdentityResponseSchema }, +}) +export type AwsStsAssumeRoleWithWebIdentityRequest = ContractBodyInput< + typeof awsStsAssumeRoleWithWebIdentityContract +> +export type AwsStsAssumeRoleWithWebIdentityBody = ContractBody< + typeof awsStsAssumeRoleWithWebIdentityContract +> +export type AwsStsAssumeRoleWithWebIdentityResponse = ContractJsonResponse< + typeof awsStsAssumeRoleWithWebIdentityContract +> diff --git a/apps/sim/lib/api/contracts/tools/aws/sts-assume-role.ts b/apps/sim/lib/api/contracts/tools/aws/sts-assume-role.ts index 71cd4843114..2366f94e357 100644 --- a/apps/sim/lib/api/contracts/tools/aws/sts-assume-role.ts +++ b/apps/sim/lib/api/contracts/tools/aws/sts-assume-role.ts @@ -20,9 +20,36 @@ const AssumeRoleSchema = z.object({ roleSessionName: z.string().min(1, 'Role session name is required'), durationSeconds: z.number().int().min(900).max(43200).nullish(), policy: z.string().max(2048).nullish(), - externalId: z.string().nullish(), + externalId: z.string().min(2).max(1224).nullish(), serialNumber: z.string().nullish(), tokenCode: z.string().nullish(), + policyArns: z + .string() + .nullish() + .refine((v) => !v || v.split(',').filter((arn) => arn.trim().length > 0).length <= 10, { + message: 'A maximum of 10 policy ARNs can be provided', + }), + tags: z + .string() + .nullish() + .refine( + (v) => { + if (!v) return true + try { + const parsed = JSON.parse(v) + return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) + } catch { + return false + } + }, + { message: 'tags must be a valid JSON object string' } + ), + transitiveTagKeys: z + .string() + .nullish() + .refine((v) => !v || v.split(',').filter((key) => key.trim().length > 0).length <= 50, { + message: 'A maximum of 50 transitive tag keys can be provided', + }), }) const AssumeRoleResponseSchema = z.object({ diff --git a/apps/sim/lib/integrations/integrations.json b/apps/sim/lib/integrations/integrations.json index fcfc2ba81ff..4357b52a503 100644 --- a/apps/sim/lib/integrations/integrations.json +++ b/apps/sim/lib/integrations/integrations.json @@ -1870,7 +1870,7 @@ "slug": "aws-secrets-manager", "name": "AWS Secrets Manager", "description": "Connect to AWS Secrets Manager", - "longDescription": "Integrate AWS Secrets Manager into the workflow. Can retrieve, create, update, list, and delete secrets.", + "longDescription": "Integrate AWS Secrets Manager into the workflow. Can retrieve, create, update, list, delete, describe, tag, untag, restore, and rotate secrets.", "bgColor": "linear-gradient(45deg, #BD0816 0%, #FF5252 100%)", "iconName": "SecretsManagerIcon", "docsUrl": "https://docs.sim.ai/integrations/secrets_manager", @@ -1894,9 +1894,29 @@ { "name": "Delete Secret", "description": "Delete a secret from AWS Secrets Manager" + }, + { + "name": "Describe Secret", + "description": "Retrieve full metadata for a secret in AWS Secrets Manager, including rotation configuration and replication status, without exposing the secret value" + }, + { + "name": "Tag Secret", + "description": "Attach tags to a secret in AWS Secrets Manager" + }, + { + "name": "Untag Secret", + "description": "Remove tags from a secret in AWS Secrets Manager" + }, + { + "name": "Restore Secret", + "description": "Cancel a scheduled deletion for a secret in AWS Secrets Manager, restoring access to it" + }, + { + "name": "Rotate Secret", + "description": "Start or reconfigure rotation for a secret in AWS Secrets Manager" } ], - "operationCount": 5, + "operationCount": 10, "triggers": [], "triggerCount": 0, "authType": "none", @@ -1909,7 +1929,7 @@ "slug": "aws-ses", "name": "AWS SES", "description": "Send emails and manage templates with AWS Simple Email Service", - "longDescription": "Integrate AWS SES v2 into the workflow. Send simple, templated, and bulk emails. Manage email templates and retrieve account sending quota and verified identity information.", + "longDescription": "Integrate AWS SES v2 into the workflow. Send simple, templated, and bulk emails. Manage email templates, identities, configuration sets, and the account suppression list, and retrieve account sending quota and verified identity information.", "bgColor": "linear-gradient(45deg, #BD0816 0%, #FF5252 100%)", "iconName": "SESIcon", "docsUrl": "https://docs.sim.ai/integrations/ses", @@ -1949,9 +1969,49 @@ { "name": "Delete Template", "description": "Delete an existing SES email template" + }, + { + "name": "Update Template", + "description": "Update the subject, HTML, and text content of an existing SES email template" + }, + { + "name": "Send Custom Verification Email", + "description": "Send a branded custom verification email to an address using a custom verification email template" + }, + { + "name": "Create Email Identity", + "description": "Start verification of a new SES email address or domain identity" + }, + { + "name": "Get Email Identity", + "description": "Retrieve verification status, DKIM, Mail-From, and policy details for an SES identity" + }, + { + "name": "Delete Email Identity", + "description": "Delete a verified SES email address or domain identity" + }, + { + "name": "Put Suppressed Destination", + "description": "Add an email address to the account-level SES suppression list" + }, + { + "name": "Get Suppressed Destination", + "description": "Retrieve details for a specific email address on the SES suppression list" + }, + { + "name": "List Suppressed Destinations", + "description": "List email addresses on the account-level SES suppression list" + }, + { + "name": "Delete Suppressed Destination", + "description": "Remove an email address from the account-level SES suppression list" + }, + { + "name": "Create Configuration Set", + "description": "Create an SES configuration set to control tracking, delivery, reputation, sending, and suppression behavior for emails" } ], - "operationCount": 9, + "operationCount": 19, "triggers": [], "triggerCount": 0, "authType": "api-key", @@ -1973,6 +2033,14 @@ "name": "Assume Role", "description": "Assume an IAM role and receive temporary security credentials" }, + { + "name": "Assume Role With Web Identity", + "description": "Assume an IAM role using an OIDC/OAuth 2.0 web identity token (e.g. GitHub Actions OIDC, EKS IRSA, Google/Facebook federation) and receive temporary security credentials" + }, + { + "name": "Assume Role With SAML", + "description": "Assume an IAM role using a SAML 2.0 authentication response from an enterprise identity provider and receive temporary security credentials" + }, { "name": "Get Caller Identity", "description": "Get details about the IAM user or role whose credentials are used to call the API" @@ -1986,7 +2054,7 @@ "description": "Get the AWS account ID associated with an access key" } ], - "operationCount": 4, + "operationCount": 6, "triggers": [], "triggerCount": 0, "authType": "api-key", diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index 2efa3158828..20ac92282f3 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -3166,8 +3166,13 @@ import { searchTool } from '@/tools/search' import { secretsManagerCreateSecretTool, secretsManagerDeleteSecretTool, + secretsManagerDescribeSecretTool, secretsManagerGetSecretTool, secretsManagerListSecretsTool, + secretsManagerRestoreSecretTool, + secretsManagerRotateSecretTool, + secretsManagerTagResourceTool, + secretsManagerUntagResourceTool, secretsManagerUpdateSecretTool, } from '@/tools/secrets_manager' import { @@ -3222,15 +3227,25 @@ import { servicenowUploadAttachmentTool, } from '@/tools/servicenow' import { + sesCreateConfigurationSetTool, + sesCreateEmailIdentityTool, sesCreateTemplateTool, + sesDeleteEmailIdentityTool, + sesDeleteSuppressedDestinationTool, sesDeleteTemplateTool, sesGetAccountTool, + sesGetEmailIdentityTool, + sesGetSuppressedDestinationTool, sesGetTemplateTool, sesListIdentitiesTool, + sesListSuppressedDestinationsTool, sesListTemplatesTool, + sesPutSuppressedDestinationTool, sesSendBulkEmailTool, + sesSendCustomVerificationEmailTool, sesSendEmailTool, sesSendTemplatedEmailTool, + sesUpdateTemplateTool, } from '@/tools/ses' import { sftpDeleteTool, @@ -3756,6 +3771,8 @@ import { } from '@/tools/stripe' import { stsAssumeRoleTool, + stsAssumeRoleWithSAMLTool, + stsAssumeRoleWithWebIdentityTool, stsGetAccessKeyInfoTool, stsGetCallerIdentityTool, stsGetSessionTokenTool, @@ -7084,11 +7101,16 @@ export const tools: Record = { s3_delete_bucket: s3DeleteBucketTool, s3_presigned_url: s3PresignedUrlTool, s3_delete_objects: s3DeleteObjectsTool, + secrets_manager_create_secret: secretsManagerCreateSecretTool, + secrets_manager_delete_secret: secretsManagerDeleteSecretTool, + secrets_manager_describe_secret: secretsManagerDescribeSecretTool, secrets_manager_get_secret: secretsManagerGetSecretTool, secrets_manager_list_secrets: secretsManagerListSecretsTool, - secrets_manager_create_secret: secretsManagerCreateSecretTool, + secrets_manager_restore_secret: secretsManagerRestoreSecretTool, + secrets_manager_rotate_secret: secretsManagerRotateSecretTool, + secrets_manager_tag_resource: secretsManagerTagResourceTool, + secrets_manager_untag_resource: secretsManagerUntagResourceTool, secrets_manager_update_secret: secretsManagerUpdateSecretTool, - secrets_manager_delete_secret: secretsManagerDeleteSecretTool, ses_send_email: sesSendEmailTool, ses_send_templated_email: sesSendTemplatedEmailTool, ses_send_bulk_email: sesSendBulkEmailTool, @@ -7098,6 +7120,16 @@ export const tools: Record = { ses_get_template: sesGetTemplateTool, ses_list_templates: sesListTemplatesTool, ses_delete_template: sesDeleteTemplateTool, + ses_update_template: sesUpdateTemplateTool, + ses_put_suppressed_destination: sesPutSuppressedDestinationTool, + ses_delete_suppressed_destination: sesDeleteSuppressedDestinationTool, + ses_get_suppressed_destination: sesGetSuppressedDestinationTool, + ses_list_suppressed_destinations: sesListSuppressedDestinationsTool, + ses_create_email_identity: sesCreateEmailIdentityTool, + ses_delete_email_identity: sesDeleteEmailIdentityTool, + ses_get_email_identity: sesGetEmailIdentityTool, + ses_create_configuration_set: sesCreateConfigurationSetTool, + ses_send_custom_verification_email: sesSendCustomVerificationEmailTool, telegram_message: telegramMessageTool, telegram_delete_message: telegramDeleteMessageTool, telegram_send_audio: telegramSendAudioTool, @@ -8062,6 +8094,8 @@ export const tools: Record = { sap_s4hana_update_supplier: sapS4HanaUpdateSupplierTool, sqs_send: sqsSendTool, sts_assume_role: stsAssumeRoleTool, + sts_assume_role_with_web_identity: stsAssumeRoleWithWebIdentityTool, + sts_assume_role_with_saml: stsAssumeRoleWithSAMLTool, sts_get_caller_identity: stsGetCallerIdentityTool, sts_get_session_token: stsGetSessionTokenTool, sts_get_access_key_info: stsGetAccessKeyInfoTool, diff --git a/apps/sim/tools/secrets_manager/create_secret.ts b/apps/sim/tools/secrets_manager/create_secret.ts index 331ee728588..0dd898c409f 100644 --- a/apps/sim/tools/secrets_manager/create_secret.ts +++ b/apps/sim/tools/secrets_manager/create_secret.ts @@ -11,7 +11,7 @@ export const createSecretTool: ToolConfig< id: 'secrets_manager_create_secret', name: 'Secrets Manager Create Secret', description: 'Create a new secret in AWS Secrets Manager', - version: '1.0', + version: '1.0.0', params: { region: { diff --git a/apps/sim/tools/secrets_manager/delete_secret.ts b/apps/sim/tools/secrets_manager/delete_secret.ts index afea77a2080..c9a72f99ca2 100644 --- a/apps/sim/tools/secrets_manager/delete_secret.ts +++ b/apps/sim/tools/secrets_manager/delete_secret.ts @@ -11,7 +11,7 @@ export const deleteSecretTool: ToolConfig< id: 'secrets_manager_delete_secret', name: 'Secrets Manager Delete Secret', description: 'Delete a secret from AWS Secrets Manager', - version: '1.0', + version: '1.0.0', params: { region: { diff --git a/apps/sim/tools/secrets_manager/describe_secret.ts b/apps/sim/tools/secrets_manager/describe_secret.ts new file mode 100644 index 00000000000..dd8018e4cda --- /dev/null +++ b/apps/sim/tools/secrets_manager/describe_secret.ts @@ -0,0 +1,152 @@ +import type { + SecretsManagerDescribeSecretParams, + SecretsManagerDescribeSecretResponse, +} from '@/tools/secrets_manager/types' +import type { ToolConfig } from '@/tools/types' + +export const describeSecretTool: ToolConfig< + SecretsManagerDescribeSecretParams, + SecretsManagerDescribeSecretResponse +> = { + id: 'secrets_manager_describe_secret', + name: 'Secrets Manager Describe Secret', + description: + 'Retrieve full metadata for a secret in AWS Secrets Manager, including rotation configuration and replication status, without exposing the secret value', + version: '1.0.0', + + params: { + region: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS region (e.g., us-east-1)', + }, + accessKeyId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS access key ID', + }, + secretAccessKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS secret access key', + }, + secretId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The name or ARN of the secret to describe', + }, + }, + + request: { + url: '/api/tools/secrets_manager/describe-secret', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + region: params.region, + accessKeyId: params.accessKeyId, + secretAccessKey: params.secretAccessKey, + secretId: params.secretId, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'Failed to describe secret') + } + + return { + success: true, + output: { + name: data.name ?? '', + arn: data.arn ?? '', + description: data.description ?? null, + kmsKeyId: data.kmsKeyId ?? null, + rotationEnabled: data.rotationEnabled ?? false, + rotationLambdaARN: data.rotationLambdaARN ?? null, + rotationRules: data.rotationRules ?? null, + lastRotatedDate: data.lastRotatedDate ?? null, + lastChangedDate: data.lastChangedDate ?? null, + lastAccessedDate: data.lastAccessedDate ?? null, + deletedDate: data.deletedDate ?? null, + nextRotationDate: data.nextRotationDate ?? null, + tags: data.tags ?? [], + versionIdsToStages: data.versionIdsToStages ?? null, + owningService: data.owningService ?? null, + createdDate: data.createdDate ?? null, + primaryRegion: data.primaryRegion ?? null, + replicationStatus: data.replicationStatus ?? [], + }, + error: undefined, + } + }, + + outputs: { + name: { type: 'string', description: 'Name of the secret' }, + arn: { type: 'string', description: 'ARN of the secret' }, + description: { type: 'string', description: 'Description of the secret', optional: true }, + kmsKeyId: { + type: 'string', + description: 'KMS key ID used to encrypt the secret', + optional: true, + }, + rotationEnabled: { type: 'boolean', description: 'Whether automatic rotation is enabled' }, + rotationLambdaARN: { + type: 'string', + description: 'ARN of the Lambda function used for rotation', + optional: true, + }, + rotationRules: { + type: 'json', + description: 'Rotation schedule configuration', + optional: true, + }, + lastRotatedDate: { + type: 'string', + description: 'Date the secret was last rotated', + optional: true, + }, + lastChangedDate: { + type: 'string', + description: 'Date the secret was last changed', + optional: true, + }, + lastAccessedDate: { + type: 'string', + description: 'Date the secret was last accessed', + optional: true, + }, + deletedDate: { type: 'string', description: 'Scheduled deletion date', optional: true }, + nextRotationDate: { + type: 'string', + description: 'Date the secret is next scheduled to rotate', + optional: true, + }, + tags: { type: 'array', description: 'Tags attached to the secret' }, + versionIdsToStages: { + type: 'json', + description: 'Map of version IDs to their staging labels', + optional: true, + }, + owningService: { + type: 'string', + description: 'ID of the AWS service that manages this secret, if any', + optional: true, + }, + createdDate: { type: 'string', description: 'Date the secret was created', optional: true }, + primaryRegion: { + type: 'string', + description: 'The primary region of the secret, if replicated', + optional: true, + }, + replicationStatus: { + type: 'array', + description: 'Replication status for each region the secret is replicated to', + }, + }, +} diff --git a/apps/sim/tools/secrets_manager/get_secret.ts b/apps/sim/tools/secrets_manager/get_secret.ts index 10fb58c3c9d..60d2c052eec 100644 --- a/apps/sim/tools/secrets_manager/get_secret.ts +++ b/apps/sim/tools/secrets_manager/get_secret.ts @@ -11,7 +11,7 @@ export const getSecretTool: ToolConfig< id: 'secrets_manager_get_secret', name: 'Secrets Manager Get Secret', description: 'Retrieve a secret value from AWS Secrets Manager', - version: '1.0', + version: '1.0.0', params: { region: { diff --git a/apps/sim/tools/secrets_manager/index.ts b/apps/sim/tools/secrets_manager/index.ts index 7bc9beae221..dad93f272cb 100644 --- a/apps/sim/tools/secrets_manager/index.ts +++ b/apps/sim/tools/secrets_manager/index.ts @@ -1,7 +1,12 @@ import { createSecretTool } from './create_secret' import { deleteSecretTool } from './delete_secret' +import { describeSecretTool } from './describe_secret' import { getSecretTool } from './get_secret' import { listSecretsTool } from './list_secrets' +import { restoreSecretTool } from './restore_secret' +import { rotateSecretTool } from './rotate_secret' +import { tagResourceTool } from './tag_resource' +import { untagResourceTool } from './untag_resource' import { updateSecretTool } from './update_secret' export const secretsManagerGetSecretTool = getSecretTool @@ -9,3 +14,8 @@ export const secretsManagerListSecretsTool = listSecretsTool export const secretsManagerCreateSecretTool = createSecretTool export const secretsManagerUpdateSecretTool = updateSecretTool export const secretsManagerDeleteSecretTool = deleteSecretTool +export const secretsManagerDescribeSecretTool = describeSecretTool +export const secretsManagerTagResourceTool = tagResourceTool +export const secretsManagerUntagResourceTool = untagResourceTool +export const secretsManagerRestoreSecretTool = restoreSecretTool +export const secretsManagerRotateSecretTool = rotateSecretTool diff --git a/apps/sim/tools/secrets_manager/list_secrets.ts b/apps/sim/tools/secrets_manager/list_secrets.ts index d5e41f229eb..ab28209a870 100644 --- a/apps/sim/tools/secrets_manager/list_secrets.ts +++ b/apps/sim/tools/secrets_manager/list_secrets.ts @@ -11,7 +11,7 @@ export const listSecretsTool: ToolConfig< id: 'secrets_manager_list_secrets', name: 'Secrets Manager List Secrets', description: 'List secrets stored in AWS Secrets Manager', - version: '1.0', + version: '1.0.0', params: { region: { @@ -80,7 +80,8 @@ export const listSecretsTool: ToolConfig< outputs: { secrets: { type: 'json', - description: 'List of secrets with name, ARN, description, and dates', + description: + 'List of secrets with name, ARN, description, dates, rotation rules/window, and version-to-stage mappings', }, nextToken: { type: 'string', diff --git a/apps/sim/tools/secrets_manager/restore_secret.ts b/apps/sim/tools/secrets_manager/restore_secret.ts new file mode 100644 index 00000000000..5397b86f7f6 --- /dev/null +++ b/apps/sim/tools/secrets_manager/restore_secret.ts @@ -0,0 +1,79 @@ +import type { + SecretsManagerRestoreSecretParams, + SecretsManagerRestoreSecretResponse, +} from '@/tools/secrets_manager/types' +import type { ToolConfig } from '@/tools/types' + +export const restoreSecretTool: ToolConfig< + SecretsManagerRestoreSecretParams, + SecretsManagerRestoreSecretResponse +> = { + id: 'secrets_manager_restore_secret', + name: 'Secrets Manager Restore Secret', + description: + 'Cancel a scheduled deletion for a secret in AWS Secrets Manager, restoring access to it', + version: '1.0.0', + + params: { + region: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS region (e.g., us-east-1)', + }, + accessKeyId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS access key ID', + }, + secretAccessKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS secret access key', + }, + secretId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The name or ARN of the secret to restore', + }, + }, + + request: { + url: '/api/tools/secrets_manager/restore-secret', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + region: params.region, + accessKeyId: params.accessKeyId, + secretAccessKey: params.secretAccessKey, + secretId: params.secretId, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'Failed to restore secret') + } + + return { + success: true, + output: { + message: data.message || 'Secret restored successfully', + name: data.name ?? '', + arn: data.arn ?? '', + }, + error: undefined, + } + }, + + outputs: { + message: { type: 'string', description: 'Operation status message' }, + name: { type: 'string', description: 'Name of the restored secret' }, + arn: { type: 'string', description: 'ARN of the restored secret' }, + }, +} diff --git a/apps/sim/tools/secrets_manager/rotate_secret.ts b/apps/sim/tools/secrets_manager/rotate_secret.ts new file mode 100644 index 00000000000..d5167d25918 --- /dev/null +++ b/apps/sim/tools/secrets_manager/rotate_secret.ts @@ -0,0 +1,124 @@ +import type { + SecretsManagerRotateSecretParams, + SecretsManagerRotateSecretResponse, +} from '@/tools/secrets_manager/types' +import type { ToolConfig } from '@/tools/types' + +export const rotateSecretTool: ToolConfig< + SecretsManagerRotateSecretParams, + SecretsManagerRotateSecretResponse +> = { + id: 'secrets_manager_rotate_secret', + name: 'Secrets Manager Rotate Secret', + description: 'Start or reconfigure rotation for a secret in AWS Secrets Manager', + version: '1.0.0', + + params: { + region: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS region (e.g., us-east-1)', + }, + accessKeyId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS access key ID', + }, + secretAccessKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS secret access key', + }, + secretId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The name or ARN of the secret to rotate', + }, + clientRequestToken: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Idempotency token for the new secret version (32-64 characters)', + }, + rotationLambdaARN: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'ARN of the Lambda function that performs rotation (omit for managed rotation)', + }, + automaticallyAfterDays: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: + 'Number of days between rotations (1-1000). Mutually exclusive with schedule expression', + }, + duration: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Length of the rotation window in hours, e.g. "3h"', + }, + scheduleExpression: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'A cron() or rate() expression defining the rotation schedule', + }, + rotateImmediately: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: + 'Whether to rotate immediately (default true) or wait for the next scheduled window', + }, + }, + + request: { + url: '/api/tools/secrets_manager/rotate-secret', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + region: params.region, + accessKeyId: params.accessKeyId, + secretAccessKey: params.secretAccessKey, + secretId: params.secretId, + clientRequestToken: params.clientRequestToken, + rotationLambdaARN: params.rotationLambdaARN, + automaticallyAfterDays: params.automaticallyAfterDays, + duration: params.duration, + scheduleExpression: params.scheduleExpression, + rotateImmediately: params.rotateImmediately, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'Failed to rotate secret') + } + + return { + success: true, + output: { + message: data.message || 'Rotation started successfully', + name: data.name ?? '', + arn: data.arn ?? '', + versionId: data.versionId ?? '', + }, + error: undefined, + } + }, + + outputs: { + message: { type: 'string', description: 'Operation status message' }, + name: { type: 'string', description: 'Name of the secret' }, + arn: { type: 'string', description: 'ARN of the secret' }, + versionId: { type: 'string', description: 'ID of the new secret version created by rotation' }, + }, +} diff --git a/apps/sim/tools/secrets_manager/tag_resource.ts b/apps/sim/tools/secrets_manager/tag_resource.ts new file mode 100644 index 00000000000..d5e17cf30c4 --- /dev/null +++ b/apps/sim/tools/secrets_manager/tag_resource.ts @@ -0,0 +1,83 @@ +import type { + SecretsManagerTagResourceParams, + SecretsManagerTagResourceResponse, +} from '@/tools/secrets_manager/types' +import type { ToolConfig } from '@/tools/types' + +export const tagResourceTool: ToolConfig< + SecretsManagerTagResourceParams, + SecretsManagerTagResourceResponse +> = { + id: 'secrets_manager_tag_resource', + name: 'Secrets Manager Tag Resource', + description: 'Attach tags to a secret in AWS Secrets Manager', + version: '1.0.0', + + params: { + region: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS region (e.g., us-east-1)', + }, + accessKeyId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS access key ID', + }, + secretAccessKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS secret access key', + }, + secretId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The name or ARN of the secret to tag', + }, + tags: { + type: 'json', + required: true, + visibility: 'user-or-llm', + description: 'Tags to attach, as an array of {key, value} pairs (max 50)', + }, + }, + + request: { + url: '/api/tools/secrets_manager/tag-resource', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + region: params.region, + accessKeyId: params.accessKeyId, + secretAccessKey: params.secretAccessKey, + secretId: params.secretId, + tags: params.tags, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'Failed to tag secret') + } + + return { + success: true, + output: { + message: data.message || 'Secret tagged successfully', + name: data.name ?? '', + }, + error: undefined, + } + }, + + outputs: { + message: { type: 'string', description: 'Operation status message' }, + name: { type: 'string', description: 'Name or ARN of the tagged secret' }, + }, +} diff --git a/apps/sim/tools/secrets_manager/types.ts b/apps/sim/tools/secrets_manager/types.ts index 48c40e6d0f2..9b3475dfa7a 100644 --- a/apps/sim/tools/secrets_manager/types.ts +++ b/apps/sim/tools/secrets_manager/types.ts @@ -52,6 +52,12 @@ export interface SecretsManagerGetSecretResponse extends ToolResponse { error?: string } +export interface SecretsManagerRotationRules { + automaticallyAfterDays: number | null + duration: string | null + scheduleExpression: string | null +} + export interface SecretsManagerListSecretsResponse extends ToolResponse { output: { secrets: Array<{ @@ -63,6 +69,11 @@ export interface SecretsManagerListSecretsResponse extends ToolResponse { lastAccessedDate: string | null rotationEnabled: boolean tags: Array<{ key: string; value: string }> + rotationRules: SecretsManagerRotationRules | null + lastRotatedDate: string | null + nextRotationDate: string | null + deletedDate: string | null + secretVersionsToStages: Record | null }> nextToken: string | null count: number @@ -99,3 +110,98 @@ export interface SecretsManagerDeleteSecretResponse extends ToolResponse { } error?: string } + +export interface SecretsManagerDescribeSecretParams extends SecretsManagerConnectionConfig { + secretId: string +} + +export interface SecretsManagerReplicationStatus { + region: string + kmsKeyId: string | null + status: string | null + statusMessage: string | null + lastAccessedDate: string | null +} + +export interface SecretsManagerDescribeSecretResponse extends ToolResponse { + output: { + name: string + arn: string + description: string | null + kmsKeyId: string | null + rotationEnabled: boolean + rotationLambdaARN: string | null + rotationRules: SecretsManagerRotationRules | null + lastRotatedDate: string | null + lastChangedDate: string | null + lastAccessedDate: string | null + deletedDate: string | null + nextRotationDate: string | null + tags: Array<{ key: string; value: string }> + versionIdsToStages: Record | null + owningService: string | null + createdDate: string | null + primaryRegion: string | null + replicationStatus: SecretsManagerReplicationStatus[] + } + error?: string +} + +export interface SecretsManagerTagResourceParams extends SecretsManagerConnectionConfig { + secretId: string + tags: Array<{ key: string; value: string }> +} + +export interface SecretsManagerTagResourceResponse extends ToolResponse { + output: { + message: string + name: string + } + error?: string +} + +export interface SecretsManagerUntagResourceParams extends SecretsManagerConnectionConfig { + secretId: string + tagKeys: string[] +} + +export interface SecretsManagerUntagResourceResponse extends ToolResponse { + output: { + message: string + name: string + } + error?: string +} + +export interface SecretsManagerRestoreSecretParams extends SecretsManagerConnectionConfig { + secretId: string +} + +export interface SecretsManagerRestoreSecretResponse extends ToolResponse { + output: { + message: string + name: string + arn: string + } + error?: string +} + +export interface SecretsManagerRotateSecretParams extends SecretsManagerConnectionConfig { + secretId: string + clientRequestToken?: string | null + rotationLambdaARN?: string | null + automaticallyAfterDays?: number | null + duration?: string | null + scheduleExpression?: string | null + rotateImmediately?: boolean | null +} + +export interface SecretsManagerRotateSecretResponse extends ToolResponse { + output: { + message: string + name: string + arn: string + versionId: string + } + error?: string +} diff --git a/apps/sim/tools/secrets_manager/untag_resource.ts b/apps/sim/tools/secrets_manager/untag_resource.ts new file mode 100644 index 00000000000..22e77205233 --- /dev/null +++ b/apps/sim/tools/secrets_manager/untag_resource.ts @@ -0,0 +1,83 @@ +import type { + SecretsManagerUntagResourceParams, + SecretsManagerUntagResourceResponse, +} from '@/tools/secrets_manager/types' +import type { ToolConfig } from '@/tools/types' + +export const untagResourceTool: ToolConfig< + SecretsManagerUntagResourceParams, + SecretsManagerUntagResourceResponse +> = { + id: 'secrets_manager_untag_resource', + name: 'Secrets Manager Untag Resource', + description: 'Remove tags from a secret in AWS Secrets Manager', + version: '1.0.0', + + params: { + region: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS region (e.g., us-east-1)', + }, + accessKeyId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS access key ID', + }, + secretAccessKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS secret access key', + }, + secretId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The name or ARN of the secret to untag', + }, + tagKeys: { + type: 'json', + required: true, + visibility: 'user-or-llm', + description: 'Tag keys to remove, as an array of strings (max 50)', + }, + }, + + request: { + url: '/api/tools/secrets_manager/untag-resource', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + region: params.region, + accessKeyId: params.accessKeyId, + secretAccessKey: params.secretAccessKey, + secretId: params.secretId, + tagKeys: params.tagKeys, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'Failed to untag secret') + } + + return { + success: true, + output: { + message: data.message || 'Secret untagged successfully', + name: data.name ?? '', + }, + error: undefined, + } + }, + + outputs: { + message: { type: 'string', description: 'Operation status message' }, + name: { type: 'string', description: 'Name or ARN of the untagged secret' }, + }, +} diff --git a/apps/sim/tools/secrets_manager/update_secret.ts b/apps/sim/tools/secrets_manager/update_secret.ts index a5a59dce681..02ec27a70df 100644 --- a/apps/sim/tools/secrets_manager/update_secret.ts +++ b/apps/sim/tools/secrets_manager/update_secret.ts @@ -11,7 +11,7 @@ export const updateSecretTool: ToolConfig< id: 'secrets_manager_update_secret', name: 'Secrets Manager Update Secret', description: 'Update the value of an existing secret in AWS Secrets Manager', - version: '1.0', + version: '1.0.0', params: { region: { diff --git a/apps/sim/tools/ses/create_configuration_set.ts b/apps/sim/tools/ses/create_configuration_set.ts new file mode 100644 index 00000000000..c5e42aa56f3 --- /dev/null +++ b/apps/sim/tools/ses/create_configuration_set.ts @@ -0,0 +1,131 @@ +import type { + SESCreateConfigurationSetParams, + SESCreateConfigurationSetResponse, +} from '@/tools/ses/types' +import type { ToolConfig } from '@/tools/types' + +export const createConfigurationSetTool: ToolConfig< + SESCreateConfigurationSetParams, + SESCreateConfigurationSetResponse +> = { + id: 'ses_create_configuration_set', + name: 'SES Create Configuration Set', + description: + 'Create an SES configuration set to control tracking, delivery, reputation, sending, and suppression behavior for emails', + version: '1.0.0', + + params: { + region: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS region (e.g., us-east-1)', + }, + accessKeyId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS access key ID', + }, + secretAccessKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS secret access key', + }, + configurationSetName: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Name of the configuration set (letters, numbers, hyphens, underscores)', + }, + customRedirectDomain: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Custom domain to use for open/click tracking links', + }, + httpsPolicy: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'HTTPS policy for tracking links: REQUIRE, REQUIRE_OPEN_ONLY, or OPTIONAL', + }, + tlsPolicy: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Whether delivery requires TLS: REQUIRE or OPTIONAL', + }, + sendingPoolName: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Dedicated IP pool to associate with the configuration set', + }, + reputationMetricsEnabled: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether to collect reputation metrics for emails using this configuration set', + }, + sendingEnabled: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether sending is enabled for this configuration set', + }, + suppressedReasons: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Comma-separated reasons that trigger suppression: BOUNCE, COMPLAINT', + }, + tags: { + type: 'json', + required: false, + visibility: 'user-or-llm', + description: + 'JSON array of tags to associate with the configuration set: [{"key":"","value":""}]', + }, + }, + + request: { + url: '/api/tools/ses/create-configuration-set', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + region: params.region, + accessKeyId: params.accessKeyId, + secretAccessKey: params.secretAccessKey, + configurationSetName: params.configurationSetName, + customRedirectDomain: params.customRedirectDomain, + httpsPolicy: params.httpsPolicy, + tlsPolicy: params.tlsPolicy, + sendingPoolName: params.sendingPoolName, + reputationMetricsEnabled: params.reputationMetricsEnabled, + sendingEnabled: params.sendingEnabled, + suppressedReasons: params.suppressedReasons, + tags: params.tags, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'Failed to create configuration set') + } + + return { + success: true, + output: { + message: data.message ?? '', + }, + } + }, + + outputs: { + message: { type: 'string', description: 'Confirmation message' }, + }, +} diff --git a/apps/sim/tools/ses/create_email_identity.ts b/apps/sim/tools/ses/create_email_identity.ts new file mode 100644 index 00000000000..df44525bc11 --- /dev/null +++ b/apps/sim/tools/ses/create_email_identity.ts @@ -0,0 +1,106 @@ +import type { + SESCreateEmailIdentityParams, + SESCreateEmailIdentityResponse, +} from '@/tools/ses/types' +import type { ToolConfig } from '@/tools/types' + +export const createEmailIdentityTool: ToolConfig< + SESCreateEmailIdentityParams, + SESCreateEmailIdentityResponse +> = { + id: 'ses_create_email_identity', + name: 'SES Create Email Identity', + description: 'Start verification of a new SES email address or domain identity', + version: '1.0.0', + + params: { + region: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS region (e.g., us-east-1)', + }, + accessKeyId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS access key ID', + }, + secretAccessKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS secret access key', + }, + emailIdentity: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The email address or domain to verify', + }, + dkimSigningAttributes: { + type: 'json', + required: false, + visibility: 'user-or-llm', + description: + 'Bring-your-own-DKIM signing attributes as JSON (domainSigningSelector, domainSigningPrivateKey, nextSigningKeyLength). Domain identities only.', + }, + tags: { + type: 'json', + required: false, + visibility: 'user-or-llm', + description: 'JSON array of tags to associate with the identity: [{"key":"","value":""}]', + }, + configurationSetName: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Default configuration set to use when sending from this identity', + }, + }, + + request: { + url: '/api/tools/ses/create-email-identity', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + region: params.region, + accessKeyId: params.accessKeyId, + secretAccessKey: params.secretAccessKey, + emailIdentity: params.emailIdentity, + dkimSigningAttributes: params.dkimSigningAttributes, + tags: params.tags, + configurationSetName: params.configurationSetName, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'Failed to create email identity') + } + + return { + success: true, + output: { + identityType: data.identityType ?? '', + verifiedForSendingStatus: data.verifiedForSendingStatus ?? false, + dkimAttributes: data.dkimAttributes ?? null, + }, + } + }, + + outputs: { + identityType: { type: 'string', description: 'The identity type: EMAIL_ADDRESS or DOMAIN' }, + verifiedForSendingStatus: { + type: 'boolean', + description: 'Whether the identity is verified and can send email', + }, + dkimAttributes: { + type: 'json', + description: 'DKIM signing status and CNAME tokens for the identity', + optional: true, + }, + }, +} diff --git a/apps/sim/tools/ses/delete_email_identity.ts b/apps/sim/tools/ses/delete_email_identity.ts new file mode 100644 index 00000000000..270f4cb9bb6 --- /dev/null +++ b/apps/sim/tools/ses/delete_email_identity.ts @@ -0,0 +1,73 @@ +import type { + SESDeleteEmailIdentityParams, + SESDeleteEmailIdentityResponse, +} from '@/tools/ses/types' +import type { ToolConfig } from '@/tools/types' + +export const deleteEmailIdentityTool: ToolConfig< + SESDeleteEmailIdentityParams, + SESDeleteEmailIdentityResponse +> = { + id: 'ses_delete_email_identity', + name: 'SES Delete Email Identity', + description: 'Delete a verified SES email address or domain identity', + version: '1.0.0', + + params: { + region: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS region (e.g., us-east-1)', + }, + accessKeyId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS access key ID', + }, + secretAccessKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS secret access key', + }, + emailIdentity: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The email address or domain identity to delete', + }, + }, + + request: { + url: '/api/tools/ses/delete-email-identity', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + region: params.region, + accessKeyId: params.accessKeyId, + secretAccessKey: params.secretAccessKey, + emailIdentity: params.emailIdentity, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'Failed to delete email identity') + } + + return { + success: true, + output: { + message: data.message ?? '', + }, + } + }, + + outputs: { + message: { type: 'string', description: 'Confirmation message' }, + }, +} diff --git a/apps/sim/tools/ses/delete_suppressed_destination.ts b/apps/sim/tools/ses/delete_suppressed_destination.ts new file mode 100644 index 00000000000..1f7cf217531 --- /dev/null +++ b/apps/sim/tools/ses/delete_suppressed_destination.ts @@ -0,0 +1,73 @@ +import type { + SESDeleteSuppressedDestinationParams, + SESDeleteSuppressedDestinationResponse, +} from '@/tools/ses/types' +import type { ToolConfig } from '@/tools/types' + +export const deleteSuppressedDestinationTool: ToolConfig< + SESDeleteSuppressedDestinationParams, + SESDeleteSuppressedDestinationResponse +> = { + id: 'ses_delete_suppressed_destination', + name: 'SES Delete Suppressed Destination', + description: 'Remove an email address from the account-level SES suppression list', + version: '1.0.0', + + params: { + region: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS region (e.g., us-east-1)', + }, + accessKeyId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS access key ID', + }, + secretAccessKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS secret access key', + }, + emailAddress: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The email address to remove from the suppression list', + }, + }, + + request: { + url: '/api/tools/ses/delete-suppressed-destination', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + region: params.region, + accessKeyId: params.accessKeyId, + secretAccessKey: params.secretAccessKey, + emailAddress: params.emailAddress, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'Failed to remove suppressed destination') + } + + return { + success: true, + output: { + message: data.message ?? '', + }, + } + }, + + outputs: { + message: { type: 'string', description: 'Confirmation message' }, + }, +} diff --git a/apps/sim/tools/ses/get_email_identity.ts b/apps/sim/tools/ses/get_email_identity.ts new file mode 100644 index 00000000000..04d37751044 --- /dev/null +++ b/apps/sim/tools/ses/get_email_identity.ts @@ -0,0 +1,120 @@ +import type { SESGetEmailIdentityParams, SESGetEmailIdentityResponse } from '@/tools/ses/types' +import type { ToolConfig } from '@/tools/types' + +export const getEmailIdentityTool: ToolConfig< + SESGetEmailIdentityParams, + SESGetEmailIdentityResponse +> = { + id: 'ses_get_email_identity', + name: 'SES Get Email Identity', + description: + 'Retrieve verification status, DKIM, Mail-From, and policy details for an SES identity', + version: '1.0.0', + + params: { + region: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS region (e.g., us-east-1)', + }, + accessKeyId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS access key ID', + }, + secretAccessKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS secret access key', + }, + emailIdentity: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The email address or domain identity to look up', + }, + }, + + request: { + url: '/api/tools/ses/get-email-identity', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + region: params.region, + accessKeyId: params.accessKeyId, + secretAccessKey: params.secretAccessKey, + emailIdentity: params.emailIdentity, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'Failed to get email identity') + } + + return { + success: true, + output: { + identityType: data.identityType ?? '', + verifiedForSendingStatus: data.verifiedForSendingStatus ?? false, + verificationStatus: data.verificationStatus ?? null, + feedbackForwardingStatus: data.feedbackForwardingStatus ?? null, + configurationSetName: data.configurationSetName ?? null, + dkimAttributes: data.dkimAttributes ?? null, + mailFromAttributes: data.mailFromAttributes ?? null, + policies: data.policies ?? null, + tags: data.tags ?? [], + verificationInfo: data.verificationInfo ?? null, + }, + } + }, + + outputs: { + identityType: { type: 'string', description: 'The identity type: EMAIL_ADDRESS or DOMAIN' }, + verifiedForSendingStatus: { + type: 'boolean', + description: 'Whether the identity is verified and can send email', + }, + verificationStatus: { + type: 'string', + description: 'Verification status: PENDING, SUCCESS, FAILED, TEMPORARY_FAILURE, NOT_STARTED', + optional: true, + }, + feedbackForwardingStatus: { + type: 'boolean', + description: 'Whether bounce/complaint notifications are forwarded by email', + optional: true, + }, + configurationSetName: { + type: 'string', + description: 'Default configuration set for this identity', + optional: true, + }, + dkimAttributes: { + type: 'json', + description: 'DKIM signing status and CNAME tokens for the identity', + optional: true, + }, + mailFromAttributes: { + type: 'json', + description: 'Custom MAIL FROM domain configuration for the identity', + optional: true, + }, + policies: { + type: 'json', + description: 'Sending authorization policies attached to the identity', + optional: true, + }, + tags: { type: 'array', description: 'Tags associated with the identity' }, + verificationInfo: { + type: 'json', + description: 'Additional verification diagnostics (error type, last checked/success time)', + optional: true, + }, + }, +} diff --git a/apps/sim/tools/ses/get_suppressed_destination.ts b/apps/sim/tools/ses/get_suppressed_destination.ts new file mode 100644 index 00000000000..3cd9e1f67f8 --- /dev/null +++ b/apps/sim/tools/ses/get_suppressed_destination.ts @@ -0,0 +1,93 @@ +import type { + SESGetSuppressedDestinationParams, + SESGetSuppressedDestinationResponse, +} from '@/tools/ses/types' +import type { ToolConfig } from '@/tools/types' + +export const getSuppressedDestinationTool: ToolConfig< + SESGetSuppressedDestinationParams, + SESGetSuppressedDestinationResponse +> = { + id: 'ses_get_suppressed_destination', + name: 'SES Get Suppressed Destination', + description: 'Retrieve details for a specific email address on the SES suppression list', + version: '1.0.0', + + params: { + region: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS region (e.g., us-east-1)', + }, + accessKeyId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS access key ID', + }, + secretAccessKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS secret access key', + }, + emailAddress: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The suppressed email address to look up', + }, + }, + + request: { + url: '/api/tools/ses/get-suppressed-destination', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + region: params.region, + accessKeyId: params.accessKeyId, + secretAccessKey: params.secretAccessKey, + emailAddress: params.emailAddress, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'Failed to get suppressed destination') + } + + return { + success: true, + output: { + emailAddress: data.emailAddress ?? '', + reason: data.reason ?? '', + lastUpdateTime: data.lastUpdateTime ?? null, + messageId: data.messageId ?? null, + feedbackId: data.feedbackId ?? null, + }, + } + }, + + outputs: { + emailAddress: { type: 'string', description: 'The suppressed email address' }, + reason: { type: 'string', description: 'The reason the address is suppressed' }, + lastUpdateTime: { + type: 'string', + description: 'When the address was added to the suppression list', + optional: true, + }, + messageId: { + type: 'string', + description: 'The message ID associated with the bounce or complaint event', + optional: true, + }, + feedbackId: { + type: 'string', + description: 'The feedback ID associated with the bounce or complaint event', + optional: true, + }, + }, +} diff --git a/apps/sim/tools/ses/index.ts b/apps/sim/tools/ses/index.ts index be2aa6ccd98..b30522ee690 100644 --- a/apps/sim/tools/ses/index.ts +++ b/apps/sim/tools/ses/index.ts @@ -1,12 +1,22 @@ +import { createConfigurationSetTool } from './create_configuration_set' +import { createEmailIdentityTool } from './create_email_identity' import { createTemplateTool } from './create_template' +import { deleteEmailIdentityTool } from './delete_email_identity' +import { deleteSuppressedDestinationTool } from './delete_suppressed_destination' import { deleteTemplateTool } from './delete_template' import { getAccountTool } from './get_account' +import { getEmailIdentityTool } from './get_email_identity' +import { getSuppressedDestinationTool } from './get_suppressed_destination' import { getTemplateTool } from './get_template' import { listIdentitiesTool } from './list_identities' +import { listSuppressedDestinationsTool } from './list_suppressed_destinations' import { listTemplatesTool } from './list_templates' +import { putSuppressedDestinationTool } from './put_suppressed_destination' import { sendBulkEmailTool } from './send_bulk_email' +import { sendCustomVerificationEmailTool } from './send_custom_verification_email' import { sendEmailTool } from './send_email' import { sendTemplatedEmailTool } from './send_templated_email' +import { updateTemplateTool } from './update_template' export const sesSendEmailTool = sendEmailTool export const sesSendTemplatedEmailTool = sendTemplatedEmailTool @@ -17,5 +27,15 @@ export const sesCreateTemplateTool = createTemplateTool export const sesGetTemplateTool = getTemplateTool export const sesListTemplatesTool = listTemplatesTool export const sesDeleteTemplateTool = deleteTemplateTool +export const sesUpdateTemplateTool = updateTemplateTool +export const sesPutSuppressedDestinationTool = putSuppressedDestinationTool +export const sesDeleteSuppressedDestinationTool = deleteSuppressedDestinationTool +export const sesGetSuppressedDestinationTool = getSuppressedDestinationTool +export const sesListSuppressedDestinationsTool = listSuppressedDestinationsTool +export const sesCreateEmailIdentityTool = createEmailIdentityTool +export const sesDeleteEmailIdentityTool = deleteEmailIdentityTool +export const sesGetEmailIdentityTool = getEmailIdentityTool +export const sesCreateConfigurationSetTool = createConfigurationSetTool +export const sesSendCustomVerificationEmailTool = sendCustomVerificationEmailTool export * from './types' diff --git a/apps/sim/tools/ses/list_suppressed_destinations.ts b/apps/sim/tools/ses/list_suppressed_destinations.ts new file mode 100644 index 00000000000..58ab851f10d --- /dev/null +++ b/apps/sim/tools/ses/list_suppressed_destinations.ts @@ -0,0 +1,112 @@ +import type { + SESListSuppressedDestinationsParams, + SESListSuppressedDestinationsResponse, +} from '@/tools/ses/types' +import type { ToolConfig } from '@/tools/types' + +export const listSuppressedDestinationsTool: ToolConfig< + SESListSuppressedDestinationsParams, + SESListSuppressedDestinationsResponse +> = { + id: 'ses_list_suppressed_destinations', + name: 'SES List Suppressed Destinations', + description: 'List email addresses on the account-level SES suppression list', + version: '1.0.0', + + params: { + region: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS region (e.g., us-east-1)', + }, + accessKeyId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS access key ID', + }, + secretAccessKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS secret access key', + }, + reasons: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Comma-separated suppression reasons to filter by: BOUNCE, COMPLAINT', + }, + startDate: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Only include addresses suppressed after this ISO 8601 date', + }, + endDate: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Only include addresses suppressed before this ISO 8601 date', + }, + pageSize: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Maximum number of results to return', + }, + nextToken: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Pagination token from a previous list response', + }, + }, + + request: { + url: '/api/tools/ses/list-suppressed-destinations', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + region: params.region, + accessKeyId: params.accessKeyId, + secretAccessKey: params.secretAccessKey, + reasons: params.reasons, + startDate: params.startDate, + endDate: params.endDate, + pageSize: params.pageSize, + nextToken: params.nextToken, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'Failed to list suppressed destinations') + } + + return { + success: true, + output: { + destinations: data.destinations ?? [], + nextToken: data.nextToken ?? null, + count: data.count ?? 0, + }, + } + }, + + outputs: { + destinations: { + type: 'array', + description: 'List of suppressed destinations with email address, reason, and last update', + }, + nextToken: { + type: 'string', + description: 'Pagination token for the next page of results', + optional: true, + }, + count: { type: 'number', description: 'Number of suppressed destinations returned' }, + }, +} diff --git a/apps/sim/tools/ses/put_suppressed_destination.ts b/apps/sim/tools/ses/put_suppressed_destination.ts new file mode 100644 index 00000000000..25701e32608 --- /dev/null +++ b/apps/sim/tools/ses/put_suppressed_destination.ts @@ -0,0 +1,80 @@ +import type { + SESPutSuppressedDestinationParams, + SESPutSuppressedDestinationResponse, +} from '@/tools/ses/types' +import type { ToolConfig } from '@/tools/types' + +export const putSuppressedDestinationTool: ToolConfig< + SESPutSuppressedDestinationParams, + SESPutSuppressedDestinationResponse +> = { + id: 'ses_put_suppressed_destination', + name: 'SES Put Suppressed Destination', + description: 'Add an email address to the account-level SES suppression list', + version: '1.0.0', + + params: { + region: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS region (e.g., us-east-1)', + }, + accessKeyId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS access key ID', + }, + secretAccessKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS secret access key', + }, + emailAddress: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The email address to add to the suppression list', + }, + reason: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The reason the address is suppressed: BOUNCE or COMPLAINT', + }, + }, + + request: { + url: '/api/tools/ses/put-suppressed-destination', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + region: params.region, + accessKeyId: params.accessKeyId, + secretAccessKey: params.secretAccessKey, + emailAddress: params.emailAddress, + reason: params.reason, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'Failed to add suppressed destination') + } + + return { + success: true, + output: { + message: data.message ?? '', + }, + } + }, + + outputs: { + message: { type: 'string', description: 'Confirmation message' }, + }, +} diff --git a/apps/sim/tools/ses/send_custom_verification_email.ts b/apps/sim/tools/ses/send_custom_verification_email.ts new file mode 100644 index 00000000000..66d4f9f59fd --- /dev/null +++ b/apps/sim/tools/ses/send_custom_verification_email.ts @@ -0,0 +1,88 @@ +import type { + SESSendCustomVerificationEmailParams, + SESSendCustomVerificationEmailResponse, +} from '@/tools/ses/types' +import type { ToolConfig } from '@/tools/types' + +export const sendCustomVerificationEmailTool: ToolConfig< + SESSendCustomVerificationEmailParams, + SESSendCustomVerificationEmailResponse +> = { + id: 'ses_send_custom_verification_email', + name: 'SES Send Custom Verification Email', + description: + 'Send a branded custom verification email to an address using a custom verification email template', + version: '1.0.0', + + params: { + region: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS region (e.g., us-east-1)', + }, + accessKeyId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS access key ID', + }, + secretAccessKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS secret access key', + }, + emailAddress: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The email address to verify', + }, + templateName: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The name of the custom verification email template to use', + }, + configurationSetName: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Configuration set to use when sending the verification email', + }, + }, + + request: { + url: '/api/tools/ses/send-custom-verification-email', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + region: params.region, + accessKeyId: params.accessKeyId, + secretAccessKey: params.secretAccessKey, + emailAddress: params.emailAddress, + templateName: params.templateName, + configurationSetName: params.configurationSetName, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'Failed to send custom verification email') + } + + return { + success: true, + output: { + messageId: data.messageId ?? '', + }, + } + }, + + outputs: { + messageId: { type: 'string', description: 'SES message ID for the sent verification email' }, + }, +} diff --git a/apps/sim/tools/ses/types.ts b/apps/sim/tools/ses/types.ts index 1f5f29046fc..671dda155d9 100644 --- a/apps/sim/tools/ses/types.ts +++ b/apps/sim/tools/ses/types.ts @@ -141,6 +141,178 @@ export interface SESDeleteTemplateResponse extends ToolResponse { } } +export interface SESPutSuppressedDestinationParams extends SESConnectionConfig { + emailAddress: string + reason: 'BOUNCE' | 'COMPLAINT' +} + +export interface SESPutSuppressedDestinationResponse extends ToolResponse { + output: { + message: string + } +} + +export interface SESDeleteSuppressedDestinationParams extends SESConnectionConfig { + emailAddress: string +} + +export interface SESDeleteSuppressedDestinationResponse extends ToolResponse { + output: { + message: string + } +} + +export interface SESGetSuppressedDestinationParams extends SESConnectionConfig { + emailAddress: string +} + +export interface SESGetSuppressedDestinationResponse extends ToolResponse { + output: { + emailAddress: string + reason: string + lastUpdateTime: string | null + messageId: string | null + feedbackId: string | null + } +} + +export interface SESListSuppressedDestinationsParams extends SESConnectionConfig { + reasons?: string | null + startDate?: string | null + endDate?: string | null + pageSize?: number | null + nextToken?: string | null +} + +export interface SESListSuppressedDestinationsResponse extends ToolResponse { + output: { + destinations: Array<{ + emailAddress: string + reason: string + lastUpdateTime: string | null + }> + nextToken: string | null + count: number + } +} + +export interface SESCreateEmailIdentityParams extends SESConnectionConfig { + emailIdentity: string + dkimSigningAttributes?: { + domainSigningSelector?: string + domainSigningPrivateKey?: string + nextSigningKeyLength?: 'RSA_1024_BIT' | 'RSA_2048_BIT' + } | null + tags?: Array<{ key: string; value: string }> | null + configurationSetName?: string | null +} + +export interface SESCreateEmailIdentityResponse extends ToolResponse { + output: { + identityType: string + verifiedForSendingStatus: boolean + dkimAttributes: { + signingEnabled: boolean | null + status: string | null + tokens: string[] + signingAttributesOrigin: string | null + nextSigningKeyLength: string | null + currentSigningKeyLength: string | null + lastKeyGenerationTimestamp: string | null + signingHostedZone: string | null + } | null + } +} + +export interface SESDeleteEmailIdentityParams extends SESConnectionConfig { + emailIdentity: string +} + +export interface SESDeleteEmailIdentityResponse extends ToolResponse { + output: { + message: string + } +} + +export interface SESGetEmailIdentityParams extends SESConnectionConfig { + emailIdentity: string +} + +export interface SESGetEmailIdentityResponse extends ToolResponse { + output: { + identityType: string + verifiedForSendingStatus: boolean + verificationStatus: string | null + feedbackForwardingStatus: boolean | null + configurationSetName: string | null + dkimAttributes: { + signingEnabled: boolean | null + status: string | null + tokens: string[] + signingAttributesOrigin: string | null + nextSigningKeyLength: string | null + currentSigningKeyLength: string | null + lastKeyGenerationTimestamp: string | null + signingHostedZone: string | null + } | null + mailFromAttributes: { + mailFromDomain: string | null + mailFromDomainStatus: string | null + behaviorOnMxFailure: string | null + } | null + policies: Record | null + tags: Array<{ key: string; value: string }> + verificationInfo: { + errorType: string | null + lastCheckedTimestamp: string | null + lastSuccessTimestamp: string | null + } | null + } +} + +export interface SESUpdateTemplateParams extends SESConnectionConfig { + templateName: string + subjectPart: string + textPart?: string | null + htmlPart?: string | null +} + +export interface SESUpdateTemplateResponse extends ToolResponse { + output: { + message: string + } +} + +export interface SESCreateConfigurationSetParams extends SESConnectionConfig { + configurationSetName: string + customRedirectDomain?: string | null + httpsPolicy?: 'REQUIRE' | 'REQUIRE_OPEN_ONLY' | 'OPTIONAL' | null + tlsPolicy?: 'REQUIRE' | 'OPTIONAL' | null + sendingPoolName?: string | null + reputationMetricsEnabled?: boolean | null + sendingEnabled?: boolean | null + suppressedReasons?: string | null + tags?: Array<{ key: string; value: string }> | null +} + +export interface SESCreateConfigurationSetResponse extends ToolResponse { + output: { + message: string + } +} + +export interface SESSendCustomVerificationEmailParams extends SESConnectionConfig { + emailAddress: string + templateName: string + configurationSetName?: string | null +} + +export interface SESSendCustomVerificationEmailResponse extends ToolResponse { + output: { + messageId: string + } +} + interface SESBaseResponse extends ToolResponse { output: { message: string } } diff --git a/apps/sim/tools/ses/update_template.ts b/apps/sim/tools/ses/update_template.ts new file mode 100644 index 00000000000..288b76af8f7 --- /dev/null +++ b/apps/sim/tools/ses/update_template.ts @@ -0,0 +1,88 @@ +import type { SESUpdateTemplateParams, SESUpdateTemplateResponse } from '@/tools/ses/types' +import type { ToolConfig } from '@/tools/types' + +export const updateTemplateTool: ToolConfig = { + id: 'ses_update_template', + name: 'SES Update Template', + description: 'Update the subject, HTML, and text content of an existing SES email template', + version: '1.0.0', + + params: { + region: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS region (e.g., us-east-1)', + }, + accessKeyId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS access key ID', + }, + secretAccessKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS secret access key', + }, + templateName: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The name of the template to update', + }, + subjectPart: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The subject line of the template', + }, + htmlPart: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'The HTML body of the template', + }, + textPart: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'The plain text body of the template', + }, + }, + + request: { + url: '/api/tools/ses/update-template', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + region: params.region, + accessKeyId: params.accessKeyId, + secretAccessKey: params.secretAccessKey, + templateName: params.templateName, + subjectPart: params.subjectPart, + htmlPart: params.htmlPart, + textPart: params.textPart, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'Failed to update template') + } + + return { + success: true, + output: { + message: data.message ?? '', + }, + } + }, + + outputs: { + message: { type: 'string', description: 'Confirmation message' }, + }, +} diff --git a/apps/sim/tools/sts/assume_role.ts b/apps/sim/tools/sts/assume_role.ts index 2e20e89af31..7422dfd7675 100644 --- a/apps/sim/tools/sts/assume_role.ts +++ b/apps/sim/tools/sts/assume_role.ts @@ -68,6 +68,26 @@ export const assumeRoleTool: ToolConfig = { + id: 'sts_assume_role_with_saml', + name: 'STS Assume Role With SAML', + description: + 'Assume an IAM role using a SAML 2.0 authentication response from an enterprise identity provider and receive temporary security credentials', + version: '1.0.0', + + params: { + region: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS region (e.g., us-east-1)', + }, + roleArn: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ARN of the IAM role to assume', + }, + principalArn: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ARN of the SAML provider in IAM that describes the identity provider', + }, + samlAssertion: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Base64-encoded SAML authentication response from the identity provider', + }, + policyArns: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Comma-separated ARNs of up to 10 IAM managed policies to use as session policies', + }, + policy: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'JSON IAM policy to further restrict session permissions (max 2048 chars)', + }, + durationSeconds: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Duration of the session in seconds (900-43200, default 3600)', + }, + }, + + request: { + url: '/api/tools/sts/assume-role-with-saml', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + region: params.region, + roleArn: params.roleArn, + principalArn: params.principalArn, + samlAssertion: params.samlAssertion, + policyArns: params.policyArns, + policy: params.policy, + durationSeconds: params.durationSeconds, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'Failed to assume role with SAML') + } + + return { + success: true, + output: { + accessKeyId: data.accessKeyId ?? '', + secretAccessKey: data.secretAccessKey ?? '', + sessionToken: data.sessionToken ?? '', + expiration: data.expiration ?? null, + assumedRoleArn: data.assumedRoleArn ?? '', + assumedRoleId: data.assumedRoleId ?? '', + subject: data.subject ?? null, + subjectType: data.subjectType ?? null, + issuer: data.issuer ?? null, + audience: data.audience ?? null, + nameQualifier: data.nameQualifier ?? null, + packedPolicySize: data.packedPolicySize ?? null, + sourceIdentity: data.sourceIdentity ?? null, + }, + } + }, + + outputs: { + accessKeyId: { type: 'string', description: 'Temporary access key ID' }, + secretAccessKey: { type: 'string', description: 'Temporary secret access key' }, + sessionToken: { type: 'string', description: 'Temporary session token' }, + expiration: { type: 'string', description: 'Credential expiration timestamp', optional: true }, + assumedRoleArn: { type: 'string', description: 'ARN of the assumed role' }, + assumedRoleId: { type: 'string', description: 'Assumed role ID with session name' }, + subject: { + type: 'string', + description: 'Value of the NameID element in the Subject of the SAML assertion', + optional: true, + }, + subjectType: { + type: 'string', + description: 'Format of the name ID (e.g. transient, persistent)', + optional: true, + }, + issuer: { + type: 'string', + description: 'Value of the Issuer element of the SAML assertion', + optional: true, + }, + audience: { + type: 'string', + description: "Value of the SAML assertion's SubjectConfirmationData Recipient attribute", + optional: true, + }, + nameQualifier: { + type: 'string', + description: 'Hash uniquely identifying the issuer, account, and SAML provider', + optional: true, + }, + packedPolicySize: { + type: 'number', + description: 'Percentage of allowed policy size used', + optional: true, + }, + sourceIdentity: { + type: 'string', + description: 'Source identity set on the role session, if any', + optional: true, + }, + }, +} diff --git a/apps/sim/tools/sts/assume_role_with_web_identity.ts b/apps/sim/tools/sts/assume_role_with_web_identity.ts new file mode 100644 index 00000000000..26afa9661ea --- /dev/null +++ b/apps/sim/tools/sts/assume_role_with_web_identity.ts @@ -0,0 +1,144 @@ +import type { + STSAssumeRoleWithWebIdentityParams, + STSAssumeRoleWithWebIdentityResponse, +} from '@/tools/sts/types' +import type { ToolConfig } from '@/tools/types' + +export const assumeRoleWithWebIdentityTool: ToolConfig< + STSAssumeRoleWithWebIdentityParams, + STSAssumeRoleWithWebIdentityResponse +> = { + id: 'sts_assume_role_with_web_identity', + name: 'STS Assume Role With Web Identity', + description: + 'Assume an IAM role using an OIDC/OAuth 2.0 web identity token (e.g. GitHub Actions OIDC, EKS IRSA, Google/Facebook federation) and receive temporary security credentials', + version: '1.0.0', + + params: { + region: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS region (e.g., us-east-1)', + }, + roleArn: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ARN of the IAM role to assume', + }, + roleSessionName: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Identifier for the assumed role session', + }, + webIdentityToken: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + 'OAuth 2.0 access token or OpenID Connect ID token from the identity provider (up to 20000 chars)', + }, + providerId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Fully qualified host of a legacy OAuth 2.0 provider (e.g. www.amazon.com); omit for OpenID Connect providers', + }, + policyArns: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'Comma-separated ARNs of up to 10 IAM managed policies to use as session policies', + }, + policy: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'JSON IAM policy to further restrict session permissions (max 2048 chars)', + }, + durationSeconds: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Duration of the session in seconds (900-43200, default 3600)', + }, + }, + + request: { + url: '/api/tools/sts/assume-role-with-web-identity', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + region: params.region, + roleArn: params.roleArn, + roleSessionName: params.roleSessionName, + webIdentityToken: params.webIdentityToken, + providerId: params.providerId, + policyArns: params.policyArns, + policy: params.policy, + durationSeconds: params.durationSeconds, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || 'Failed to assume role with web identity') + } + + return { + success: true, + output: { + accessKeyId: data.accessKeyId ?? '', + secretAccessKey: data.secretAccessKey ?? '', + sessionToken: data.sessionToken ?? '', + expiration: data.expiration ?? null, + assumedRoleArn: data.assumedRoleArn ?? '', + assumedRoleId: data.assumedRoleId ?? '', + subjectFromWebIdentityToken: data.subjectFromWebIdentityToken ?? '', + audience: data.audience ?? null, + provider: data.provider ?? null, + packedPolicySize: data.packedPolicySize ?? null, + sourceIdentity: data.sourceIdentity ?? null, + }, + } + }, + + outputs: { + accessKeyId: { type: 'string', description: 'Temporary access key ID' }, + secretAccessKey: { type: 'string', description: 'Temporary secret access key' }, + sessionToken: { type: 'string', description: 'Temporary session token' }, + expiration: { type: 'string', description: 'Credential expiration timestamp', optional: true }, + assumedRoleArn: { type: 'string', description: 'ARN of the assumed role' }, + assumedRoleId: { type: 'string', description: 'Assumed role ID with session name' }, + subjectFromWebIdentityToken: { + type: 'string', + description: "Unique user identifier from the identity provider's token subject claim", + }, + audience: { + type: 'string', + description: 'Intended audience (client ID) of the web identity token', + optional: true, + }, + provider: { + type: 'string', + description: 'Issuing authority of the presented web identity token', + optional: true, + }, + packedPolicySize: { + type: 'number', + description: 'Percentage of allowed policy size used', + optional: true, + }, + sourceIdentity: { + type: 'string', + description: 'Source identity set on the role session, if any', + optional: true, + }, + }, +} diff --git a/apps/sim/tools/sts/index.ts b/apps/sim/tools/sts/index.ts index b691b7a64cf..2e4baa5f3a2 100644 --- a/apps/sim/tools/sts/index.ts +++ b/apps/sim/tools/sts/index.ts @@ -1,9 +1,13 @@ import { assumeRoleTool } from './assume_role' +import { assumeRoleWithSAMLTool } from './assume_role_with_saml' +import { assumeRoleWithWebIdentityTool } from './assume_role_with_web_identity' import { getAccessKeyInfoTool } from './get_access_key_info' import { getCallerIdentityTool } from './get_caller_identity' import { getSessionTokenTool } from './get_session_token' export const stsAssumeRoleTool = assumeRoleTool +export const stsAssumeRoleWithWebIdentityTool = assumeRoleWithWebIdentityTool +export const stsAssumeRoleWithSAMLTool = assumeRoleWithSAMLTool export const stsGetCallerIdentityTool = getCallerIdentityTool export const stsGetSessionTokenTool = getSessionTokenTool export const stsGetAccessKeyInfoTool = getAccessKeyInfoTool diff --git a/apps/sim/tools/sts/types.ts b/apps/sim/tools/sts/types.ts index 5676d972b5a..de0a1afa89c 100644 --- a/apps/sim/tools/sts/types.ts +++ b/apps/sim/tools/sts/types.ts @@ -14,6 +14,30 @@ export interface STSAssumeRoleParams extends STSConnectionConfig { externalId?: string | null serialNumber?: string | null tokenCode?: string | null + policyArns?: string | null + tags?: string | null + transitiveTagKeys?: string | null +} + +export interface STSAssumeRoleWithWebIdentityParams { + region: string + roleArn: string + roleSessionName: string + webIdentityToken: string + providerId?: string | null + policyArns?: string | null + policy?: string | null + durationSeconds?: number | null +} + +export interface STSAssumeRoleWithSAMLParams { + region: string + roleArn: string + principalArn: string + samlAssertion: string + policyArns?: string | null + policy?: string | null + durationSeconds?: number | null } export interface STSGetCallerIdentityParams extends STSConnectionConfig {} @@ -41,6 +65,40 @@ export interface STSAssumeRoleResponse extends ToolResponse { } } +export interface STSAssumeRoleWithWebIdentityResponse extends ToolResponse { + output: { + accessKeyId: string + secretAccessKey: string + sessionToken: string + expiration: string | null + assumedRoleArn: string + assumedRoleId: string + subjectFromWebIdentityToken: string + audience: string | null + provider: string | null + packedPolicySize: number | null + sourceIdentity: string | null + } +} + +export interface STSAssumeRoleWithSAMLResponse extends ToolResponse { + output: { + accessKeyId: string + secretAccessKey: string + sessionToken: string + expiration: string | null + assumedRoleArn: string + assumedRoleId: string + subject: string | null + subjectType: string | null + issuer: string | null + audience: string | null + nameQualifier: string | null + packedPolicySize: number | null + sourceIdentity: string | null + } +} + export interface STSGetCallerIdentityResponse extends ToolResponse { output: { account: string diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index 0135f8596d5..677077f158e 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: 887, - zodRoutes: 887, + totalRoutes: 904, + zodRoutes: 904, nonZodRoutes: 0, } as const From 03478cd57413062e37d4adb9842039f4813e6635 Mon Sep 17 00:00:00 2001 From: Waleed Date: Mon, 6 Jul 2026 18:07:52 -0700 Subject: [PATCH 26/35] fix(microsoft-excel): clean up dead code found during integration audit (#5454) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(microsoft-excel): clean up dead code found during integration audit - Remove unreachable 'update' operation subblocks (Google Sheets copy-paste leftover — never a selectable dropdown option and not handled by the tool selector or any registered tool) - Fix microsoft_excel_table_add to trim spreadsheetId and OData-escape tableName, matching every other tool in this file - Drop phantom/dead type fields: Google-Sheets-only insertDataOption and responseValueRenderOption (never used), never-populated sheetId/sheetName/title/sheets metadata fields, unused rowIndex, and the unconfigurable majorDimension param (hardcoded to 'ROWS' now that it's inlined) * fix(microsoft-excel): fix write output field mapping + OData escaping gaps Found in an independent second-pass audit against the live Graph API docs: - microsoft_excel_write (v1) transformResponse read updatedRange/updatedRows/updatedColumns/updatedCells from the Graph response — none of these fields exist on the workbookRange resource (the real fields are address/rowCount/columnCount), so these four output fields were always undefined. Fixed to read the actual fields, matching what the v2 write tool already does correctly. - read.ts and write.ts (v1 and v2) built worksheets('name') OData URLs with only encodeURIComponent, skipping escapeODataString — a worksheet name containing an apostrophe would produce a malformed request. Every other tool in this integration already escapes correctly; read/write were the outliers. - write.ts (v1) also wasn't trimming spreadsheetId before use, inconsistent with every other tool here. --- apps/sim/blocks/blocks/microsoft_excel.ts | 35 --------------------- apps/sim/tools/microsoft_excel/read.ts | 9 +++--- apps/sim/tools/microsoft_excel/table_add.ts | 32 ++++++++++++++----- apps/sim/tools/microsoft_excel/types.ts | 15 --------- apps/sim/tools/microsoft_excel/write.ts | 29 +++++++++++------ 5 files changed, 48 insertions(+), 72 deletions(-) diff --git a/apps/sim/blocks/blocks/microsoft_excel.ts b/apps/sim/blocks/blocks/microsoft_excel.ts index 4d05101cbed..4312a5b370a 100644 --- a/apps/sim/blocks/blocks/microsoft_excel.ts +++ b/apps/sim/blocks/blocks/microsoft_excel.ts @@ -234,41 +234,6 @@ Return ONLY the JSON array - no explanations, no markdown, no extra text.`, ], condition: { field: 'operation', value: 'write' }, }, - { - id: 'values', - title: 'Values', - type: 'long-input', - placeholder: - 'Enter values as JSON array of arrays (e.g., [["A1", "B1"], ["A2", "B2"]]) or an array of objects (e.g., [{"name":"John", "age":30}, {"name":"Jane", "age":25}])', - condition: { field: 'operation', value: 'update' }, - required: true, - wandConfig: { - enabled: true, - prompt: `Generate Microsoft Excel data as a JSON array based on the user's description. - -Format options: -1. Array of arrays: [["Header1", "Header2"], ["Value1", "Value2"]] -2. Array of objects: [{"column1": "value1", "column2": "value2"}] - -Examples: -- "update with new prices" -> [["Product", "Price"], ["Widget A", 29.99], ["Widget B", 49.99]] -- "quarterly targets" -> [{"Q1": 10000, "Q2": 12000, "Q3": 15000, "Q4": 18000}] - -Return ONLY the JSON array - no explanations, no markdown, no extra text.`, - placeholder: 'Describe the data you want to update...', - generationType: 'json-object', - }, - }, - { - id: 'valueInputOption', - title: 'Value Input Option', - type: 'dropdown', - options: [ - { label: 'User Entered (Parse formulas)', id: 'USER_ENTERED' }, - { label: "Raw (Don't parse formulas)", id: 'RAW' }, - ], - condition: { field: 'operation', value: 'update' }, - }, { id: 'values', title: 'Values', diff --git a/apps/sim/tools/microsoft_excel/read.ts b/apps/sim/tools/microsoft_excel/read.ts index adf4ab53850..fe18103b563 100644 --- a/apps/sim/tools/microsoft_excel/read.ts +++ b/apps/sim/tools/microsoft_excel/read.ts @@ -7,6 +7,7 @@ import type { MicrosoftExcelV2ToolParams, } from '@/tools/microsoft_excel/types' import { + escapeODataString, getItemBasePath, getSpreadsheetWebUrl, parseGraphErrorMessage, @@ -79,7 +80,7 @@ export const readTool: ToolConfig { - const tableName = encodeURIComponent(params.tableName) - const basePath = getItemBasePath(params.spreadsheetId, params.driveId) - return `${basePath}/workbook/tables('${tableName}')/rows/add` + const spreadsheetId = params.spreadsheetId?.trim() + if (!spreadsheetId) { + throw new Error('Spreadsheet ID is required') + } + const tableName = params.tableName?.trim() + if (!tableName) { + throw new Error('Table name is required') + } + const basePath = getItemBasePath(spreadsheetId, params.driveId) + return `${basePath}/workbook/tables('${encodeURIComponent(escapeODataString(tableName))}')/rows/add` }, method: 'POST', - headers: (params) => ({ - Authorization: `Bearer ${params.accessToken}`, - 'Content-Type': 'application/json', - }), + headers: (params) => { + if (!params.accessToken) { + throw new Error('Access token is required') + } + return { + Authorization: `Bearer ${params.accessToken}`, + 'Content-Type': 'application/json', + } + }, body: (params) => { let processedValues: any = params.values || [] diff --git a/apps/sim/tools/microsoft_excel/types.ts b/apps/sim/tools/microsoft_excel/types.ts index a3d3bc42d34..ed5a3123587 100644 --- a/apps/sim/tools/microsoft_excel/types.ts +++ b/apps/sim/tools/microsoft_excel/types.ts @@ -4,8 +4,6 @@ import type { ToolResponse } from '@/tools/types' export type ExcelCellValue = string | number | boolean | null interface MicrosoftExcelRange { - sheetId?: number - sheetName?: string range: string values: ExcelCellValue[][] } @@ -13,14 +11,6 @@ interface MicrosoftExcelRange { interface MicrosoftExcelMetadata { spreadsheetId: string spreadsheetUrl?: string - title?: string - sheets?: { - sheetId: number - title: string - index: number - rowCount?: number - columnCount?: number - }[] } export interface MicrosoftExcelReadResponse extends ToolResponse { @@ -67,10 +57,7 @@ export interface MicrosoftExcelToolParams { range?: string values?: ExcelCellValue[][] valueInputOption?: 'RAW' | 'USER_ENTERED' - insertDataOption?: 'OVERWRITE' | 'INSERT_ROWS' includeValuesInResponse?: boolean - responseValueRenderOption?: 'FORMATTED_VALUE' | 'UNFORMATTED_VALUE' | 'FORMULA' - majorDimension?: 'ROWS' | 'COLUMNS' } export interface MicrosoftExcelTableToolParams { @@ -79,7 +66,6 @@ export interface MicrosoftExcelTableToolParams { driveId?: string tableName: string values: ExcelCellValue[][] - rowIndex?: number } export interface MicrosoftExcelWorksheetToolParams { @@ -217,7 +203,6 @@ export interface MicrosoftExcelV2ToolParams { values?: ExcelCellValue[][] valueInputOption?: 'RAW' | 'USER_ENTERED' includeValuesInResponse?: boolean - majorDimension?: 'ROWS' | 'COLUMNS' } export interface MicrosoftExcelV2ReadResponse extends ToolResponse { diff --git a/apps/sim/tools/microsoft_excel/write.ts b/apps/sim/tools/microsoft_excel/write.ts index 0420c753648..4c2ec1dc2b2 100644 --- a/apps/sim/tools/microsoft_excel/write.ts +++ b/apps/sim/tools/microsoft_excel/write.ts @@ -5,7 +5,11 @@ import type { MicrosoftExcelV2WriteResponse, MicrosoftExcelWriteResponse, } from '@/tools/microsoft_excel/types' -import { getItemBasePath, getSpreadsheetWebUrl } from '@/tools/microsoft_excel/utils' +import { + escapeODataString, + getItemBasePath, + getSpreadsheetWebUrl, +} from '@/tools/microsoft_excel/utils' import type { ToolConfig } from '@/tools/types' /** @@ -82,6 +86,11 @@ export const writeTool: ToolConfig { + const spreadsheetId = params.spreadsheetId?.trim() + if (!spreadsheetId) { + throw new Error('Spreadsheet ID is required') + } + const rangeInput = params.range?.trim() const match = rangeInput?.match(/^([^!]+)!(.+)$/) @@ -89,10 +98,10 @@ export const writeTool: ToolConfig = { - majorDimension: params.majorDimension || 'ROWS', + majorDimension: 'ROWS', values: processedValues, } @@ -173,10 +182,10 @@ export const writeTool: ToolConfig = { - majorDimension: params.majorDimension || 'ROWS', + majorDimension: 'ROWS', values: processedValues, } From 719179258c0c51eaf8e308c76868e4e3796db7ba Mon Sep 17 00:00:00 2001 From: Waleed Date: Mon, 6 Jul 2026 18:19:52 -0700 Subject: [PATCH 27/35] improvement(rich-markdown-editor): table column-resize cursor, exhaustive paste tests, editor docs (#5455) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(rich-markdown-editor): show the col-resize cursor on table column borders prosemirror-tables toggles a `resize-cursor` class on the editor while the pointer is over a column boundary, but there was no rule to change the cursor — the blue resize handle showed with no cursor affordance. Add the scoped `col-resize` rule. * test(rich-markdown-editor): exhaustive markdown paste coverage Cover every rich construct (headings, marks, lists, task lists, blockquote, code block, image, thematic break, table), markdown parsed despite an HTML sibling, multi-block order, read-only rejection, and the defer/verbatim cases for non-markdown input. * docs(editor): add rich markdown editor page Document the inline rich markdown editor — formatting, structure, lists, tables, code blocks, images, the slash menu, and markdown fidelity — with a rendered overview screenshot. * test(rich-markdown-editor): scope paste tests to what MarkdownPaste actually gates Inline-only marks (single-asterisk italic, ~~, single-backtick code) are intentionally not detected by looksLikeMarkdown (single `*` would false-positive on e.g. `*args`); they route through the Markdown extension's own paste path, not MarkdownPaste. Move them from the rich-render cases to the defers-to-default cases so the suite tests the handler it names. --- apps/docs/content/docs/en/files/editor.mdx | 56 ++++++++++++++++ apps/docs/content/docs/en/meta.json | 1 + .../public/static/files/editor/overview.png | Bin 0 -> 33521 bytes .../markdown-paste.test.ts | 61 +++++++++++++++++- .../rich-markdown-editor.css | 9 +++ 5 files changed, 125 insertions(+), 2 deletions(-) create mode 100644 apps/docs/content/docs/en/files/editor.mdx create mode 100644 apps/docs/public/static/files/editor/overview.png diff --git a/apps/docs/content/docs/en/files/editor.mdx b/apps/docs/content/docs/en/files/editor.mdx new file mode 100644 index 00000000000..672797fd98a --- /dev/null +++ b/apps/docs/content/docs/en/files/editor.mdx @@ -0,0 +1,56 @@ +--- +title: Editor +description: A rich markdown editor for your files — type markdown and watch it render, or edit visually. +pageType: concept +--- + +import { Image } from '@/components/ui/image' +import { Card, Cards } from 'fumadocs-ui/components/card' +import { Callout } from 'fumadocs-ui/components/callout' + +Every markdown file in your workspace opens in a **rich editor**. Type markdown and it renders as you go, or format visually with the toolbar and slash menu. What you see is exactly what gets saved — plain markdown underneath, no lock-in. + +
+ A markdown file rendered in the editor: headings, bold, italic, and a link, a nested bullet list, and a table +
+ +## Formatting text + +Select any text to bring up the formatting toolbar — bold, italic, strikethrough, inline code, and links. The same marks appear instantly as you type the markdown for them, like `**bold**` or `*italic*`. Links show a hover card so you can open, copy, edit, or remove them without hunting through the source. + +## Structure + +Headings, blockquotes, and dividers keep long documents scannable. Type `# ` through `###### ` for headings, `> ` for a quote, and `---` for a divider. + +## Lists and checklists + +Bullet, ordered, and nested lists all work, plus task lists you can tick right in the document. + +## Tables + +Insert a table from the slash menu, then click any cell for the floating table toolbar — add or remove rows and columns, toggle the header row, or delete the table. Drag a column border to resize it. + +## Code blocks + +Fenced code blocks are syntax-highlighted, with a language picker in the corner. Pick `mermaid` to render a live diagram instead of code. + +## Images + +Paste or drag an image straight into the document, then drag a corner to resize it. + +## Slash menu and shortcuts + +Type `/` anywhere to insert any block — heading, list, table, code block, image, and more — without leaving the keyboard. Familiar shortcuts work too: **Cmd/Ctrl + B** for bold, **Cmd/Ctrl + I** for italic, and **Cmd/Ctrl + K** to add a link over selected text. + +## Markdown fidelity + +The editor round-trips your markdown exactly — it saves what you wrote, with no reformatting churn. + + +A few constructs can't be represented visually without losing information on save — footnotes, raw HTML, and HTML comments. When a file contains one of these, it opens **read-only** so the original source is preserved untouched. Everything is still rendered faithfully; you just can't edit that file inline. + + + + + + diff --git a/apps/docs/content/docs/en/meta.json b/apps/docs/content/docs/en/meta.json index 5d11bb02c2a..69ddd2d5679 100644 --- a/apps/docs/content/docs/en/meta.json +++ b/apps/docs/content/docs/en/meta.json @@ -27,6 +27,7 @@ "./tables/workflow-columns", "---Files---", "./files/index", + "./files/editor", "./files/using-in-workflows", "./files/generating", "./files/passing-files", diff --git a/apps/docs/public/static/files/editor/overview.png b/apps/docs/public/static/files/editor/overview.png new file mode 100644 index 0000000000000000000000000000000000000000..0f736b2eeff0afe360eb607094e8a4efb4a175c8 GIT binary patch literal 33521 zcmeFZXH-*L*e(hPBA{TQC`j2hnt+8SosA6?X+mfL1?jzn(5s4y2#SExRgjt>U;-pS zL=+ID2!sG36zKs%uc6$TT@>Bpo*(zeJ?D(^?J?BsWUV#VY;Sp<=baCAZ(P~O%F9Ye zN4HP&>P3Azx?K}=boBLmc7t!u>F0k2|8v_xLqk_nLqkZ{)6Lew*@liz_)9lCO z&a&2TrD;AQ1N^O^f=1vV42>$&|3=oKH!Q$}w7Tb**1Pt5ytHC08vi?wTi z;5nHfd%e~#xeSt>^sxEo?EPd5dXpa%&OezDqBp)lWpex~nuNEv9=m ze2>Roy+>z*C;R0VD^eNG2%PGguXqU?aNa+j=WnqZJ#R2D|40-w?`4wCe}?tV*+fN& zjHM4A7Grd`_v9$cH)BWSQO7@Q?=kO6XN|GJ8B9De2V-tzW2|YbtxYEeKJTHUk9448 z1fS@^A71bWOwY6Xbj;x2gW%7_42FMy-8GT1>)+4x^|TAs4Ky@0!M_I9o;EhFUiNO@ zTZSXU;86sJo5tS8+Se7V-CQJZ-*L0Dk@R(Or`p*cJ)&5RX+CD z6$;=p?Q5xHLVsQ2?WBCnSX);}!_Cu1NLKQU_*xFr0%q?@Om)Y%IcE=Zj@Cw1=><~AOHRRo;JP?KU;G3`lnlj&<^b98iSbeb2{Z~D?t zk(dLy414P4gctjm7@wco6aIk7*;tpmC^ScCkC<4O?t!9PW&{RSWWjgVV`5f=P^cnn zLRQYzqR@L*yYxi4IWJ~v%+IU(*M4bg(mwv>#0q>lu2~A@LLF9B2~^dh!+}^s7?hkt~K& z*I(Q^sdZ=O!uS(@MduHp^xb(|n=AcpEx)~=Oa^$>ntFUspm|Md1Q}+Vl?Jjot56vo6XX1N@xKyv^3us5YSZN7( zL7$H-!et{E*d#_MXy8Lw2>*+StaCOE#^s(P;m5TSP9Hfv<~=xD!%<4c=QBs{J96%X znYusNYz1K4sy$5Y2}o$w?g@OJVM$xoHLO=B5~@fl_rktDSJ|HyKzT{DIu_6yOr1`Y zf)TA_l*4BR`T}OAn_n>-%Q$sPB4CCDC(bZ*Lh~(NG!yV#ju0-`M%p;0FyN28biuCOHMTReCTi*#~_31 z$m6Mw6b!cC-WXGr=;b$!L!;dK`?y{`T#=sZQr(*4XqWfc$0fhqWo;d$Q;MAb$Z^rA z(86Vtx&rr;)(ORoW|OurE8Pa|8s6FNADUEeVQ4ucIVA z{iNRpbiKRX{95nLW!Ho9+6Py=?b6F#_+*{0LOLnt-{6L%jujHzGG9OI5!N!VrK`w# zDDCp0!xn*apI!S``a|4!%-zGVF(}7QtrguXX{m~EpS3i<(Id(79#*;*K9#W01{TGA zywdfQ@-YF3<&xEf$%2yJ5N7qk(?9L~9W&rWh&S(?W@QLQyW?e!KFAZVNM9{bkJw$E zWq%OKEEw4Ok@((Vs3%#*$yOth9R(~y62n-R2d*Jos4j6b#tEMnA01*}v9B^Xr*ffL zu_xt?_u{uJ*mKr(ZRvsYUnEHgZJY9tQ`9iH3*JBj;@&^9RmWSKBJ@omJ-UfZ-s87V0Jqnf@ZPfq~zLCc^77 zLZNK_IT@ZcB?Kqjsh0+7h{^WM0EoHAJ;m!a{DGTX&m$ui)06{tQ=ezsG$u&acZMag zKw)#5l{~z%&WDEyyIyqp=RH3DME;|4*|!t-{AY`>?H+afcBTEn*m$ueLb@FkdrG-u z*>~k4M`DHgn&${K-fEY(OGnM#Z8eR4rmPhI$-~MY=EmAJ7?rzNw&58{Cw{oruwIrk z9_Ex@c`+*n)^W-vp;9t-&am+YUDmBLvT8B=YVi$$O)`X4tM;Jv4<}^Z^%EDT-g8qw zek2jOVslSQ%jV`|bsV{#C}%J8`wc&xVDM&Y>JMCqMwpd03zCr#MB3al0ngPzhPh>` z3+rRSpv7){W(98jRj!A_8MINRk&XI6L8*se-5~>E%;y-#i$F(sy>am$IsW9l zOU+ccZ|RXe?QcI2C*Z&OJR!-}Kh;D&mr|A1BX}cT%+OQ$;78&?iOE+F?^}JMe$zOT z&bi7iD60ewMFtHnL~3t(%&8c(x8d%ek&TgS%7r^$Sg%^&X17`?dh)|9$9e&8kdo|i zk&$unhDRk&@3#Hs>~VG-NJ&BX9T@*g;k&jlf16|mIVQ|tg6(vBrq|0XyY!$=1@G}& ztmuT(CI{~r3>+l#9-Vq@qAN$9)wp-QMd>`0ohjW>b4e3gGQ{NMxVEARtyv;pU~0_U z2IhAJZemo(Y+!LjWc-OXYr`-?V3$g6G7cjjLY*!}nyog;^-saeKWsd`BH)4aY$#|% zv$w`ZxKE-_Z5m9LQ637I-xa>tzG>9DnZCUtET8DFwn`|^O|ByI-f#?C(NsPWxbDA3 z@CeWnk?&NTfJx%5hiap@Vh5(_tM8xYmX=lOi)LG{Oqgwj?YlyqSsg|e6+!Aw1zbrcAnT8*u6(DYrq_r8l1@UK@stxFhp z*UeSy7H&E?6JdgD3VFgO*Nnt%`4f&IGZbZWFNLHBEwjjOZ&7F7&VA)&Lu3k>RTt|d zxtNc2&hElP>9-7Yltlx{VbKA^nYN9BLh(m6Z0Xsbd({W>?*r+O>GI`I9?7 zlbApAHV+t-MOE~_ZJ}Q7=c&M2EVaIXr+4sI25+th!%71wxa}?V3uO9NR7vJ64wY3Q zSB|KGQ1jEnwTgWX+>++w6#T~80?F9BUrSI*YR8ZrWZRG#KkK2gd`$NaBj;X8C`P(* z{eDD8cZpp#+m`n`(}^01!k*FoN~Fz6ruT!HYEbp~@LMOfPbHEvk*#E-LYB_w(q{?D zZdDVtHzFfSfhqdrvV=d&dO|Q@>MQ|fMvrXJWvd8ppFM0hf*-!4@~#u3^HP~^mTIla z6-tfd>&b=$-!qVZw?qWSPm{-;3fn$XC7kq0@@TAh3}!lHeIi?3EiWzIuHEt#Kqh~S6g}>96i9P z3vp&Ty+p>JXL@J4Qi2h0EvHhjq`PhpO&7(UZ|9>Rh=g)Y@-e~Wjk3wE>24FqyPQs| zlhN^ni?)tg%TS`BPeq2DIT};pv^?FL38Ig)b&PE2CKc83Ft2mFUO_ zeSSW6|MOU3+92%Dq%w1mw`L-|n?4~a8(ZpMfZsPyTy2!XnPrEr+6H8Ur}nR_BJ=nNa@GNwx9R zOv(O1BPSdw!~#x&9bxdU#9SlAo_G4PR7W=OD~bE4j%08_nNu?(g;gBPtJ1Z5-P*I! zPFAH|oM}&0d$UwhbSfDF^;?)uqL5TN(0*4o*R73 zKazl3{T_8I`Bd8@?WDunq=BXVYS0ho*?6sE8XGa;&A@8NiTnvIU3w&*UhH^@Al?zE zf2Q!^?BcsW5BXPaE)#8Qg{I$`IFMWnT1`dFcj&Tqy1HcY3W+ zQmJ#OHwbc zAEa8svT_g)A#DadO11t&s;}z0%Fq4eQ%Gms1}_xO;wlcsn2ML_>F2*<_~^be>AtkF z>L%Eg_ZcGbjn@n@HxPWZ?1{EtR(K4v0Pu;*@!&if5ubg&Rcv6xc_0;f@gUbsnr zc7?C7f-v=`Q}UEjyXH@smOmHHKu#05@vt^4pY6TvI(wrRXS}!ID@sgZT zw0+wwBxSp!avWG}jKG1Uqc`HJ@9pxARuu$Ekm~8xy&Sh&YiA3l{Z|iA9A0huyZPSG ze~lT-OzEnqGB>GR_H7+@^|ybXOa~of;j@P-bKXp2Sg!ETS33A(b$+0530>{4OnaW7 z!!Sr`SNPnCm=n%VQVH_M@U9xc`q%SQ2MPWTN8k0u2+YL0_`@-nPw5~(QBNH2^1s`t z4gc<3a97weO5#d&lI`5G!@PLhrqxFs)GhI=PY~JR8wZYG&Q$DFT-BL=tlTWPAL-eiqIj61+3SJA3p>tpN!!&f=<>{QhizeNm{X z& ziRknV32YtDQlR6UYg^_+gmKX^SNF8`$#(J0n|;7C+4;i*oV9#rj5&Vzqp*(v7n0fw zYkv5{^!;tb)s6xSUZ;Nh(2t4w=Yo4646-;(PC4`&@)RZy;4%;0uAlhBKM%SMur8ka z3S$?4L&1cefQ;1uTfNU|})Bc&r|NV*cK6udW z9IqQcBL06~?K^1sf4cD3ys7=qEc}ST_W$o(NYBL{$9djhoIlo@YapC&n4b^?Vo>y* zFV9X#DNxFW3Vfz}B>Tge*rq@{_SOCCV5FeyK0aA*VtVlU#9nGt!~F<$h5BZN${`y^tca!KmuDxZ-_@|6M|p~?wYcN~Y;PaGf^^NGBk5!o&kNvaft|%Gb86y* z?VJEKXu3ZmJL_#XKptJ1l4Ovv7pa~;+@8mOM4UppN{sfJQUWVQ#+H}*tAe1A2@>on z6zhY^PV;Z50+Dsb)$qm8W5)JvWe>BSUoASNoy17=ENMkTu>wvPKJdml7LF5bS#T#j{-CO3hsY#wh`O!-B7S!i`>sWW+6AyXHwiPeH>u7t zXvxs5D$oz(?05iaR*KvNh^eei1Lq{6FdgMW>=f`Fpr^q|4h3ax5gaP^0Fgp3PZNnP zs@qehd8%8h)}=Eqte+HtY^f}bf%Q(m?@;t!5(S{OLzogdx4z!0&1Ex##O0N~fENDr zI7!RzFo7sbP{0T?>$Evx&cfwHQy)&r_cAv;aXp8*MW!ScIv3C4d0r=c4A?Z zqozwsbOQqs9G#~UrERex+gp+ukW|COZ*Q&;|A;*Ly`<|^q=2Jcn%~=?t+g>pw<&q5 zkoDJ(Hi=N%u@tWx7)0KihH`+HPFE~6dvP2M+#StF&|p3Z3&DA|#!H4mN|)SYk)i!# z2Kx&)gGq@a1Re>~I{?{PP9|mdL%=kKFM6LV_zBUSh*Nub2V;yx*|X+c@@Y=FKp6w! zM8ndhoNH9v)>7fX>K_{S%RZR$eMe<-@X4MA5RK+oVB1CSq`lgRI*{TjS4g1wDowLc z+SL_00ATUipW)WML0Dd2noK6=csELvGSXw_Pg_)U$-4DTEsM%m0T_?uX@mnypNDar zmAo`-4RQqa3lYp5Xv^TW&x^M{2;(7>TXk+NWCM7|a2p&sJjE zWE~f7&xCb^K^nc}5tg{VMb-UbCfzY|LFDFj-2hl8XkktG3piN|7#({4K6Y zN`A$P6tH&ou^}7QX=frA?drM_+B``T~NOHcA?K|(N2%i&lr57 zKO41W3+&B_!q%3EicG7LaL!v`FWPqMZaI{We(^m5G~g5O-B7%R6iJX%8FRh9E1b zYG37F!s=&fy`>BlIRH~wlpGEdeBcy;38ZocJjUD;zq*op@!>(A%_V|axZTzO;Z({dS@xkL-Xa$u!;~v=pK~KamvfU9Sa!x_tXD!QlzT(XCTu3RhU=fI z-$HUbYMJ!P=46H_Ol}nx1 zoo28l;u8BpI@=TudV)#SqF4V6m0;3$Sj2 z6JEO3VraTuzrFVrjhE29Yiq`l-SJ-40+Nkf8O-daXzp-mLa}lH*sNId@MdHLHhsAE zf%v=5g2`@ee;z;IdGxFYU@+MF^pGp5PDZQ1yOtA>_so)El#Lrn1I?wqGhIbBXjhV> zO~dn|07|)VU3Ta7dEUsvv|V83;Gk0Us0^ZD;YAMH$B=3O62L}g%E!`t+BAi`4_rAq z*tr?Of=Y492@RB{R@9cp{Y=dEs|mR=Jl1SU7QAvrO>O97kW*neA?1Yjv22#!k3%?E zg=tS=*>_gHg%5}2cAL}Zap(uFvCbZO*a<4#i-tmn@cPZ#ThCcmYeVGF^{PzNs^&w;!2FXIX-_^!#$t6SL(TLHPV9PPaB@P>BYrtb>7>ETuu&_ol!WoS54$a8x}=X|X4sK|j!nVj z-0WVb*6;j@Bo{H^_QfkR3%5!!nT5th9}!OG7=|$rmV5!o1K04E=J39#zCugYl|0nD zWZYZ8qcD9Qm+_plD3|s4ezD!D?PI4YJM(H0HT+L&?vHC$K1GjyrW|VeteI1esZlJ< z&)UMGE(^U1wS~BK7iY0Ou8C_nsU#xklb5Xq>>26$)EvWjN{W6Z^CGWY(4k_q$ar{CWbFR@ zJyCfD+})o7KF59r8-IP$bq9$GlU>uxowH86_o@ik2eFZ)wN``!j`yZwv2;JN~+tNf_i>?wu*W>@sji*j>;vi(l7&lD2A~WcZ&h{5>E4SIt5e zE9GViOIR;k2f|Elh1u)Gj-_zv`T!tKMXY4rVzAdjg@WLk`ZuarXRnA^`iUOhY4L&d z1HSS+!?;Q;Cfg9*!ZHRnhSPBFhrpdA{^EWY4 z*@pQ>FB1%q<`>||(WYd(r0kq&5tHIO$b9o6t4l-x+H0q4`H6}X;_HV$uyTo&1sDRA z$#u^=mE{+6i;b#$48%>AVkZ@zqU9gi_W`)@jr2_OH_UA1ajF!uPo-4|hc zCMY22aZPE;L=Yg{G047w0$>=SS#hQ!P{F!6O2V?LA{r2E0{-K#QUs4Gd`w)ZKOS=z z{nk=xAaqx6d9A9n%I1?}nB|Ry7A!S4lA~Oc$1<=;d1JQ52gHIzL}?$nN*F}8R<4hF zrt6M+*8f4VSej@TCfGo7aOu+_o8I|-m=Z%5zZ8DY7KqVUy;q50pUP%!PjG}~vysKQ zp9t8$9^!4_{=-1rLsLk|I(OZQ6V^e;sBU%X>+4TFyCzu#bVp+~M?{N-n8Xb8?tswr zy$catZZr#W_!`&pkpyRmdVlo$_tF7?XLV^&UU}b?EJp<5k)-LNDE->hWCHO?^rSPj z*7A`)Dp&mHm_1g@eE~B%AfnH}%r2&!y&x&>TyslBn6G)FJ%%m^fZSvHZL(`|N5@Tr(jlOTA-vT|rs`N^`8i(~=ld8P)=5n;>h?q!+2QOQpkOzP}-Xh1hS31Ox|?2{t>MpR7W4N#CU=sF=*VUH?pByt z%qHwJ7sMFA9ol19@SO0uVUYAbnYdDUvO^2$)sbh|$d6Q`f|dSW)Znc#m`c)FHXcdo z=x-t49|})_L;_Cgler9CQeJG;i14%_f%uK!C}@@l$k1*S!GfjjSDv~`a+u`aY^e$!HN8ecUl_G7;bl&_vrh*{ zLLM`( zl1+O;`d6{J*!SUeKpoTb>ER(#5$gl40-uSt&rCsJp1suZAcl9c7C3C3^llogjdXDx z^;`xCCa8jCiEY#MHR01wkg~iR#8|1e^KN>}{an^8tA( z#wlp<9#8KH__ahy7{`9;>X)Xk@Xru85vDR@S!yQLWS~AYPu=!gQ6=rV4=g29-9ACt zUK7!)0zinv=E+q?KI%XFhvkJ&*Zg0+650S*pVt~!=;W254j>9Jfj_8S&kwe)djcE6 z!U~aKC=c)B1#KDK<$G!gqTEeOPG5M)y2U3%lm_=O+kr^x*GkZ zzM}aHpA|0?wVvnNXNePv^+&|8r)~149Z)gjPVvbCj``V}#Lkcmo-C5rvL^Mgu0e z;2eJ=aM*`pnKjU}cRxztw3_y9<(OqZ-{kG*iJ_cJzjJSO*+Hhq=t z-jze;YqzwkhX7>I#9;K~WU2d8ffAh&{ycnfW1JPgn42Pch|~^oUr@-Rd!4YhMUE0p7wxHzA?KWvIgZ18 zuM1|zvmKYUfmQj!9!t~|X1nw|vR&C~@^N0T$!=<$Fmk~?2`;5Nx7s|wPb$^RBDTJ& z@U=JkxNb?1f{lJ|Nmip!p~_p87tB=lh}>$6310*@qWy;Av^0;J^6Ef@I>eLd(_kir z#Ok~a0Of9=VH@zN1fD@sjnDwZhNI|Qd`FDo)>MfqdJSSH8o_XVb2U><`kOeaL&i*# zgMqV4Y#eeXFOr39ic|yK+EdAx=KKUJ7z;%0@D0{aT%W|ZT}nyZ!w@*N;FM9^XRxe+ z?m^_m23R$z>EU*;O^Ci+V0s5Re~_NcG_WghnytlXd%CnABLrQk7LLyrg2v%w9NIp) zBm{t=_jcB2dymwC;^S{xW&y{C(b?3W**(%8)ETi?S^6o?e~*ZR5=NjxBxi{pXZnpp zX;#gw#8!KMhIk>_f2yd?@vKJME;elox$#Hpon@NVD@m%TJn@O1Kz-%F_3<|`y{`q_ z?QQ)|D!a1?lbDq=A84DTwy4HFP?qdl$9!%Nr-M@Eg0{;I3-M>_^Ag}p<68L^+dTCC zLNz}yFkN37LuS|VazP~H!!>9Xmzvmsp3qWZ$Yvvq1M&g!p))4ZXl2NK{?Oxl5vQAk z1d=}@5?CJu>8l|!JH47t3*J!O-th93Ar*71qV%c@pP5?M*kGeWw)A6jhg(+0Mh7(1 zbAtottrs`_D|DJdX)+mXA2VwM-o}!Ht;2;#IKCWjM@D31Dye*4V0Bxt5yc5;iL6;L zyNO9J`2V%%)}VQkLQ}0KmV@o;ejbz2y&M$4%`D2(eh&{~)nApvt$d*7X|k zWs#klb6S5@AVEyd)sC975E^uDLxi|&w*Fx$4eBd1zEY^Cj* z6s$T)H79SZ@2sS{r_@-BTAVaeNAkU2N)&rupI5VWXAaLhY9_wuAF z(u^uI_wpIWJ6{nMuEZ3J5lEM2Nq*+Wzf6qpS5?!6`=xidsN`od^EtjE$Ooe`ouHbR zfsosyk-SBT!;FfH7&agh$@!USEPdBVQ+R(w8cUI8H99;{H8(BXNqJxvI^tF~xEtPr z*S3g5IsB=>|0a`rdDeH){lm;I8>SVN+<}VS>=0HMVgew%ZKP770MKJs8#Cy^EMlV$@UdERD8QMwmvH8-|R>&n633$ zTK~uA>23`DA={P7O%>Y7va%z1QQ3fIXPz>#jO(ILt!7$+`e9B+YKXJdN$;*pl#q)N z-2-?n#9V(>ylnoX`(_1P!(#RNXNd1^Yske_)bhzWcGMpP-T(UDREz7hqxt)1wRmyS zt2bj2LfYzYO}l~w`nCIPHWm_Varx|G2Bd03+cLY*VgMGJQAh(S_IO!Xe4#=16UFpX z?(N0_S%p>W4X`hrWggHdOl&@OO*A{@du^6@EWTj4T$A?-^5M{;kj5z475Q+C^K`yh zfb+9A0v4-j%;W}SBonXcED&#(txq0i>N=-uqQyET^Q{*MRwfdbbO%5*&%qFkLIYZ< zJRk!(s!(dXnTR?Cj~dIuh&iy+#g7TjmPL23W?Aroz{$&vzE#=i$odw_mDIz}zh&W| zVbPKpEyiSmc3XWzs9TrnD-!Gr?jQ@a_mk7*KRFfzw$qp%InF=(J$|Yk)T&up@8|3rF5f;|@Vu^4uwi$};6A zJ-!}R6&|Q<@%9!}0*EIAy;jfg2(9l++6OIUGnhrtb?EfI-8Aid;j+Z2dnQ*2guFh7 znsTH3%YqvUizaz-1)d$7X7q*O>RHlDO?~lq^bllAXa`E$dJezO+!~E%Exe@{R(ML^ zfDpv=#&NB4^2?oUHRGDyH0dF~fOr2gy|*p#<5H?>NL7Gg5_)2DiF_#o<6sNhr@cqG z>5ia43^E84f|LnJEWdxjehM4zg4d}=*E-}oS9ac5(Ol_?xk3na+VbO#C&-1o8Bcf5NVw9q7nY*0wtwz#XjQ7qS5!*e8G(tpou z%If+Pv9%#m1JV*$&M~%-w^^HKQ`CqX7utQG1j$TyG?cn!s&fs$h3ngg`;NytHzti3 z6}FEUmCVsoUd8nNq4N{r6EO~Z4n;VI`0SPJI^-qG=u;XRt{F}0JUsIC*(uTX;C@4; zlE4ey$N^($uS~nI&oc(cCN<=j&U4osKO6CvC~CeyUgMw(bRTcTpYTk2Xn~k6(v)at zeX#W5SX!iMBG=Tq3y_4PW^IMv4Riockk=xq~h&@R080tPH_?$n^EX^oh?o zw<;ceYZ})7*C&S^69c&6MYp5)%R-6_jzLBcH~nSC*@m=3<^Ntn&p59GV9;%6(IY=H zufJOgJ4#baKPjvKDTezyEK}P8;Nn=i#K|3m(qF&C9tSUW)Z^)Yp0sfYNF7zpv@ZYX z!`~dd&=ugnE_^?B{zvBd4-&iv@XoXl^z;8T{RAl3ygpsB{T(>{UvvVz>w~DT|603$ zq2}RqkX>$d>!16*=?e$JyB^?q`8z6HuL?T1QId7%_oh8*#PD&+bN?BQ5YV|byK7(m z)AUs^;8)D={AV<_LFX1)5}y61>8Idb|G!Mcg_n$CMg{f&6F>pdaBQL&aL3j_XzFDn z?b6ec1QOxwdHKUT=lxM3Xlkyq)Z9dXiCuY77x2L;seYp`{lpwLR_9~^e+8)>|Mq4Y z0!Aqx)gQCjLSU|jk~-KjWZhWHrhFQU_qo zt<69HU!C@%>M!vMHpe*l;i_SBZ%0NJ{cymjAMuUnD5+Yo*}Mk*`0GOav99$=W$!^ zm22gox}^X%*-1IKl)IGj7L-PHfr76BP=F$9L*@)v?zThFL=XdA%n0v^HjgMpz}Eal zM0A?Dbp!myo`$OA^7%hJc(On%K95ocpnxnzbl}=Nve%o>+6d(7N_^7xu`a!3-)O~O ztZksoW3<5nF#DC~C;6{k(=Gv!NLR`QFY^om1IGLvLoB5G6-X;AzPj3bR2>z}pgKK< zt`H9efr^*@zX<;&JY;$zJKe{G>qI&bF#7a*)OGW@RXiPZhsmh|Wvep)XC!kV(Fv;Du4!w-A|0(RXd9OTLbj{*l?Evy48#lNBPq|dEq)oV zB!>5yJWbSu=O~N!_IjAfCs3Wz^KtQ@cn90*RJmIlF-}fEa=T>F1Z*u`mNaOE!F$@^ zT7^Ey*GYD+!4oJ~$8sr1Rf^Le5OGX!PpR9?qsFFP4|+Qytbf`sK(LvDmaa?&>n1ZY z0LgITU5k02UD@nSFnw4mA4i;cp?g=;~u9QjW=*yG+;gpqaKtsFg&FHnbh#8V& zwikiA#o>_fjjy_o1@^}N^6)jVE8}L;c)Gq++T5i!jogVe z6$J90%AU6N)9pp`a}73pJFO9?3$Qq^gSK;m*qNmNNPbou=d>FZtx*V|fRFmpD(32y zwfXs6mUWItv1o^I`7BDdPbm;AnBHUMDgY@lOXGr~B`A9X3YF|+hpmxB%RU(vDA%ON zWscGS5(Y-`n#Q0&WtB3PoR{zX`4N|7p=z4WFEbbnh)b#9mFMDGLT6lhq|E@-1Oy`% zKA-@K$dan#KB;g~f;iU(Fa#R7=#5xqZN;m%SZeV~3#jtX*~ z(-JO_L#yeUjlov(64W5aFF;mbj4#k@mwuSSu-s%OOKhEtOq$Uzd^jv!gEj2}i*Yh) z_!ouYqg)`7G{WFsKeY0HYR=72#K0TZm!o&Q{i0`)b+h$?Bec-0or(vkiZJ1kfK<#f zP%*T;q{P4<_y1*Xus#TfQ)^wBUipffz$j{;ul$9q%~%B#98Z5hWBg8oMC0tv(pbSF zzRR%R%<~VEd1UA00V8$*cs(gAxyR(+_(D!gFt6VO+{(}G9Be@59Rswv#V6Op_)EX~ zNs%g=@@oO)&5t#4qrUxQBj1n@CN09FXhHw3^M0@QZ-iQxdr z?(>13*%-(gnh52}v z(TxwgIJ}|569VanWaf9wW2^-r(H8{@E*Yy?BL)42571C$YKOn8K0raI0jgM1?ls!e zTkhpT?#OkmC~Hs{j@57m#lt=TLc>feBz8vb<4d62r0LQ?{N9egLo{3*}-ZR3DBQ9)aA*F61x^ODCKB#>@1j=HwTsv zSpmd?y+JpUWhF>O!6OM~u0=r1RtF`D7gkt-E$@YGcrH5h1Uro;1{$s8VsM};c>?{} zOiww@_d5$j3h!3?_z1G9f%^4)OBaCn2idV}qsb({4ZorI&%Zds2U0+S+kX#nfV1o$ zUx>6_@9@77$AH(2J7u!zzvR-;A-W7yf=Q;qf=8(t@siM>Y|x?UTe#ohzm3yGj5;*j*dV2W>4Bf~r?yn#F<5Y{E~2 z*Fl4NY5d#kFTbNBLne4{D9v3oE57qEAT)!S36Lgl_~E@CMtL_y;T0U~n;s1Yx%ww@ zyekHkyB2S0EIob5Kn3-jnDsP30JsvtE0T(686lw(cuA&17_PAO#sG**mxAgeT)miFhsW^D|kn?!!t7Td|0jhR-i_eK%HF^Z0=w0Erv95hw zw6#ekCN!Cumsjy&Persdx<>N*yil~j85J1cRt{L++iA@MN|Hwy99;-CyP~5ZvxS{A zy>Fu=G4&0JQZuJ;pjh>__$VklfIp!XqX87JN%?shK#ZqVx7T85pk28#SKST^8QTGN zvStu9s`Imy>3b1-O-K_etv$07%2Yr;hcxm=iHWb?%meBCTIVfT7(@t}Y2<`@1j*5@ zuKZIQF$cH^;QL3ueVt})19fG$5VlDN4*wGypY>1 zz2o&3Sn9^9J+O_p=0W5o4=o-G-&fSRdzqP;!;?-U8-A-h%!7~#&4-WN;E9;50PKi4 z51grhwUzdQNRm|L;xo}!BQ>Pt2x~K~5+0cYY+07sia4uQe8;9@N(VzBoe6Z{P}bK5 zLx}fD<-@ZQ_(4zG<`f4|`tOn4@hr_mOS@72X)jmEzrpit(Hvk*n2Of!a zoprQ<=o8>fAFts|*RSX~jGBJlHvY1{k|g6~9$FPd^pP{^1?tIOb>8j1BI_##v1zH9 zk<4V%LQT^1Mcvoxo4c2R9vJKIlq{gNn)!vYID{oor;E~~c%;xkvQs`c4=NGg5fK9x zht`4skLju`;X=4Hqg7=WZZ^oX)9w%dletKe8S;$~o{1S}`;s0)>0M}2QEwAoub^rs zo-)dOQiXOLGBvR63-aV9@<7OYN+Gng6Zl#?2ox62r$MPh3M+hLld2e~P0GivgL#}f zRYVI*0A*2ZCr2YWEK=w5a_^a`R|yD+yf6~4v|uYGSOj!GIBJV8h|cb&K3XII7vRrVv(S|Fmj^}6!KYbX^{q_2x-K3He$22?+ zwtgLE<38;kMkc}gU4*?=su7dCn}@w6s7ALXsKaK-buof7whsuabAV?h0>H_NFS!3! zB6~##pf!aPp@*t+so3y4(S|FcdgYGY|Hd*v+Vy2n)~(xFqfn3x>e&_|Cnp;5*$qae z)U3t?jOL8=5erbbOq!Yvex{`Dp3O4-M0wd*ai!O5dLizf>y1iz% za#w3vFw>-9;B44LyptACbC+gaOT7AlKxFujt2$UMhtSeXz0CR0w%)g=o*+6Xl!!aJjnB=7_3l%W2X#}=G{z< zFF||`?Zt;v1jUAjk8+6{NkdB+=6yBe=0sym$-m0(&Vz6!#qTNKPU{Ea4ggMMU<0bY z>7UuTglGZE1sWXilBax!Fa8S;sHX||0^ela`ZX@1XY{1$3C~OIRC)gmIJg0;vv&Df z_0A!n{W1&!BV{{_;s2!A{j?N2IwAZ&P1BUj%@y_0zXnpFYE?9>gHM9#KiU2Nd#^#~ zM|lyV^P>pRSM=m*z>BBAYzr zSWO_baX>qrX?)g_Kno)y*5Ca#@&EqyjSC#$fmykqH1Z~tqZP)=L^f@4$OE)IE!@C> zNP|69)+Nb>gu#O(^Uf}2c5mYExitu)eCXF`Wzx$rM=!LQzPG;(grX%hTArq<0J}0q z0%)*DTheFd!TBc5K(=d5+l)4b7T~(+FlGUF`gLGm-4^~b zIh4I$T=06U_!h)H%OLN<*aYvKZMFAcRlErb$$_e{h|KO|9yM)oNm792v9-cB3UOPG zKmh&@$Zd=PR0O?-H6?O^YFpQ7Ed|_@X;8aB)4QM@+D{R}w`M}NiKq%EC4YIF>1YL}cm;?cdeAOT(>+qqWK5}e7vZo^*6w}0Wkx}$*0HPHClVyEl=z&|>7z<&%F3IfIr2Ki?xEE8=Qjm^8r85&5HsjJB7VIznjLET(K;^u z#Eoh=DEvW8ibA0oh(O)|B%xq}%lR)&63C|h(ijNFFMW@a>H(S`@}oe?+L(E42oN1$ z5|$JxWyf#(iFtv;PJ93;MEoM0KGk3h5GKADRvr|IXC@ zpPi$964GSc^5wbt%=~oXfAp-+X$ux?qF(?KkYnou^DK;?9<}<-puav1@{PK*J62NX*WC2);iN4;+s^$yJj3x4~!t4eM1YcI^A@`&?QmOZM?9 z+TjdnPF0V#yXHx+Tz~ss^Ca+{H%pVFc6bFEyzrnHH2i#q9j5Us*l<-0j6^1eWPbWr zx2^U+Q}EAP68b-I3Z84_<&RiJRj9o@XEOy1J{lC1bwL|Ju|^>d+x5F-329%?8yo+$9KwoAL*Ho-U3BTPfEop=+99+#EQ}`Z~yMN7y9{^KL%f+g3x!7lOXsO0sQ9(~hO!4hQboZL!w?P&an)Ne$o_z_}%| zjvYDP^q4|*Ev-i++RooLwGvEa$ z#2u*FlEOW*fS`v6^70wecaiM;jsWw95kU~-1T>jGjdQ`dPP8)pbXUUNE5gQF4>^fN z){#)(!Fvos~El!TdK&TVLc z&|>=2A)WUcL_x=Ei4v$p3x8UH#t2+^qoZ8f!h$p^=h|xz4gz_IMS^<8JMuTI2dFDE zGtDFLWiq}ocx%m$3OvX4AyPaz@y!8>@h$>*o(TY#%}Loh>1hs|wDW7)W5a1~UuAV* z7eNc>N>T`UH_ z9t^lVzSWs)P}ku#0OYx)Pmf>Oy=MHgNLMYR?C-D`Q3mBC7;G?hup&KrSxvbH?#0#q zDK2EFF!iqHBevLlxlU}h^RBlF)ce~dB%V~-N1aZ(N&%Iqs2SXLpNl_0-pr$_81$Id z!Af5?A!XO1#50WY00h_CrV6K+({S+K-|7W zI;dSe50qRJwz_@U}VD&Y7TVGCnT?q_yLAoq)$qT%zW`kMn-+a_K(?ZCmm=sVrJ0tZFZAp4KHZn^@phb`g!(b=*40?Phf z7sgVC7`#ELW`40-5aF)(9D~c!v978htYV@Rq|T8Dw65Q6lLZ$(ybw|&>|&fYXp<3L z#cR-~0~GP0&>o{rP@D?RbLu9_sGIvd<7i#_pfMuiAW}^`vk>U3eJ1_Y26lPlVIZKd z{<;CmUVXxFjfdCqB8#(X1kInEyjq4l@UlE(ShKH4?P`azYu;dSyUfNzc0(>Wv}NA9 zAjJmY&tgUMFx&L1AoE6V+HqxGPDgz!&JN@y3!KWyqOm+242CZG7M1?JV$e0aO|FaS z{^VTQTOa5YK{=Z*VB}|F7e5Reik8C&uwZPLj_J1TdhAqK*(rUr zS}{ivmtr_Gt3=N$xnIiRMVPH7%zwGemC?%0%q=mvgZJKqWsO7AcC*QJ{H~mg@o)} zLa2`atqjVrmVbY_Z94=Wa1!RS4NA;Np0>Errc|ECk%p+Kh#Ld`)jBNqJgJEF zXczy^&AyC8@wRw~0aLF#YOB@L{s<#usCbx_Z)Eo&sLp98 zX;-L#HD42BRgHJpt=4!b)J?xWFFQBTQM_fgWL@1^vx3oCwj$~RyfArn{M}JhLC>1k2<@iH2Tx2*o#w;V|-5@zT{|D3N?lg-f! z7PQn2nT9AoV}sA78f%1H2L;zQO29rmo8JIObw6}gN6wUp&S^`A<+R4%{)3ltcJNiI zFIRt?5jZbq8IDAaK!V8DqQf{XPZ9B3T1^a)2LQZf*l~S~Q^yMnseRHLZH;^Q!hpP{Q6wZ|5 z{7hMX4l3Zol!vlMI}h{Dk6vR$$!Aaou;;eAA&qyAl#CMHlo)n;n1o9Us%;>&>lll* zKs$cdaQ5kfBZr@w+tCLZV+F3|b@I+Cjy(8Add-SD9gYp@TF&a6Y)jxb7joJ&@wjoX zq$ly7%e9Y3ae)~6wZQv=>QMNNm~v?86z**#*PLh+52sRY&-cpCJEUFumhoKRz$y7$ zpPmND;W%=>_J1|MSuow7JQNxq>98?JFhS3Mr|Q;&sfSy>2AF#lbO$K62yU6!LKznq+28(+3hhC7#AI9IyO5n6C;z&Nwd zS&)zZC-d`y_F2ZokH_a9PCJF_%l}FV_`vO9t5aAvC)VTZdUekJKm+THK&}_lN3KuW zOI)RjoMEE0x#vSh8_8c1Pf4D^fAxre_~Ab2CSA0K-Z3mY+k0S z)cfwEO&_3@G`Bk)SyS{l!7ugJ@2;z@DDVWn0rZt9SCwEEntUnE?qoEftv+zg`6HCh zKj!T*qDOu0Ms+OGX9~|s;cc8)zmZTrJ>!wVr@m542i^j)pbtG2j=Y{amveNLM3?QRCyvFl1TC3!H2X^Cuy#%JXRs%h zrGc@Kf9@fiNLaV*cTot{0^eGDo0etbyCMJFk=QJ6wj}1EGY6oAICMmY{!5~}!0K@d z!ikky*$>=UmWltrhZHjLerNS9%ZAz%5@NN_)N1lF>h?6E0RC^LK>a|dNQAY>aSUjO zgpLP2k6SWH-5RL5v;6YfZARKKxA*#=DHpriHN4ohHZ4pU1yfeFDDcU6)81D6trz5M z{<)ax8Rc_Ekn9MIyqp=lt-gKP@7*8_Pyu8Cy7Vj=JakMXxA1HDd zFW1Hl9a~nAyjH2#fM2!^bR~Q*V%cfhz(bYyj0aH+HXJmvIH=9Pr0OqTK-2}$TN|a= z**O|xAtzng02VpOIGah)z#z=<`^=5)i)Iqt`h2rl6DT7>@+wg{Gk%Za{TE=K(mS<{ zYvEb(l5>DXE>cAdv1VH(oYTGGpvEV^G%Cl(8_)i}H7YB7D*VRlhPjsm@^5f#O(B+^ z$3&3%wVcC7b&)t=yz6M`ZBR{a2C|-73N?mUD(&fyXO&leI0;;4UKn-n-F(nVyxTr7 z!K97(ZLpB_jn#jogN2UBq;Iqc8B}Y|aEliL_o-mj^PGnaW>CKy$jWkB!LzqM-`x#q zeg;88@d_L+7KCp=cITnyJ5&usEHjiw+ulHx?XMQdu7*&d0lAJ7=1xs~4!6sMJZ)NQ z43U9*g9;(IUqt$Jam$**u%ov9sdo75swPM@-dVnzJqJD^@r$N&j|JU(DFj|1estx1RweY&NFbXUBfo~ z&r3ct;aja^eWPsIExX2`Q}E*f?SzYT?9~TD1@k{%>i@HX%28T0WmwK^%8xwcvoGr7 z_ST(@2n*Y~Ejr4ei+|HoBpH}HrCCHau%h`42l@0$BPc#g>$4oc(>aZG6@&bC!GnA2nE8beQ)-)=k~XZMU|+W1`n*&exm=h zg<#anFa2SuG@vT1X$g-E>(e0eQVR{{)t`d!V5Tg_;B631s8--TR~q*u7%Wj$wi-?q zvx#K)KqsVnTyj32HOh?25_+ww)c4@gr8`O&1Q|m(s$nHN-#eL36<~#9IVtA zDYpQ6#~WIphi`KJ`|zr_@ze8NlWuPe9Md1~+x;-sBPhW5@Q{Fh0Kb8HZ+DcIrgre? zLDNLbprO-T0i@lAcTV$n?RR?BAeLUawnA9{htbYnkNNhICsQ64Pez`NR5GR{EzE}* zZnBK4Bkovt%E*j92)nvjphWepM5lXOF>AsAMH9++SnF$}m4#u4RAD{ay_ZX2B)UzN zaEDdAMH3|s>O#WCx-01vc-9+LZoUGV?R@4ZYlsAVo^1DbNH;y)A>FT<4nz-rgQR=S z3qBDSQdi%=Iq8c!nnYrrCvKwu=Gu)dxx3({_WfwD$l^mer3ewC9eYl7Z$RO2D^;bd z_C_rf-doQ+*rKQGCvnQnK4S2Y-{!-`W_m;-A(FnzDNI|ntGPCd-7FrvAkK2ZT?9SO zFxL^!vd4m2En_7dvNq7&n_dwpKwT?QCw6w@)zt!o8+vy82t<1Lk@2JK?>VA76Nm%0i1{Ty%~^3JoifW6s}jFpK5IVCyS)m$)plT3`1 z>wABLL8u#=z{C+-2MD#29<$ew|A=Dv=ytd*|Fl)P!h=TJ2cJFNN7F`?$zj4>HviSk3Nw9lv5@+3Q|HZ%QrLK?%g$P1DXT_C~A` z=}_6sBSY^avwprxNv(S8QLzXRydp79M21$l`;rWG5x=2XT*scg40mvjYI*Uss@Z;D zjJ69Qci}^BGXRf2kEJZ3ebA#QSEvTttH_cYc@`JPub_Sj0BA|a3)Y1Iwd6woKFTfgkUDwA_j*S>x_Opw}!urwzJhL-o$ zK1lKmx=`;62**E9E_!)$lajJB8#N+mVFV-(`agbbO3Rb9K6eF$7Ts0a^8ugqpcne9 z+mPhd-|x|TGlE1f*@`=tVXjhyxn_;rs+MD}H!!xB2Yu|1{Tl9tZ(jjJ?I@r}h%L_^ z5Ng~^<=e*o`z*=L0^?IQLhh|x21`v43Vb8H#&;Ra*hF`DWC6_ z^*euOax1K&a&d1AGi!HKe&gZATk%-Kd290TN*PDo#kO3R;D7)V=q@lTz3hS-=7Dz4 z1mCYSmCCCZW=q}MfNYgBtxLd9M}p7nP4lYH@nt(bTfi;f1UXlI4mSOXK1bGYRi5yD z`)IelGe*zYos3EZ_%7s$0ji9uRKCoG=ittM^%os|hW920=tyRGY7B94yCO4YE`c@! z?Db?2%kM0~|I^#q1wC`s*aPh2Hq5lKXs5x3!^*0vRj)P^>|`B2mFlNeJDt)+YrtXu z3!6q!-s4V|rD7ajb^@7H+6a6Nh(CN{!TYr(jzUfsWvi5}Kr?S!0 zmBdJThsrjXG2{XYmK>&AMGyv|it5bE0)dq2^38k(j3;qk?Qtr+JdfSX2Ie{7KV%_l z+jesCZS1uZid{2kv8xoal@nHe2W-*=%A7%2z4#QNBdqvw>2&^@mHWR^$!9D-eUk9ZN6eD{~iVorly;J@AH52Qfpgxo?o#Y&R4Q(R*Em3PFsq5`?mKdXtE7h4>jWmf21O@$Ap8|{-zP;s&K4}mvYqCNn z?ich&4?jn)2+Vy+F@RYem6eoUh~!Te8`>)T#pTehlc@Q+ZXQZPYawul3|Fa|V zLDj)^)h>!QI+C7bV z8Y+^fWaaq~`M1aHXjmN&&&q0d2r9R0~edy_N?gM}{vv=VLe8(|X72n_7wZ=$H+%d?#=Fo6aHe*j? zj&(e2X7YA5$Bsb?>7X%aZ*Xe+RXCGaR&K@gJkX#|1EAEm>gN^;SVR%!;t*!AF&1SX zxRlPF6P^2_h<3r+Mg7NpVqx4O=kj}752x2i9|Ry71TjR`g?>uM~hhS=0t+8 z-uATAA=~CxY@_w{p~T>>5@c5WdQb4U6GXF~XOXmeE& z%S8Y;D8qHaiV6p>+8-#gaLAL)M}`e$i_Q|P?HS`81cUm)p>wP>I?1s2M=-Q2NT1h{ zo<&}l6Vp9}M;v{hc3g0y@C3cdko`Eeo^f29)RbZ2hx|N+_59{1UVw4`yMVyJR=<3j zjNAB^P9K7MRf-L1&mxxN6!N~Q)#`DIrCZTj?eV=r$u$WC!?cFGx|%%7L&$P;%kW9k z@qX?t`0Hl3dB^x6Ui$koN8=cA@gHWY+!({Qh$VnF0A^il-D}J}Z8b54e;pEj45BI( znGlWbsnhf6?}V?8SaIwSh!Hm*=NGNKJa&?v0iT?t)sIoPRI;k990HU7-tw%hKgB4OdR%2Zr7RDDvJ~s;$6@K zoJJ)F)&#D9)I^Ou>gsLLW^737dEZ@SXh07=fSC; z@vne2L5J!-^L=V!{MgUfu$y(SVx{~-|V$* zmwXiRz=Ll5q;c)(7lW>i6LGKU>m?Ug>#r;N|GxompZvjF&-naTV$PmDE2++^uy88VGhP&l69(Oj zf{>0DxSi@&{|(nS0F?c9MQw0_%$CEj)<9O7g)sW0Na0G&`S+Kv_&E_5Q17xUE+qqC z#Tt94?)YuM<@kaTVC}QAKeTK}9E1R{(nBHxmjhOQn2HOOt=rgsA9Fb@P%32;J-yw_ z0qa>9s^HYsWy>a&lMVsK!F@{7<@Z7V1>FC~Z$Vj``CN#wEyQXMZH9d3!U%lWv|l|+ z&WR}3w>Ti(>2gKbHd*Q!z9bAtYqFrn#$bXs$?&9!Pc!}HjS+okw_Iv4G&n9KYB~;fwbI0coRz)&Kn7HnZKZIxAH)6>HQvDl}?49 zut>#u&P~J#k5Y*v>?z9UrKB<4VE8nA!u@;j%1|HfViHG|aKajHm|gGdg8u=*SK0LR z{yPxt`o-8NK5vAGNv zcvICwO9yC)&XhZhA`p4!3{zSa@?8L8vV4%Hw6`v>|4lw|#7OHOQ-g3EW_q9qvIHmF9Rpw#j3&9)9dGS*=R?Z^i z#~RCkY+z|yovwz+^eGNPp*0gU;)WpgSTNp}xESuyZfZ5>0=Rt;7`<1p#%NAj@w!Cj zp>x26q(jF`b7zUKEkH)+@h=+ItrRjc4;tS06=1h$s}%I})xqCbgy5V*5gfODhhEBj zMyrmzQew(s#CYjfBwvMY2AhMrsecmg!L}Q@V4Gh_((51{Csr_&vD=eG*ft&L?l-S{ z2jx94Y)Qzg6qY#H;x5yvPqS8PuhGEV1Bo_EwA~=nRJKFc`L0fJqaUarYevKC2UKJm z;q)rfDYOUqz;FuhXS~JNr-Tl*7qq{>{ORbYpLB)Hnwo&&m7>&9V>xe!lP7(K8Z!h; zds!)YUjqz)r?y*FuLks}iYO7Bv~qa!i*B$qLl*Px)3_C(_?P z_4$f~@JOeumv7KSC`pJF4uA&S$iCYSkX!u3+fz6>4%xchL(fqUmvqCpj2~p3<1w*Q zr@#QC8o0s;ED%u>u<{5oo;M&lO#))fbs+qqn)z^O4;;Rk{vQ$}(my6c@# z{jq*ClHT?~{=5gLr2WRH&p%&4<#|#?p5o)>%DXnYCzWe;wTdPAZAv)Ux49Scl;Aw$ z6U5=KODtz;wGM={r$i-uh5j6A$DpVv|O~br#@(i>g4XhEj@=kbDKnYxz z6c(;>VPhq;0P|qj79$A{*G>NW?$??sLXhxf>xXxuHIT9|ff;C5IURfVvr`Pxw5;YS z5rq}$pT<1yrO6?+1mo+d0bdK(h~sO8jc6ruuxUnlJ{gMGAJAmdQaRkup7ys=e^Xng zis1?SQ7^Vit>Cx|V?q_f^239T8K@>Ds)CQdq!F6;S?H5$1%fL;ys?8=sCUI~h0-`L zpHamJygB#)fb_%Wru0Ys`_wo7kq4VC;Bzl4G0L?DKqqANK+RSUHd1B84EsZ%3URyT zmr1{07$!X#H{aI1V?{#l^L7!!Wf7fVqpA923y}e66iBDy$NMw;9t0AN$JpT2U*3z^ z${p9U!+!t29XFqrK!>U{`OX%Qs#HA z;U?v&3uAisN4@NEeO;BammHm-;06@C_u2O1pm_aeaT(l{n6lN72Aiv*gkzJg#3Rwg60+JP(Q!#KbtSbKXF-N|HL%)@W^05SoKWOcG2)R@ z{bBRH5E!8S`kw%C;d>p{CD!n;?6rOV*|HVfQG6M0*%jIKGCO9SY zZGl|j%l%Jg!26{_8}b5r;1ytqIwjaMbOXK2gSnpgCchJFemJs-@a_Zyba`be_)jN% zI)7BBf{;Px3#eKkmFuCyhd&TB=-FdmyUHw*5B;=v@z(<^`>j8yD|_)M<-QoJIy?|l zGE!oyKZ^({YxkN9@h)-L`3bWZn?<HnhAkpTp7P%?{e+C@Z% zV5L6FQFe1RF30b8GSNNV5?szYekZ?KIkFwyF5B-@>B)^&!U% zx0$g<;kTY|lqWQ^6%lb1`U_Nl~-#oNkT?Qk`56tyrEd`AQ zsu)g?;P&eTj;OKJmrrmX1p|@zLo3ts-_-+$Q5kx3_L^<~vA$b;3|C-)Chu)$T0TIB z(cb5KczjBJBk?-a6BL99=O%Rj=PSnTRxAl vXS>8d*Y(dx%iV_;MOpc+jz5=A)}(Cs`0>GuqT2gcz`wH!s;5&=UiA4dBeUfl literal 0 HcmV?d00001 diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-paste.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-paste.test.ts index 3311dd5ea52..789c2d8a60c 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-paste.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-paste.test.ts @@ -16,8 +16,8 @@ afterEach(() => { editor = null }) -function mount(): Editor { - return new Editor({ extensions: [...createMarkdownContentExtensions(), MarkdownPaste] }) +function mount(editable = true): Editor { + return new Editor({ extensions: [...createMarkdownContentExtensions(), MarkdownPaste], editable }) } /** Run the plugin paste handlers the way ProseMirror would, with a mocked clipboard. */ @@ -93,4 +93,61 @@ describe('markdown paste', () => { expect(editor.isActive('codeBlock')).toBe(true) expect(paste(editor, '[link](https://example.com)')).toBe(false) }) + + it('rejects the paste entirely in a read-only editor', () => { + editor = mount(false) + expect(paste(editor, '# heading\n\n- one\n- two')).toBe(false) + expect(editor.getText()).toBe('') + }) + + it.each([ + ['empty string', ''], + ['whitespace only', ' \n\n '], + ['a bare thematic break (ambiguous — needs another markdown signal)', '---'], + ['inline-only italic (single asterisk would false-positive on e.g. *args)', 'an *italic* word'], + ['inline-only strikethrough', 'a ~~struck~~ word'], + ['inline-only code', 'some `code` here'], + ])('leaves %s to the default handler', (_label, text) => { + editor = mount() + expect(paste(editor, text)).toBe(false) + }) + + // Only structural / unambiguous constructs gate the markdown parse. Inline-only marks that + // `looksLikeMarkdown` deliberately omits to avoid false positives — single-asterisk italic + // (`*args`), `~~`, single-backtick code — are covered by the Markdown extension's own paste path, + // not MarkdownPaste, so they belong to a different test surface. + it.each([ + ['heading', '# Heading', 'heading'], + ['bold', 'a **bold** word', 'bold'], + ['bullet list', '- one\n- two', 'bulletList'], + ['ordered list', '1. one\n2. two', 'orderedList'], + ['task list', '- [x] done\n- [ ] todo', 'taskList'], + ['blockquote', '> a quote', 'blockquote'], + ['fenced code block', '```ts\nconst x = 1\n```', 'codeBlock'], + ['standalone image', '![alt](https://e.com/i.png)', 'image'], + ['thematic break within a document', '# Title\n\n---\n\nbody', 'horizontalRule'], + ])('renders pasted %s as rich content', (_label, md, nodeType) => { + editor = mount() + expect(paste(editor, md)).toBe(true) + expect(JSON.stringify(editor.getJSON())).toContain(`"type":"${nodeType}"`) + }) + + it('parses markdown-shaped plain text even when an HTML sibling is present', () => { + editor = mount() + const html = '

Title

  • a
  • b
' + expect(paste(editor, '# Title\n\n- a\n- b', html)).toBe(true) + const json = JSON.stringify(editor.getJSON()) + expect(json).toContain('"type":"heading"') + expect(json).toContain('"type":"bulletList"') + expect(json).not.toContain('# Title') + }) + + it('preserves the structural blocks of a multi-block document, in order, on paste', () => { + editor = mount() + expect(paste(editor, '# Title\n\nA paragraph.\n\n- a\n- b\n\n> quote')).toBe(true) + const structural = (editor.getJSON().content ?? []) + .map((node) => node.type) + .filter((type) => type !== 'paragraph') + expect(structural).toEqual(['heading', 'bulletList', 'blockquote']) + }) }) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css index 66a80d470f0..8c019c36f7c 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css @@ -380,6 +380,15 @@ pointer-events: none; } +/* + * prosemirror-tables' column-resizing plugin toggles the `resize-cursor` class on the editor root + * while the pointer is over a column boundary; without this rule the handle shows but the cursor + * never changes to the resize affordance. + */ +.rich-markdown-prose.resize-cursor { + cursor: col-resize; +} + .rich-markdown-prose p.is-editor-empty:first-child::before { content: attr(data-placeholder); color: var(--text-subtle); From 07a12249f53357b1dc12ad8b9a4f92f35b2e4954 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Mon, 6 Jul 2026 18:23:48 -0700 Subject: [PATCH 28/35] feat(pii): env-driven uvicorn worker count (PII_WORKERS) (#5457) * feat(pii): env-driven uvicorn worker count (PII_WORKERS) Launch the Presidio service with --workers from the PII_WORKERS env var so one image scales per task size (set PII_WORKERS = the task's vCPU count) without a rebuild. `sh -c exec` expands the var while keeping uvicorn as PID 1 for clean SIGTERM. Defaults to 1, so local/self-hosted is unchanged. Each worker loads the spaCy models independently (~3.3GB measured), so task memory must be sized to PII_WORKERS x ~3.3GB + overhead (set in infra alongside PII_WORKERS). * harden(pii): quote PII_WORKERS expansion + raise healthcheck start-period for multi-worker - Quote ${PII_WORKERS} so a malformed value fails uvicorn arg-parsing instead of being shell-interpreted (verified: '1; echo X' rejected as non-integer, X not run) - Bump HEALTHCHECK start-period 180s -> 300s: N workers load the spaCy models in parallel, stretching cold start beyond the single-worker case --- docker/pii.Dockerfile | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/docker/pii.Dockerfile b/docker/pii.Dockerfile index 73f56647909..29cb5185ea4 100644 --- a/docker/pii.Dockerfile +++ b/docker/pii.Dockerfile @@ -42,9 +42,16 @@ USER pii # 5001 avoids colliding with the app's 3000 in local/compose runs on one host. EXPOSE 5001 -# start-period is generous: five large spaCy models load at import before -# /health responds. Tune against measured cold-start once built. -HEALTHCHECK --interval=30s --timeout=5s --start-period=180s --retries=3 \ +# start-period covers the model cold start. With PII_WORKERS>1 each worker loads +# the five spaCy models independently and in parallel, so allow generous headroom +# (memory-bandwidth contention stretches the wall-time beyond the single-worker case). +HEALTHCHECK --interval=30s --timeout=5s --start-period=300s --retries=3 \ CMD curl -fsS http://localhost:5001/health || exit 1 -CMD ["uvicorn", "server:app", "--host", "0.0.0.0", "--port", "5001"] +# Worker count is env-driven so ONE image scales per task size: set PII_WORKERS to +# the task's vCPU count (each worker loads the models independently, ~3 GB each, so +# size task memory ≈ PII_WORKERS × 3 GB + overhead). Defaults to 1 for local/small. +# `sh -c exec` expands the env var while keeping uvicorn as PID 1 for clean SIGTERM. +# Quote the expansion so a malformed PII_WORKERS fails uvicorn arg-parsing rather +# than being interpreted by the shell. +CMD ["sh", "-c", "exec uvicorn server:app --host 0.0.0.0 --port 5001 --workers \"${PII_WORKERS:-1}\""] From 24ebba9acc085f9e1ab0ef1afe9a708746278999 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Mon, 6 Jul 2026 18:49:06 -0700 Subject: [PATCH 29/35] chore(credential-sets): cleanup feature (#5460) --- .claude/commands/add-trigger.md | 4 +- .claude/rules/sim-queries.md | 2 +- .claude/rules/sim-settings-pages.md | 4 +- .cursor/commands/add-trigger.md | 4 +- .../docs/content/docs/de/enterprise/index.mdx | 1 - apps/docs/content/docs/de/triggers/index.mdx | 4 - .../content/docs/en/integrations/index.mdx | 6 - .../docs/en/platform/enterprise/index.mdx | 1 - .../docs/content/docs/es/enterprise/index.mdx | 1 - apps/docs/content/docs/es/triggers/index.mdx | 4 - .../docs/content/docs/fr/enterprise/index.mdx | 1 - apps/docs/content/docs/fr/triggers/index.mdx | 4 - .../docs/content/docs/ja/enterprise/index.mdx | 1 - apps/docs/content/docs/ja/triggers/index.mdx | 26 - .../docs/content/docs/zh/enterprise/index.mdx | 1 - apps/docs/content/docs/zh/triggers/index.mdx | 4 - apps/sim/app/(auth)/signup/signup-form.tsx | 5 +- .../api/auth/oauth/disconnect/route.test.ts | 8 - .../app/api/auth/oauth/disconnect/route.ts | 46 +- apps/sim/app/api/auth/oauth/utils.ts | 90 +- .../[id]/invite/[invitationId]/route.ts | 183 - .../api/credential-sets/[id]/invite/route.ts | 306 - .../api/credential-sets/[id]/members/route.ts | 238 - .../sim/app/api/credential-sets/[id]/route.ts | 242 - .../api/credential-sets/invitations/route.ts | 54 - .../credential-sets/invite/[token]/route.ts | 253 - .../api/credential-sets/memberships/route.ts | 150 - apps/sim/app/api/credential-sets/route.ts | 216 - apps/sim/app/api/webhooks/[id]/route.ts | 75 +- apps/sim/app/api/webhooks/route.ts | 150 +- .../app/api/webhooks/trigger/[path]/route.ts | 9 +- .../credential-account/[token]/loading.tsx | 19 - .../app/credential-account/[token]/page.tsx | 275 - apps/sim/app/robots.ts | 1 - .../[workspaceId]/settings/[section]/page.tsx | 1 - .../settings/[section]/settings.tsx | 19 +- .../credential-sets/credential-sets.tsx | 818 - .../components/credential-sets/index.ts | 1 - .../settings-resource-row.tsx | 2 +- .../[workspaceId]/settings/navigation.ts | 15 - .../credential-selector.tsx | 139 +- apps/sim/blocks/types.ts | 2 - .../components/emails/invitations/index.ts | 1 - .../polling-group-invitation-email.tsx | 55 - apps/sim/components/emails/render.ts | 19 - apps/sim/components/emails/subjects.ts | 3 - apps/sim/executor/constants.ts | 12 - apps/sim/hooks/queries/credential-sets.ts | 318 - .../hooks/queries/oauth/oauth-credentials.ts | 34 +- .../queries/utils/fetch-credential-set.ts | 21 - apps/sim/hooks/use-webhook-management.ts | 1 - apps/sim/lib/api/contracts/credential-sets.ts | 344 - apps/sim/lib/api/contracts/index.ts | 1 - apps/sim/lib/api/contracts/webhooks.ts | 9 - apps/sim/lib/auth/auth.ts | 41 - .../sim/lib/billing/core/subscription.test.ts | 1 - apps/sim/lib/billing/core/subscription.ts | 107 - apps/sim/lib/billing/index.ts | 3 - .../sim/lib/compare/data/competitors/tines.ts | 2 +- apps/sim/lib/compare/data/feature-catalog.ts | 2 +- apps/sim/lib/core/config/env-flags.ts | 6 - apps/sim/lib/core/config/env.ts | 5 - .../core/security/input-validation.test.ts | 2 +- apps/sim/lib/core/telemetry.ts | 11 - apps/sim/lib/credential-sets/providers.ts | 27 - apps/sim/lib/webhooks/deploy.test.ts | 2 - apps/sim/lib/webhooks/deploy.ts | 226 +- apps/sim/lib/webhooks/polling/gmail.ts | 7 +- .../lib/webhooks/polling/google-calendar.ts | 7 +- apps/sim/lib/webhooks/polling/google-drive.ts | 7 +- .../sim/lib/webhooks/polling/google-sheets.ts | 7 +- apps/sim/lib/webhooks/polling/hubspot.ts | 2 +- apps/sim/lib/webhooks/polling/outlook.ts | 2 +- apps/sim/lib/webhooks/polling/utils.test.ts | 111 +- apps/sim/lib/webhooks/polling/utils.ts | 36 +- apps/sim/lib/webhooks/processor.test.ts | 4 +- apps/sim/lib/webhooks/processor.ts | 58 +- apps/sim/lib/webhooks/utils.server.ts | 371 +- .../comparison/format-description.test.ts | 5 - .../workflows/comparison/resolve-values.ts | 19 +- .../sim/lib/workflows/orchestration/deploy.ts | 7 +- .../fork/mapping/dependent-reconfigs.test.ts | 13 +- .../fork/mapping/dependent-reconfigs.ts | 3 +- .../fork/mapping/mapping-service.ts | 4 +- .../fork/remap/remap-references.test.ts | 45 - .../workspaces/fork/remap/remap-references.ts | 12 - apps/sim/triggers/gmail/poller.ts | 23 - apps/sim/triggers/google-calendar/poller.ts | 1 - apps/sim/triggers/google-drive/poller.ts | 1 - apps/sim/triggers/google-sheets/poller.ts | 1 - apps/sim/triggers/hubspot/poller.ts | 7 - apps/sim/triggers/outlook/poller.ts | 17 - helm/sim/values.yaml | 2 - packages/audit/src/types.ts | 12 - .../0255_remove_credential_sets.sql | 12 + .../db/migrations/meta/0255_snapshot.json | 16681 ++++++++++++++++ packages/db/migrations/meta/_journal.json | 7 + packages/db/schema.ts | 98 - packages/testing/src/mocks/audit.mock.ts | 10 - .../src/mocks/auth-oauth-utils.mock.ts | 2 - packages/testing/src/mocks/env-flags.mock.ts | 1 - packages/testing/src/mocks/schema.mock.ts | 35 - scripts/check-api-validation-contracts.ts | 1 - 103 files changed, 16881 insertions(+), 5388 deletions(-) delete mode 100644 apps/sim/app/api/credential-sets/[id]/invite/[invitationId]/route.ts delete mode 100644 apps/sim/app/api/credential-sets/[id]/invite/route.ts delete mode 100644 apps/sim/app/api/credential-sets/[id]/members/route.ts delete mode 100644 apps/sim/app/api/credential-sets/[id]/route.ts delete mode 100644 apps/sim/app/api/credential-sets/invitations/route.ts delete mode 100644 apps/sim/app/api/credential-sets/invite/[token]/route.ts delete mode 100644 apps/sim/app/api/credential-sets/memberships/route.ts delete mode 100644 apps/sim/app/api/credential-sets/route.ts delete mode 100644 apps/sim/app/credential-account/[token]/loading.tsx delete mode 100644 apps/sim/app/credential-account/[token]/page.tsx delete mode 100644 apps/sim/app/workspace/[workspaceId]/settings/components/credential-sets/credential-sets.tsx delete mode 100644 apps/sim/app/workspace/[workspaceId]/settings/components/credential-sets/index.ts delete mode 100644 apps/sim/components/emails/invitations/polling-group-invitation-email.tsx delete mode 100644 apps/sim/hooks/queries/credential-sets.ts delete mode 100644 apps/sim/hooks/queries/utils/fetch-credential-set.ts delete mode 100644 apps/sim/lib/api/contracts/credential-sets.ts delete mode 100644 apps/sim/lib/credential-sets/providers.ts create mode 100644 packages/db/migrations/0255_remove_credential_sets.sql create mode 100644 packages/db/migrations/meta/0255_snapshot.json diff --git a/.claude/commands/add-trigger.md b/.claude/commands/add-trigger.md index f5990517573..7e8df25695a 100644 --- a/.claude/commands/add-trigger.md +++ b/.claude/commands/add-trigger.md @@ -374,7 +374,7 @@ export const {service}PollingHandler: PollingProviderHandler = { try { // For OAuth services: - const accessToken = await resolveOAuthCredential(webhookData, '{service}', requestId, logger) + const accessToken = await resolveOAuthCredential(webhookData, '{service}', requestId) const config = webhookData.providerConfig as unknown as {Service}WebhookConfig // First poll: seed state, emit nothing @@ -421,7 +421,7 @@ export const {service}PollingTrigger: TriggerConfig = { polling: true, // REQUIRED — routes to polling infrastructure subBlocks: [ - { id: 'triggerCredentials', type: 'oauth-input', title: 'Credentials', serviceId: '{service}', requiredScopes: [], required: true, mode: 'trigger', supportsCredentialSets: true }, + { id: 'triggerCredentials', type: 'oauth-input', title: 'Credentials', serviceId: '{service}', requiredScopes: [], required: true, mode: 'trigger' }, // ... service-specific config fields (dropdowns, inputs, switches) ... { id: 'triggerInstructions', type: 'text', title: 'Setup Instructions', hideFromPreview: true, mode: 'trigger', defaultValue: '...' }, ], diff --git a/.claude/rules/sim-queries.md b/.claude/rules/sim-queries.md index 4eccbe2d7ef..14acca8f2cb 100644 --- a/.claude/rules/sim-queries.md +++ b/.claude/rules/sim-queries.md @@ -34,7 +34,7 @@ Next.js rewrites **every** export of a `'use client'` module into a *client refe So any **query-key factory, standalone `requestJson` fetcher, mapper, or constant** that a server module imports must live in a **non-`'use client'`** module: - key factories → `hooks/queries/utils/-keys.ts` (see `folder-keys.ts`, `table-keys.ts`, `credential-keys.ts`) -- standalone fetchers/mappers → `hooks/queries/utils/fetch-*.ts` / `*-list-query.ts` (see `fetch-workflow-envelope.ts`, `fetch-credential-set.ts`) +- standalone fetchers/mappers → `hooks/queries/utils/fetch-*.ts` / `*-list-query.ts` (see `fetch-workflow-envelope.ts`, `fetch-workspace-credentials.ts`) The `'use client'` hook module then imports these back for its hooks. **Never** define a server-imported factory/fetcher directly in a `'use client'` hooks file — it crashes SSR (this caused the tables-page crash). Enforced for prefetch/route/trigger/block files by `scripts/check-client-boundary-imports.ts` (`bun run check:client-boundary`, run in CI). Escape hatch for a genuinely browser-only path: `// client-boundary-allow: ` on the line above the import. diff --git a/.claude/rules/sim-settings-pages.md b/.claude/rules/sim-settings-pages.md index 38e9a364554..154b45a2599 100644 --- a/.claude/rules/sim-settings-pages.md +++ b/.claude/rules/sim-settings-pages.md @@ -117,7 +117,7 @@ pairing is: This is not a stylistic guess — it is the tokenized form of the literal-pixel pairing (`text-[14px] text-[var(--text-body)]` / `text-[12px] text-[var(--text-muted)]`) already used for this exact row shape across -`member-list.tsx`, `api-keys.tsx`, `mcp.tsx`, `billing.tsx`, `credential-sets.tsx`, +`member-list.tsx`, `api-keys.tsx`, `mcp.tsx`, `billing.tsx`, `workflow-mcp-servers.tsx`, and others — keep new rows consistent with it rather than inventing a new size pairing. @@ -203,7 +203,7 @@ changes" modal: ## Detail sub-views A drill-down view reached from a list row (selected MCP server, workflow MCP -server, credential set, permission group, retention policy) renders through +server, permission group, retention policy) renders through `SettingsPanel` like a list page: pass `back={{ text, icon: ArrowLeft, onSelect }}` for the left back chip, `title` (the entity name), and the header `actions`, then render the body. Do NOT hand-roll a shell or header bar; a tab bar renders as the diff --git a/.cursor/commands/add-trigger.md b/.cursor/commands/add-trigger.md index 6e1e6ed975f..bcf54ec6587 100644 --- a/.cursor/commands/add-trigger.md +++ b/.cursor/commands/add-trigger.md @@ -369,7 +369,7 @@ export const {service}PollingHandler: PollingProviderHandler = { try { // For OAuth services: - const accessToken = await resolveOAuthCredential(webhookData, '{service}', requestId, logger) + const accessToken = await resolveOAuthCredential(webhookData, '{service}', requestId) const config = webhookData.providerConfig as unknown as {Service}WebhookConfig // First poll: seed state, emit nothing @@ -416,7 +416,7 @@ export const {service}PollingTrigger: TriggerConfig = { polling: true, // REQUIRED — routes to polling infrastructure subBlocks: [ - { id: 'triggerCredentials', type: 'oauth-input', title: 'Credentials', serviceId: '{service}', requiredScopes: [], required: true, mode: 'trigger', supportsCredentialSets: true }, + { id: 'triggerCredentials', type: 'oauth-input', title: 'Credentials', serviceId: '{service}', requiredScopes: [], required: true, mode: 'trigger' }, // ... service-specific config fields (dropdowns, inputs, switches) ... { id: 'triggerInstructions', type: 'text', title: 'Setup Instructions', hideFromPreview: true, mode: 'trigger', defaultValue: '...' }, ], diff --git a/apps/docs/content/docs/de/enterprise/index.mdx b/apps/docs/content/docs/de/enterprise/index.mdx index 02ee74cdb34..c217e859e0e 100644 --- a/apps/docs/content/docs/de/enterprise/index.mdx +++ b/apps/docs/content/docs/de/enterprise/index.mdx @@ -79,7 +79,6 @@ Für selbst gehostete Bereitstellungen können Enterprise-Funktionen über Umgeb | Variable | Beschreibung | |----------|-------------| | `SSO_ENABLED`, `NEXT_PUBLIC_SSO_ENABLED` | Single Sign-On mit SAML/OIDC | -| `CREDENTIAL_SETS_ENABLED`, `NEXT_PUBLIC_CREDENTIAL_SETS_ENABLED` | Polling-Gruppen für E-Mail-Trigger | | `DISABLE_INVITATIONS`, `NEXT_PUBLIC_DISABLE_INVITATIONS` | Workspace-/Organisations-Einladungen global deaktivieren | diff --git a/apps/docs/content/docs/de/triggers/index.mdx b/apps/docs/content/docs/de/triggers/index.mdx index 23b51adf279..48e5a9c7412 100644 --- a/apps/docs/content/docs/de/triggers/index.mdx +++ b/apps/docs/content/docs/de/triggers/index.mdx @@ -33,9 +33,6 @@ Verwende den Start-Block für alles, was aus dem Editor, deploy-to-API oder depl RSS- und Atom-Feeds auf neue Inhalte überwachen - - Team-Gmail- und Outlook-Postfächer überwachen - ## Schneller Vergleich @@ -46,7 +43,6 @@ Verwende den Start-Block für alles, was aus dem Editor, deploy-to-API oder depl | **Schedule** | Timer, der im Schedule-Block verwaltet wird | | **Webhook** | Bei eingehender HTTP-Anfrage | | **RSS Feed** | Neues Element im Feed veröffentlicht | -| **Email Polling Groups** | Neue E-Mail in Team-Gmail- oder Outlook-Postfächern empfangen | > Der Start-Block stellt immer `input`, `conversationId` und `files` Felder bereit. Füge benutzerdefinierte Felder zum Eingabeformat für zusätzliche strukturierte Daten hinzu. diff --git a/apps/docs/content/docs/en/integrations/index.mdx b/apps/docs/content/docs/en/integrations/index.mdx index 79c68b05011..bc65ecb4a0f 100644 --- a/apps/docs/content/docs/en/integrations/index.mdx +++ b/apps/docs/content/docs/en/integrations/index.mdx @@ -110,12 +110,6 @@ Open a connection from the **Connected** list to manage it: If you disconnect an integration that is used in a workflow, that workflow will fail at any block referencing it. Update blocks before disconnecting. -## Email polling groups - -The Gmail and Outlook email triggers can watch several team members' inboxes through a single trigger, called an **email polling group** (Team and Enterprise plans). An admin creates a group under **Settings → Email Polling**, picks Gmail or Outlook, and invites members by email; each invitee connects their own inbox through a link. On an email trigger, select the group from the credentials dropdown instead of a single account, and the trigger routes everyone's mail through the workflow. - -Inviting someone to a group grants inbox access for that trigger only, which is separate from workspace membership. - diff --git a/apps/docs/content/docs/es/triggers/index.mdx b/apps/docs/content/docs/es/triggers/index.mdx index 94fcb23e76c..730cdbcc803 100644 --- a/apps/docs/content/docs/es/triggers/index.mdx +++ b/apps/docs/content/docs/es/triggers/index.mdx @@ -33,9 +33,6 @@ Utiliza el bloque Start para todo lo que se origina desde el editor, despliegue Monitorea feeds RSS y Atom para detectar contenido nuevo - - Monitorea bandejas de entrada de Gmail y Outlook del equipo - ## Comparación rápida @@ -46,7 +43,6 @@ Utiliza el bloque Start para todo lo que se origina desde el editor, despliegue | **Schedule** | Temporizador gestionado en el bloque de programación | | **Webhook** | Al recibir una solicitud HTTP entrante | | **RSS Feed** | Nuevo elemento publicado en el feed | -| **Email Polling Groups** | Nuevo correo electrónico recibido en bandejas de entrada de Gmail o Outlook del equipo | > El bloque Start siempre expone los campos `input`, `conversationId` y `files`. Añade campos personalizados al formato de entrada para datos estructurados adicionales. diff --git a/apps/docs/content/docs/fr/enterprise/index.mdx b/apps/docs/content/docs/fr/enterprise/index.mdx index 4af1489fda0..f7546d66af4 100644 --- a/apps/docs/content/docs/fr/enterprise/index.mdx +++ b/apps/docs/content/docs/fr/enterprise/index.mdx @@ -79,7 +79,6 @@ Pour les déploiements auto-hébergés, les fonctionnalités entreprise peuvent | Variable | Description | |----------|-------------| | `SSO_ENABLED`, `NEXT_PUBLIC_SSO_ENABLED` | Authentification unique avec SAML/OIDC | -| `CREDENTIAL_SETS_ENABLED`, `NEXT_PUBLIC_CREDENTIAL_SETS_ENABLED` | Groupes de sondage pour les déclencheurs d'e-mail | | `DISABLE_INVITATIONS`, `NEXT_PUBLIC_DISABLE_INVITATIONS` | Désactiver globalement les invitations aux espaces de travail/organisations | diff --git a/apps/docs/content/docs/fr/triggers/index.mdx b/apps/docs/content/docs/fr/triggers/index.mdx index 2102b482f7e..ef9b408df06 100644 --- a/apps/docs/content/docs/fr/triggers/index.mdx +++ b/apps/docs/content/docs/fr/triggers/index.mdx @@ -33,9 +33,6 @@ Utilisez le bloc Démarrer pour tout ce qui provient de l'éditeur, du déploiem Surveiller les flux RSS et Atom pour détecter du nouveau contenu - - Surveiller les boîtes de réception Gmail et Outlook de l'équipe - ## Comparaison rapide @@ -46,7 +43,6 @@ Utilisez le bloc Démarrer pour tout ce qui provient de l'éditeur, du déploiem | **Schedule** | Minuteur géré dans le bloc de planification | | **Webhook** | Lors d'une requête HTTP entrante | | **RSS Feed** | Nouvel élément publié dans le flux | -| **Email Polling Groups** | Nouvel e-mail reçu dans les boîtes de réception Gmail ou Outlook de l'équipe | > Le bloc Démarrer expose toujours les champs `input`, `conversationId` et `files`. Ajoutez des champs personnalisés au format d'entrée pour des données structurées supplémentaires. diff --git a/apps/docs/content/docs/ja/enterprise/index.mdx b/apps/docs/content/docs/ja/enterprise/index.mdx index b769395011d..473697ad064 100644 --- a/apps/docs/content/docs/ja/enterprise/index.mdx +++ b/apps/docs/content/docs/ja/enterprise/index.mdx @@ -78,7 +78,6 @@ Simのホストキーの代わりに、AIモデルプロバイダー用の独自 | 変数 | 説明 | |----------|-------------| | `SSO_ENABLED`、`NEXT_PUBLIC_SSO_ENABLED` | SAML/OIDCによるシングルサインオン | -| `CREDENTIAL_SETS_ENABLED`、`NEXT_PUBLIC_CREDENTIAL_SETS_ENABLED` | メールトリガー用のポーリンググループ | | `DISABLE_INVITATIONS`、`NEXT_PUBLIC_DISABLE_INVITATIONS` | ワークスペース/組織への招待をグローバルに無効化 | diff --git a/apps/docs/content/docs/ja/triggers/index.mdx b/apps/docs/content/docs/ja/triggers/index.mdx index 9095371cf32..7bf66bd07c9 100644 --- a/apps/docs/content/docs/ja/triggers/index.mdx +++ b/apps/docs/content/docs/ja/triggers/index.mdx @@ -33,9 +33,6 @@ import { Image } from '@/components/ui/image' RSSおよびAtomフィードの新しいコンテンツを監視 - - チームのGmailおよびOutlook受信トレイを監視 - ## クイック比較 @@ -46,7 +43,6 @@ import { Image } from '@/components/ui/image' | **Schedule** | スケジュールブロックで管理されるタイマー | | **Webhook** | インバウンドHTTPリクエスト時 | | **RSS Feed** | フィードに新しいアイテムが公開された時 | -| **Email Polling Groups** | チームのGmailまたはOutlook受信トレイに新しいメールが受信された時 | > スタートブロックは常に `input`、`conversationId`、および `files` フィールドを公開します。追加の構造化データには入力フォーマットにカスタムフィールドを追加してください。 @@ -69,25 +65,3 @@ import { Image } from '@/components/ui/image' ワークフローに複数のトリガーがある場合、最も優先度の高いトリガーが実行されます。例えば、スタートブロックとウェブフックトリガーの両方がある場合、実行をクリックするとスタートブロックが実行されます。 **モックペイロードを持つ外部トリガー**: 外部トリガー(ウェブフックと連携)が手動で実行される場合、Simはトリガーの予想されるデータ構造に基づいてモックペイロードを自動生成します。これにより、テスト中に下流のブロックが変数を正しく解決できるようになります。 - -## Email Polling Groups - -Polling Groupsを使用すると、単一のトリガーで複数のチームメンバーのGmailまたはOutlook受信トレイを監視できます。TeamまたはEnterpriseプランが必要です。 - -**Polling Groupの作成**(管理者/オーナー) - -1. **設定 → Email Polling**に移動 -2. **作成**をクリックし、GmailまたはOutlookを選択 -3. グループの名前を入力 - -**メンバーの招待** - -1. Polling Groupの**メンバーを追加**をクリック -2. メールアドレスを入力(カンマまたは改行で区切る、またはCSVをドラッグ&ドロップ) -3. **招待を送信**をクリック - -招待された人は、アカウントを接続するためのリンクが記載されたメールを受信します。接続されると、その受信トレイは自動的にPolling Groupに含まれます。招待された人は、Sim組織のメンバーである必要はありません。 - -**ワークフローでの使用** - -メールトリガーを設定する際、個別のアカウントではなく、認証情報ドロップダウンからPolling Groupを選択します。システムは各メンバーのWebhookを作成し、すべてのメールをワークフローを通じてルーティングします。 diff --git a/apps/docs/content/docs/zh/enterprise/index.mdx b/apps/docs/content/docs/zh/enterprise/index.mdx index 25c7d8da7ed..2eb7f2bc382 100644 --- a/apps/docs/content/docs/zh/enterprise/index.mdx +++ b/apps/docs/content/docs/zh/enterprise/index.mdx @@ -78,7 +78,6 @@ Sim 企业版为需要更高安全性、合规性和管理能力的组织提供 | 变量 | 描述 | |----------|-------------| | `SSO_ENABLED`,`NEXT_PUBLIC_SSO_ENABLED` | 使用 SAML/OIDC 的单点登录 | -| `CREDENTIAL_SETS_ENABLED`,`NEXT_PUBLIC_CREDENTIAL_SETS_ENABLED` | 用于邮件触发器的轮询组 | | `DISABLE_INVITATIONS`,`NEXT_PUBLIC_DISABLE_INVITATIONS` | 全局禁用工作区/组织邀请 | diff --git a/apps/docs/content/docs/zh/triggers/index.mdx b/apps/docs/content/docs/zh/triggers/index.mdx index a17b11dd325..bdbf48cc1b4 100644 --- a/apps/docs/content/docs/zh/triggers/index.mdx +++ b/apps/docs/content/docs/zh/triggers/index.mdx @@ -33,9 +33,6 @@ import { Image } from '@/components/ui/image' 监控 RSS 和 Atom 订阅源的新内容 - - 监控团队 Gmail 和 Outlook 收件箱 - ## 快速对比 @@ -46,7 +43,6 @@ import { Image } from '@/components/ui/image' | **Schedule** | 在 schedule 块中管理的定时器 | | **Webhook** | 收到入站 HTTP 请求时 | | **RSS Feed** | 订阅源中有新内容发布时 | -| **Email Polling Groups** | 团队 Gmail 或 Outlook 收件箱收到新邮件时 | > Start 块始终公开 `input`、`conversationId` 和 `files` 字段。通过向输入格式添加自定义字段来增加结构化数据。 diff --git a/apps/sim/app/(auth)/signup/signup-form.tsx b/apps/sim/app/(auth)/signup/signup-form.tsx index 5553cfa6fe9..e7aa86ea1f9 100644 --- a/apps/sim/app/(auth)/signup/signup-form.tsx +++ b/apps/sim/app/(auth)/signup/signup-form.tsx @@ -121,10 +121,7 @@ function SignupFormContent({ } const redirectUrl = isValidRedirectUrl ? rawRedirectUrl : '' const isInviteFlow = useMemo( - () => - searchParams.get('invite_flow') === 'true' || - redirectUrl.startsWith('/invite/') || - redirectUrl.startsWith('/credential-account/'), + () => searchParams.get('invite_flow') === 'true' || redirectUrl.startsWith('/invite/'), [searchParams, redirectUrl] ) diff --git a/apps/sim/app/api/auth/oauth/disconnect/route.test.ts b/apps/sim/app/api/auth/oauth/disconnect/route.test.ts index 3681ac5cc36..97215a0d923 100644 --- a/apps/sim/app/api/auth/oauth/disconnect/route.test.ts +++ b/apps/sim/app/api/auth/oauth/disconnect/route.test.ts @@ -13,16 +13,8 @@ import { } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockSyncAllWebhooksForCredentialSet } = vi.hoisted(() => ({ - mockSyncAllWebhooksForCredentialSet: vi.fn().mockResolvedValue({}), -})) - vi.mock('@sim/db', () => dbChainMock) -vi.mock('@/lib/webhooks/utils.server', () => ({ - syncAllWebhooksForCredentialSet: mockSyncAllWebhooksForCredentialSet, -})) - vi.mock('@sim/audit', () => auditMock) import { POST } from '@/app/api/auth/oauth/disconnect/route' diff --git a/apps/sim/app/api/auth/oauth/disconnect/route.ts b/apps/sim/app/api/auth/oauth/disconnect/route.ts index 101cbed9e44..44840b2d568 100644 --- a/apps/sim/app/api/auth/oauth/disconnect/route.ts +++ b/apps/sim/app/api/auth/oauth/disconnect/route.ts @@ -1,6 +1,6 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' -import { account, credential, credentialSet, credentialSetMember } from '@sim/db/schema' +import { account, credential } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq, inArray, like, or } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' @@ -11,7 +11,6 @@ import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { deleteCredential } from '@/lib/credentials/deletion' import { captureServerEvent } from '@/lib/posthog/server' -import { syncAllWebhooksForCredentialSet } from '@/lib/webhooks/utils.server' export const dynamic = 'force-dynamic' @@ -104,49 +103,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { await db.delete(account).where(inArray(account.id, targetAccountIds)) } - // Sync webhooks for all credential sets the user is a member of - // This removes webhooks that were using the disconnected credential - const userMemberships = await db - .select({ - id: credentialSetMember.id, - credentialSetId: credentialSetMember.credentialSetId, - providerId: credentialSet.providerId, - }) - .from(credentialSetMember) - .innerJoin(credentialSet, eq(credentialSetMember.credentialSetId, credentialSet.id)) - .where( - and( - eq(credentialSetMember.userId, session.user.id), - eq(credentialSetMember.status, 'active') - ) - ) - - for (const membership of userMemberships) { - // Only sync if the credential set matches this provider - // Credential sets store OAuth provider IDs like 'google-email' or 'outlook' - const matchesProvider = - membership.providerId === provider || - membership.providerId === providerId || - membership.providerId?.startsWith(`${provider}-`) - - if (matchesProvider) { - try { - await syncAllWebhooksForCredentialSet(membership.credentialSetId, requestId) - logger.info(`[${requestId}] Synced webhooks after credential disconnect`, { - credentialSetId: membership.credentialSetId, - provider, - }) - } catch (error) { - // Log but don't fail the disconnect - credential is already removed - logger.error(`[${requestId}] Failed to sync webhooks after credential disconnect`, { - credentialSetId: membership.credentialSetId, - provider, - error, - }) - } - } - } - recordAudit({ workspaceId: null, actorId: session.user.id, diff --git a/apps/sim/app/api/auth/oauth/utils.ts b/apps/sim/app/api/auth/oauth/utils.ts index 732f157d581..041a6a2ce53 100644 --- a/apps/sim/app/api/auth/oauth/utils.ts +++ b/apps/sim/app/api/auth/oauth/utils.ts @@ -1,9 +1,9 @@ import { createSign } from 'crypto' import { db } from '@sim/db' -import { account, credential, credentialSetMember } from '@sim/db/schema' +import { account, credential } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' -import { and, desc, eq, inArray } from 'drizzle-orm' +import { and, desc, eq } from 'drizzle-orm' import { withLeaderLock } from '@/lib/concurrency/leader-lock' import { coalesceLocally } from '@/lib/concurrency/singleflight' import { decryptSecret } from '@/lib/core/security/encryption' @@ -639,89 +639,3 @@ export async function refreshTokenIfNeeded( } throw new Error('Failed to refresh token') } - -export interface CredentialSetCredential { - userId: string - credentialId: string - accessToken: string - providerId: string -} - -export async function getCredentialsForCredentialSet( - credentialSetId: string, - providerId: string -): Promise { - logger.info(`Getting credentials for credential set ${credentialSetId}, provider ${providerId}`) - - const members = await db - .select({ userId: credentialSetMember.userId }) - .from(credentialSetMember) - .where( - and( - eq(credentialSetMember.credentialSetId, credentialSetId), - eq(credentialSetMember.status, 'active') - ) - ) - - logger.info(`Found ${members.length} active members in credential set ${credentialSetId}`) - - if (members.length === 0) { - logger.warn(`No active members found for credential set ${credentialSetId}`) - return [] - } - - const userIds = members.map((m) => m.userId) - logger.debug(`Member user IDs: ${userIds.join(', ')}`) - - const credentials = await db - .select({ - id: account.id, - userId: account.userId, - providerId: account.providerId, - accessToken: account.accessToken, - refreshToken: account.refreshToken, - accessTokenExpiresAt: account.accessTokenExpiresAt, - }) - .from(account) - .where(and(inArray(account.userId, userIds), eq(account.providerId, providerId))) - - logger.info( - `Found ${credentials.length} credentials with provider ${providerId} for ${members.length} members` - ) - - const results: CredentialSetCredential[] = [] - - for (const cred of credentials) { - const now = new Date() - const tokenExpiry = cred.accessTokenExpiresAt - const shouldRefresh = - !!cred.refreshToken && (!cred.accessToken || (tokenExpiry && tokenExpiry < now)) - - let accessToken = cred.accessToken - - if (shouldRefresh && cred.refreshToken) { - const fresh = await performCoalescedRefresh({ - accountId: cred.id, - providerId, - refreshToken: cred.refreshToken, - userId: cred.userId, - }) - if (fresh) accessToken = fresh - } - - if (accessToken) { - results.push({ - userId: cred.userId, - credentialId: cred.id, - accessToken, - providerId, - }) - } - } - - logger.info( - `Found ${results.length} valid credentials for credential set ${credentialSetId}, provider ${providerId}` - ) - - return results -} diff --git a/apps/sim/app/api/credential-sets/[id]/invite/[invitationId]/route.ts b/apps/sim/app/api/credential-sets/[id]/invite/[invitationId]/route.ts deleted file mode 100644 index cdd1fc4b9f5..00000000000 --- a/apps/sim/app/api/credential-sets/[id]/invite/[invitationId]/route.ts +++ /dev/null @@ -1,183 +0,0 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' -import { db } from '@sim/db' -import { credentialSet, credentialSetInvitation, member, organization, user } from '@sim/db/schema' -import { createLogger } from '@sim/logger' -import { and, eq } from 'drizzle-orm' -import { type NextRequest, NextResponse } from 'next/server' -import { getEmailSubject, renderPollingGroupInvitationEmail } from '@/components/emails' -import { credentialSetInvitationParamsSchema } from '@/lib/api/contracts/credential-sets' -import { getSession } from '@/lib/auth' -import { hasCredentialSetsAccess } from '@/lib/billing' -import { getBaseUrl } from '@/lib/core/utils/urls' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { sendEmail } from '@/lib/messaging/email/mailer' - -const logger = createLogger('CredentialSetInviteResend') - -async function getCredentialSetWithAccess(credentialSetId: string, userId: string) { - const [set] = await db - .select({ - id: credentialSet.id, - organizationId: credentialSet.organizationId, - name: credentialSet.name, - providerId: credentialSet.providerId, - }) - .from(credentialSet) - .where(eq(credentialSet.id, credentialSetId)) - .limit(1) - - if (!set) return null - - const [membership] = await db - .select({ role: member.role }) - .from(member) - .where(and(eq(member.userId, userId), eq(member.organizationId, set.organizationId))) - .limit(1) - - if (!membership) return null - - return { set, role: membership.role } -} - -export const POST = withRouteHandler( - async ( - req: NextRequest, - { params }: { params: Promise<{ id: string; invitationId: string }> } - ) => { - const session = await getSession() - - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - // Check plan access (team/enterprise) or env var override - const hasAccess = await hasCredentialSetsAccess(session.user.id) - if (!hasAccess) { - return NextResponse.json( - { error: 'Credential sets require a Team or Enterprise plan' }, - { status: 403 } - ) - } - - const { id, invitationId } = credentialSetInvitationParamsSchema.parse(await params) - - try { - const result = await getCredentialSetWithAccess(id, session.user.id) - - if (!result) { - return NextResponse.json({ error: 'Credential set not found' }, { status: 404 }) - } - - if (result.role !== 'admin' && result.role !== 'owner') { - return NextResponse.json({ error: 'Admin or owner permissions required' }, { status: 403 }) - } - - const [invitation] = await db - .select() - .from(credentialSetInvitation) - .where( - and( - eq(credentialSetInvitation.id, invitationId), - eq(credentialSetInvitation.credentialSetId, id) - ) - ) - .limit(1) - - if (!invitation) { - return NextResponse.json({ error: 'Invitation not found' }, { status: 404 }) - } - - if (invitation.status !== 'pending') { - return NextResponse.json( - { error: 'Only pending invitations can be resent' }, - { status: 400 } - ) - } - - // Update expiration - const newExpiresAt = new Date() - newExpiresAt.setDate(newExpiresAt.getDate() + 7) - - await db - .update(credentialSetInvitation) - .set({ expiresAt: newExpiresAt }) - .where(eq(credentialSetInvitation.id, invitationId)) - - const inviteUrl = `${getBaseUrl()}/credential-account/${invitation.token}` - - // Send email if email address exists - if (invitation.email) { - try { - const [inviter] = await db - .select({ name: user.name }) - .from(user) - .where(eq(user.id, session.user.id)) - .limit(1) - - const [org] = await db - .select({ name: organization.name }) - .from(organization) - .where(eq(organization.id, result.set.organizationId)) - .limit(1) - - const provider = (result.set.providerId as 'google-email' | 'outlook') || 'google-email' - const emailHtml = await renderPollingGroupInvitationEmail({ - inviterName: inviter?.name || 'A team member', - organizationName: org?.name || 'your organization', - pollingGroupName: result.set.name, - provider, - inviteLink: inviteUrl, - }) - - const emailResult = await sendEmail({ - to: invitation.email, - subject: getEmailSubject('polling-group-invitation'), - html: emailHtml, - emailType: 'transactional', - }) - - if (!emailResult.success) { - logger.warn('Failed to resend invitation email', { - email: invitation.email, - error: emailResult.message, - }) - return NextResponse.json({ error: 'Failed to send email' }, { status: 500 }) - } - } catch (emailError) { - logger.error('Error sending invitation email', emailError) - return NextResponse.json({ error: 'Failed to send email' }, { status: 500 }) - } - } - - logger.info('Resent credential set invitation', { - credentialSetId: id, - invitationId, - userId: session.user.id, - }) - - recordAudit({ - workspaceId: null, - actorId: session.user.id, - actorName: session.user.name, - actorEmail: session.user.email, - action: AuditAction.CREDENTIAL_SET_INVITATION_RESENT, - resourceType: AuditResourceType.CREDENTIAL_SET, - resourceId: id, - resourceName: result.set.name, - description: `Resent credential set invitation to ${invitation.email}`, - metadata: { - invitationId, - targetEmail: invitation.email, - providerId: result.set.providerId, - credentialSetName: result.set.name, - }, - request: req, - }) - - return NextResponse.json({ success: true }) - } catch (error) { - logger.error('Error resending invitation', error) - return NextResponse.json({ error: 'Failed to resend invitation' }, { status: 500 }) - } - } -) diff --git a/apps/sim/app/api/credential-sets/[id]/invite/route.ts b/apps/sim/app/api/credential-sets/[id]/invite/route.ts deleted file mode 100644 index 0594c9f5c5e..00000000000 --- a/apps/sim/app/api/credential-sets/[id]/invite/route.ts +++ /dev/null @@ -1,306 +0,0 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' -import { db } from '@sim/db' -import { credentialSet, credentialSetInvitation, member, organization, user } from '@sim/db/schema' -import { createLogger } from '@sim/logger' -import { generateId } from '@sim/utils/id' -import { and, eq } from 'drizzle-orm' -import { type NextRequest, NextResponse } from 'next/server' -import { getEmailSubject, renderPollingGroupInvitationEmail } from '@/components/emails' -import { - cancelCredentialSetInvitationQuerySchema, - createCredentialSetInvitationContract, -} from '@/lib/api/contracts/credential-sets' -import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { hasCredentialSetsAccess } from '@/lib/billing' -import { getBaseUrl } from '@/lib/core/utils/urls' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { sendEmail } from '@/lib/messaging/email/mailer' - -const logger = createLogger('CredentialSetInvite') - -async function getCredentialSetWithAccess(credentialSetId: string, userId: string) { - const [set] = await db - .select({ - id: credentialSet.id, - organizationId: credentialSet.organizationId, - name: credentialSet.name, - providerId: credentialSet.providerId, - }) - .from(credentialSet) - .where(eq(credentialSet.id, credentialSetId)) - .limit(1) - - if (!set) return null - - const [membership] = await db - .select({ role: member.role }) - .from(member) - .where(and(eq(member.userId, userId), eq(member.organizationId, set.organizationId))) - .limit(1) - - if (!membership) return null - - return { set, role: membership.role } -} - -export const GET = withRouteHandler( - async (req: NextRequest, { params }: { params: Promise<{ id: string }> }) => { - const session = await getSession() - - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - // Check plan access (team/enterprise) or env var override - const hasAccess = await hasCredentialSetsAccess(session.user.id) - if (!hasAccess) { - return NextResponse.json( - { error: 'Credential sets require a Team or Enterprise plan' }, - { status: 403 } - ) - } - - const { id } = await params - const result = await getCredentialSetWithAccess(id, session.user.id) - - if (!result) { - return NextResponse.json({ error: 'Credential set not found' }, { status: 404 }) - } - - if (result.role !== 'admin' && result.role !== 'owner') { - return NextResponse.json({ error: 'Admin or owner permissions required' }, { status: 403 }) - } - - const invitations = await db - .select({ - id: credentialSetInvitation.id, - credentialSetId: credentialSetInvitation.credentialSetId, - email: credentialSetInvitation.email, - status: credentialSetInvitation.status, - expiresAt: credentialSetInvitation.expiresAt, - createdAt: credentialSetInvitation.createdAt, - invitedBy: credentialSetInvitation.invitedBy, - }) - .from(credentialSetInvitation) - .where(eq(credentialSetInvitation.credentialSetId, id)) - - return NextResponse.json({ invitations }) - } -) - -export const POST = withRouteHandler( - async (req: NextRequest, context: { params: Promise<{ id: string }> }) => { - const session = await getSession() - - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - // Check plan access (team/enterprise) or env var override - const hasAccess = await hasCredentialSetsAccess(session.user.id) - if (!hasAccess) { - return NextResponse.json( - { error: 'Credential sets require a Team or Enterprise plan' }, - { status: 403 } - ) - } - - try { - const parsed = await parseRequest(createCredentialSetInvitationContract, req, context, { - validationErrorResponse: (error) => - NextResponse.json({ error: getValidationErrorMessage(error) }, { status: 400 }), - }) - if (!parsed.success) return parsed.response - - const { id } = parsed.data.params - const { email } = parsed.data.body - - const result = await getCredentialSetWithAccess(id, session.user.id) - - if (!result) { - return NextResponse.json({ error: 'Credential set not found' }, { status: 404 }) - } - - if (result.role !== 'admin' && result.role !== 'owner') { - return NextResponse.json({ error: 'Admin or owner permissions required' }, { status: 403 }) - } - - const token = generateId() - const expiresAt = new Date() - expiresAt.setDate(expiresAt.getDate() + 7) - - const invitation = { - id: generateId(), - credentialSetId: id, - email: email || null, - token, - invitedBy: session.user.id, - status: 'pending' as const, - expiresAt, - createdAt: new Date(), - } - - await db.insert(credentialSetInvitation).values(invitation) - - const inviteUrl = `${getBaseUrl()}/credential-account/${token}` - - // Send email if email address was provided - if (email) { - try { - // Get inviter name - const [inviter] = await db - .select({ name: user.name }) - .from(user) - .where(eq(user.id, session.user.id)) - .limit(1) - - // Get organization name - const [org] = await db - .select({ name: organization.name }) - .from(organization) - .where(eq(organization.id, result.set.organizationId)) - .limit(1) - - const provider = (result.set.providerId as 'google-email' | 'outlook') || 'google-email' - const emailHtml = await renderPollingGroupInvitationEmail({ - inviterName: inviter?.name || 'A team member', - organizationName: org?.name || 'your organization', - pollingGroupName: result.set.name, - provider, - inviteLink: inviteUrl, - }) - - const emailResult = await sendEmail({ - to: email, - subject: getEmailSubject('polling-group-invitation'), - html: emailHtml, - emailType: 'transactional', - }) - - if (!emailResult.success) { - logger.warn('Failed to send invitation email', { - email, - error: emailResult.message, - }) - } - } catch (emailError) { - logger.error('Error sending invitation email', emailError) - // Don't fail the invitation creation if email fails - } - } - - logger.info('Created credential set invitation', { - credentialSetId: id, - invitationId: invitation.id, - userId: session.user.id, - emailSent: !!email, - }) - - recordAudit({ - workspaceId: null, - actorId: session.user.id, - action: AuditAction.CREDENTIAL_SET_INVITATION_CREATED, - resourceType: AuditResourceType.CREDENTIAL_SET, - resourceId: id, - actorName: session.user.name ?? undefined, - actorEmail: session.user.email ?? undefined, - resourceName: result.set.name, - description: `Created invitation for credential set "${result.set.name}"${email ? ` to ${email}` : ''}`, - metadata: { - invitationId: invitation.id, - targetEmail: email || undefined, - providerId: result.set.providerId, - credentialSetName: result.set.name, - }, - request: req, - }) - - return NextResponse.json({ - invitation: { - ...invitation, - inviteUrl, - }, - }) - } catch (error) { - logger.error('Error creating invitation', error) - return NextResponse.json({ error: 'Failed to create invitation' }, { status: 500 }) - } - } -) - -export const DELETE = withRouteHandler( - async (req: NextRequest, { params }: { params: Promise<{ id: string }> }) => { - const session = await getSession() - - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - // Check plan access (team/enterprise) or env var override - const hasAccess = await hasCredentialSetsAccess(session.user.id) - if (!hasAccess) { - return NextResponse.json( - { error: 'Credential sets require a Team or Enterprise plan' }, - { status: 403 } - ) - } - - const { id } = await params - const { searchParams } = new URL(req.url) - const validation = cancelCredentialSetInvitationQuerySchema.safeParse({ - invitationId: searchParams.get('invitationId') ?? '', - }) - - if (!validation.success) { - return NextResponse.json( - { error: getValidationErrorMessage(validation.error) }, - { status: 400 } - ) - } - - const { invitationId } = validation.data - - try { - const result = await getCredentialSetWithAccess(id, session.user.id) - - if (!result) { - return NextResponse.json({ error: 'Credential set not found' }, { status: 404 }) - } - - if (result.role !== 'admin' && result.role !== 'owner') { - return NextResponse.json({ error: 'Admin or owner permissions required' }, { status: 403 }) - } - - const [revokedInvitation] = await db - .update(credentialSetInvitation) - .set({ status: 'cancelled' }) - .where( - and( - eq(credentialSetInvitation.id, invitationId), - eq(credentialSetInvitation.credentialSetId, id) - ) - ) - .returning({ email: credentialSetInvitation.email }) - - recordAudit({ - workspaceId: null, - actorId: session.user.id, - action: AuditAction.CREDENTIAL_SET_INVITATION_REVOKED, - resourceType: AuditResourceType.CREDENTIAL_SET, - resourceId: id, - actorName: session.user.name ?? undefined, - actorEmail: session.user.email ?? undefined, - resourceName: result.set.name, - description: `Revoked invitation "${invitationId}" for credential set "${result.set.name}"`, - metadata: { targetEmail: revokedInvitation?.email ?? undefined }, - request: req, - }) - - return NextResponse.json({ success: true }) - } catch (error) { - logger.error('Error cancelling invitation', error) - return NextResponse.json({ error: 'Failed to cancel invitation' }, { status: 500 }) - } - } -) diff --git a/apps/sim/app/api/credential-sets/[id]/members/route.ts b/apps/sim/app/api/credential-sets/[id]/members/route.ts deleted file mode 100644 index b49aadcfae0..00000000000 --- a/apps/sim/app/api/credential-sets/[id]/members/route.ts +++ /dev/null @@ -1,238 +0,0 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' -import { db } from '@sim/db' -import { account, credentialSet, credentialSetMember, member, user } from '@sim/db/schema' -import { createLogger } from '@sim/logger' -import { generateId } from '@sim/utils/id' -import { and, eq, inArray } from 'drizzle-orm' -import { type NextRequest, NextResponse } from 'next/server' -import { removeCredentialSetMemberQuerySchema } from '@/lib/api/contracts/credential-sets' -import { getValidationErrorMessage } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { hasCredentialSetsAccess } from '@/lib/billing' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { syncAllWebhooksForCredentialSet } from '@/lib/webhooks/utils.server' - -const logger = createLogger('CredentialSetMembers') - -async function getCredentialSetWithAccess(credentialSetId: string, userId: string) { - const [set] = await db - .select({ - id: credentialSet.id, - name: credentialSet.name, - organizationId: credentialSet.organizationId, - providerId: credentialSet.providerId, - }) - .from(credentialSet) - .where(eq(credentialSet.id, credentialSetId)) - .limit(1) - - if (!set) return null - - const [membership] = await db - .select({ role: member.role }) - .from(member) - .where(and(eq(member.userId, userId), eq(member.organizationId, set.organizationId))) - .limit(1) - - if (!membership) return null - - return { set, role: membership.role } -} - -export const GET = withRouteHandler( - async (req: NextRequest, { params }: { params: Promise<{ id: string }> }) => { - const session = await getSession() - - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - // Check plan access (team/enterprise) or env var override - const hasAccess = await hasCredentialSetsAccess(session.user.id) - if (!hasAccess) { - return NextResponse.json( - { error: 'Credential sets require a Team or Enterprise plan' }, - { status: 403 } - ) - } - - const { id } = await params - const result = await getCredentialSetWithAccess(id, session.user.id) - - if (!result) { - return NextResponse.json({ error: 'Credential set not found' }, { status: 404 }) - } - - const members = await db - .select({ - id: credentialSetMember.id, - userId: credentialSetMember.userId, - status: credentialSetMember.status, - joinedAt: credentialSetMember.joinedAt, - createdAt: credentialSetMember.createdAt, - userName: user.name, - userEmail: user.email, - userImage: user.image, - }) - .from(credentialSetMember) - .leftJoin(user, eq(credentialSetMember.userId, user.id)) - .where(eq(credentialSetMember.credentialSetId, id)) - - // Get credentials for all active members filtered by the polling group's provider - const activeMembers = members.filter((m) => m.status === 'active') - const memberUserIds = activeMembers.map((m) => m.userId) - - let credentials: { userId: string; providerId: string; accountId: string }[] = [] - if (memberUserIds.length > 0 && result.set.providerId) { - credentials = await db - .select({ - userId: account.userId, - providerId: account.providerId, - accountId: account.accountId, - }) - .from(account) - .where( - and(inArray(account.userId, memberUserIds), eq(account.providerId, result.set.providerId)) - ) - } - - // Group credentials by userId - const credentialsByUser = credentials.reduce( - (acc, cred) => { - if (!acc[cred.userId]) { - acc[cred.userId] = [] - } - acc[cred.userId].push({ - providerId: cred.providerId, - accountId: cred.accountId, - }) - return acc - }, - {} as Record - ) - - // Attach credentials to members - const membersWithCredentials = members.map((m) => ({ - ...m, - credentials: credentialsByUser[m.userId] || [], - })) - - return NextResponse.json({ members: membersWithCredentials }) - } -) - -export const DELETE = withRouteHandler( - async (req: NextRequest, { params }: { params: Promise<{ id: string }> }) => { - const session = await getSession() - - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - // Check plan access (team/enterprise) or env var override - const hasAccess = await hasCredentialSetsAccess(session.user.id) - if (!hasAccess) { - return NextResponse.json( - { error: 'Credential sets require a Team or Enterprise plan' }, - { status: 403 } - ) - } - - const { id } = await params - const { searchParams } = new URL(req.url) - const validation = removeCredentialSetMemberQuerySchema.safeParse({ - memberId: searchParams.get('memberId') ?? '', - }) - - if (!validation.success) { - return NextResponse.json( - { error: getValidationErrorMessage(validation.error) }, - { status: 400 } - ) - } - - const { memberId } = validation.data - - try { - const result = await getCredentialSetWithAccess(id, session.user.id) - - if (!result) { - return NextResponse.json({ error: 'Credential set not found' }, { status: 404 }) - } - - if (result.role !== 'admin' && result.role !== 'owner') { - return NextResponse.json({ error: 'Admin or owner permissions required' }, { status: 403 }) - } - - const [memberToRemove] = await db - .select({ - id: credentialSetMember.id, - credentialSetId: credentialSetMember.credentialSetId, - userId: credentialSetMember.userId, - status: credentialSetMember.status, - email: user.email, - }) - .from(credentialSetMember) - .innerJoin(user, eq(credentialSetMember.userId, user.id)) - .where( - and(eq(credentialSetMember.id, memberId), eq(credentialSetMember.credentialSetId, id)) - ) - .limit(1) - - if (!memberToRemove) { - return NextResponse.json({ error: 'Member not found' }, { status: 404 }) - } - - const requestId = generateId().slice(0, 8) - - await db.delete(credentialSetMember).where(eq(credentialSetMember.id, memberId)) - - // Runs after the deletion commits: the sync performs external HTTP - // (OAuth refresh, provider unsubscribe) and must not hold a pooled - // connection. A sync failure must not fail the committed mutation — - // it self-heals on the next membership change/deploy. - try { - const syncResult = await syncAllWebhooksForCredentialSet(id, requestId) - logger.info('Synced webhooks after member removed', { - credentialSetId: id, - ...syncResult, - }) - } catch (syncError) { - logger.error('Webhook sync failed after member removal', { - credentialSetId: id, - error: syncError, - }) - } - - logger.info('Removed member from credential set', { - credentialSetId: id, - memberId, - userId: session.user.id, - }) - - recordAudit({ - workspaceId: null, - actorId: session.user.id, - action: AuditAction.CREDENTIAL_SET_MEMBER_REMOVED, - resourceType: AuditResourceType.CREDENTIAL_SET, - resourceId: id, - actorName: session.user.name ?? undefined, - actorEmail: session.user.email ?? undefined, - resourceName: result.set.name, - description: `Removed member from credential set "${result.set.name}"`, - metadata: { - memberId, - memberUserId: memberToRemove.userId, - targetEmail: memberToRemove.email ?? undefined, - providerId: result.set.providerId, - }, - request: req, - }) - - return NextResponse.json({ success: true }) - } catch (error) { - logger.error('Error removing member from credential set', error) - return NextResponse.json({ error: 'Failed to remove member' }, { status: 500 }) - } - } -) diff --git a/apps/sim/app/api/credential-sets/[id]/route.ts b/apps/sim/app/api/credential-sets/[id]/route.ts deleted file mode 100644 index 8e7241382ee..00000000000 --- a/apps/sim/app/api/credential-sets/[id]/route.ts +++ /dev/null @@ -1,242 +0,0 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' -import { db } from '@sim/db' -import { credentialSet, credentialSetMember, member, user } from '@sim/db/schema' -import { createLogger } from '@sim/logger' -import { and, count, eq } from 'drizzle-orm' -import { type NextRequest, NextResponse } from 'next/server' -import { updateCredentialSetContract } from '@/lib/api/contracts/credential-sets' -import { parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { hasCredentialSetsAccess } from '@/lib/billing' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' - -const logger = createLogger('CredentialSet') - -async function getCredentialSetWithAccess(credentialSetId: string, userId: string) { - const [set] = await db - .select({ - id: credentialSet.id, - organizationId: credentialSet.organizationId, - name: credentialSet.name, - description: credentialSet.description, - providerId: credentialSet.providerId, - createdBy: credentialSet.createdBy, - createdAt: credentialSet.createdAt, - updatedAt: credentialSet.updatedAt, - creatorName: user.name, - creatorEmail: user.email, - }) - .from(credentialSet) - .leftJoin(user, eq(credentialSet.createdBy, user.id)) - .where(eq(credentialSet.id, credentialSetId)) - .limit(1) - - if (!set) return null - - const [membership] = await db - .select({ role: member.role }) - .from(member) - .where(and(eq(member.userId, userId), eq(member.organizationId, set.organizationId))) - .limit(1) - - if (!membership) return null - - const [memberCount] = await db - .select({ count: count() }) - .from(credentialSetMember) - .where( - and( - eq(credentialSetMember.credentialSetId, credentialSetId), - eq(credentialSetMember.status, 'active') - ) - ) - - return { - set: { - ...set, - memberCount: memberCount?.count ?? 0, - }, - role: membership.role, - } -} - -export const GET = withRouteHandler( - async (req: NextRequest, { params }: { params: Promise<{ id: string }> }) => { - const session = await getSession() - - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - // Check plan access (team/enterprise) or env var override - const hasAccess = await hasCredentialSetsAccess(session.user.id) - if (!hasAccess) { - return NextResponse.json( - { error: 'Credential sets require a Team or Enterprise plan' }, - { status: 403 } - ) - } - - const { id } = await params - const result = await getCredentialSetWithAccess(id, session.user.id) - - if (!result) { - return NextResponse.json({ error: 'Credential set not found' }, { status: 404 }) - } - - return NextResponse.json({ credentialSet: result.set }) - } -) - -export const PUT = withRouteHandler( - async (req: NextRequest, context: { params: Promise<{ id: string }> }) => { - const session = await getSession() - - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - // Check plan access (team/enterprise) or env var override - const hasAccess = await hasCredentialSetsAccess(session.user.id) - if (!hasAccess) { - return NextResponse.json( - { error: 'Credential sets require a Team or Enterprise plan' }, - { status: 403 } - ) - } - - const { id } = await context.params - - try { - const result = await getCredentialSetWithAccess(id, session.user.id) - - if (!result) { - return NextResponse.json({ error: 'Credential set not found' }, { status: 404 }) - } - - if (result.role !== 'admin' && result.role !== 'owner') { - return NextResponse.json({ error: 'Admin or owner permissions required' }, { status: 403 }) - } - - const parsed = await parseRequest(updateCredentialSetContract, req, context) - if (!parsed.success) return parsed.response - const updates = parsed.data.body - - if (updates.name) { - const existingSet = await db - .select({ id: credentialSet.id }) - .from(credentialSet) - .where( - and( - eq(credentialSet.organizationId, result.set.organizationId), - eq(credentialSet.name, updates.name) - ) - ) - .limit(1) - - if (existingSet.length > 0 && existingSet[0].id !== id) { - return NextResponse.json( - { error: 'A credential set with this name already exists' }, - { status: 409 } - ) - } - } - - await db - .update(credentialSet) - .set({ - ...updates, - updatedAt: new Date(), - }) - .where(eq(credentialSet.id, id)) - - const [updated] = await db - .select() - .from(credentialSet) - .where(eq(credentialSet.id, id)) - .limit(1) - - recordAudit({ - workspaceId: null, - actorId: session.user.id, - action: AuditAction.CREDENTIAL_SET_UPDATED, - resourceType: AuditResourceType.CREDENTIAL_SET, - resourceId: id, - actorName: session.user.name ?? undefined, - actorEmail: session.user.email ?? undefined, - resourceName: updated?.name ?? result.set.name, - description: `Updated credential set "${updated?.name ?? result.set.name}"`, - metadata: { - organizationId: result.set.organizationId, - providerId: result.set.providerId, - updatedFields: Object.keys(updates).filter( - (k) => updates[k as keyof typeof updates] !== undefined - ), - }, - request: req, - }) - - return NextResponse.json({ credentialSet: updated }) - } catch (error) { - logger.error('Error updating credential set', error) - return NextResponse.json({ error: 'Failed to update credential set' }, { status: 500 }) - } - } -) - -export const DELETE = withRouteHandler( - async (req: NextRequest, { params }: { params: Promise<{ id: string }> }) => { - const session = await getSession() - - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - // Check plan access (team/enterprise) or env var override - const hasAccess = await hasCredentialSetsAccess(session.user.id) - if (!hasAccess) { - return NextResponse.json( - { error: 'Credential sets require a Team or Enterprise plan' }, - { status: 403 } - ) - } - - const { id } = await params - - try { - const result = await getCredentialSetWithAccess(id, session.user.id) - - if (!result) { - return NextResponse.json({ error: 'Credential set not found' }, { status: 404 }) - } - - if (result.role !== 'admin' && result.role !== 'owner') { - return NextResponse.json({ error: 'Admin or owner permissions required' }, { status: 403 }) - } - - await db.delete(credentialSetMember).where(eq(credentialSetMember.credentialSetId, id)) - await db.delete(credentialSet).where(eq(credentialSet.id, id)) - - logger.info('Deleted credential set', { credentialSetId: id, userId: session.user.id }) - - recordAudit({ - workspaceId: null, - actorId: session.user.id, - action: AuditAction.CREDENTIAL_SET_DELETED, - resourceType: AuditResourceType.CREDENTIAL_SET, - resourceId: id, - actorName: session.user.name ?? undefined, - actorEmail: session.user.email ?? undefined, - resourceName: result.set.name, - description: `Deleted credential set "${result.set.name}"`, - metadata: { organizationId: result.set.organizationId, providerId: result.set.providerId }, - request: req, - }) - - return NextResponse.json({ success: true }) - } catch (error) { - logger.error('Error deleting credential set', error) - return NextResponse.json({ error: 'Failed to delete credential set' }, { status: 500 }) - } - } -) diff --git a/apps/sim/app/api/credential-sets/invitations/route.ts b/apps/sim/app/api/credential-sets/invitations/route.ts deleted file mode 100644 index a6da9ca82b2..00000000000 --- a/apps/sim/app/api/credential-sets/invitations/route.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { db } from '@sim/db' -import { credentialSet, credentialSetInvitation, organization, user } from '@sim/db/schema' -import { createLogger } from '@sim/logger' -import { and, eq, gt } from 'drizzle-orm' -import { NextResponse } from 'next/server' -import { getSession } from '@/lib/auth' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' - -const logger = createLogger('CredentialSetInvitations') - -export const GET = withRouteHandler(async () => { - const session = await getSession() - - if (!session?.user?.id || !session?.user?.email) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - try { - // Scope to invitations addressed to the caller's own email only. Open-link - // (null-email) invites carry a bearer token redeemed via the out-of-band URL - // and must never be listed, or any user could accept another org's invite. - const invitations = await db - .select({ - invitationId: credentialSetInvitation.id, - token: credentialSetInvitation.token, - status: credentialSetInvitation.status, - expiresAt: credentialSetInvitation.expiresAt, - createdAt: credentialSetInvitation.createdAt, - credentialSetId: credentialSet.id, - credentialSetName: credentialSet.name, - providerId: credentialSet.providerId, - organizationId: organization.id, - organizationName: organization.name, - invitedByName: user.name, - invitedByEmail: user.email, - }) - .from(credentialSetInvitation) - .innerJoin(credentialSet, eq(credentialSetInvitation.credentialSetId, credentialSet.id)) - .innerJoin(organization, eq(credentialSet.organizationId, organization.id)) - .leftJoin(user, eq(credentialSetInvitation.invitedBy, user.id)) - .where( - and( - eq(credentialSetInvitation.email, session.user.email), - eq(credentialSetInvitation.status, 'pending'), - gt(credentialSetInvitation.expiresAt, new Date()) - ) - ) - - return NextResponse.json({ invitations }) - } catch (error) { - logger.error('Error fetching credential set invitations', error) - return NextResponse.json({ error: 'Failed to fetch invitations' }, { status: 500 }) - } -}) diff --git a/apps/sim/app/api/credential-sets/invite/[token]/route.ts b/apps/sim/app/api/credential-sets/invite/[token]/route.ts deleted file mode 100644 index 160a5054739..00000000000 --- a/apps/sim/app/api/credential-sets/invite/[token]/route.ts +++ /dev/null @@ -1,253 +0,0 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' -import { db } from '@sim/db' -import { - credentialSet, - credentialSetInvitation, - credentialSetMember, - organization, -} from '@sim/db/schema' -import { createLogger } from '@sim/logger' -import { generateId } from '@sim/utils/id' -import { normalizeEmail } from '@sim/utils/string' -import { and, eq } from 'drizzle-orm' -import { type NextRequest, NextResponse } from 'next/server' -import { - acceptCredentialSetInvitationContract, - getCredentialSetInvitationContract, -} from '@/lib/api/contracts/credential-sets' -import { parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { syncAllWebhooksForCredentialSet } from '@/lib/webhooks/utils.server' - -const logger = createLogger('CredentialSetInviteToken') - -export const GET = withRouteHandler( - async (req: NextRequest, context: { params: Promise<{ token: string }> }) => { - const parsed = await parseRequest(getCredentialSetInvitationContract, req, context) - if (!parsed.success) return parsed.response - const { token } = parsed.data.params - - const [invitation] = await db - .select({ - id: credentialSetInvitation.id, - credentialSetId: credentialSetInvitation.credentialSetId, - email: credentialSetInvitation.email, - status: credentialSetInvitation.status, - expiresAt: credentialSetInvitation.expiresAt, - credentialSetName: credentialSet.name, - providerId: credentialSet.providerId, - organizationId: credentialSet.organizationId, - organizationName: organization.name, - }) - .from(credentialSetInvitation) - .innerJoin(credentialSet, eq(credentialSetInvitation.credentialSetId, credentialSet.id)) - .innerJoin(organization, eq(credentialSet.organizationId, organization.id)) - .where(eq(credentialSetInvitation.token, token)) - .limit(1) - - if (!invitation) { - return NextResponse.json({ error: 'Invitation not found' }, { status: 404 }) - } - - if (invitation.status !== 'pending') { - return NextResponse.json({ error: 'Invitation is no longer valid' }, { status: 410 }) - } - - if (new Date() > invitation.expiresAt) { - await db - .update(credentialSetInvitation) - .set({ status: 'expired' }) - .where(eq(credentialSetInvitation.id, invitation.id)) - - return NextResponse.json({ error: 'Invitation has expired' }, { status: 410 }) - } - - return NextResponse.json({ - invitation: { - credentialSetName: invitation.credentialSetName, - organizationName: invitation.organizationName, - providerId: invitation.providerId, - email: invitation.email, - }, - }) - } -) - -export const POST = withRouteHandler( - async (req: NextRequest, context: { params: Promise<{ token: string }> }) => { - const parsed = await parseRequest(acceptCredentialSetInvitationContract, req, context) - if (!parsed.success) return parsed.response - const { token } = parsed.data.params - - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) - } - - try { - const [invitationData] = await db - .select({ - id: credentialSetInvitation.id, - credentialSetId: credentialSetInvitation.credentialSetId, - email: credentialSetInvitation.email, - status: credentialSetInvitation.status, - expiresAt: credentialSetInvitation.expiresAt, - invitedBy: credentialSetInvitation.invitedBy, - credentialSetName: credentialSet.name, - providerId: credentialSet.providerId, - }) - .from(credentialSetInvitation) - .innerJoin(credentialSet, eq(credentialSetInvitation.credentialSetId, credentialSet.id)) - .where(eq(credentialSetInvitation.token, token)) - .limit(1) - - if (!invitationData) { - return NextResponse.json({ error: 'Invitation not found' }, { status: 404 }) - } - - const invitation = invitationData - - if (invitation.status !== 'pending') { - return NextResponse.json({ error: 'Invitation is no longer valid' }, { status: 410 }) - } - - if (new Date() > invitation.expiresAt) { - await db - .update(credentialSetInvitation) - .set({ status: 'expired' }) - .where(eq(credentialSetInvitation.id, invitation.id)) - - return NextResponse.json({ error: 'Invitation has expired' }, { status: 410 }) - } - - if (invitation.email) { - const sessionEmail = session.user.email - if (!sessionEmail || normalizeEmail(sessionEmail) !== normalizeEmail(invitation.email)) { - logger.warn('Rejected credential set invitation accept due to email mismatch', { - invitationId: invitation.id, - credentialSetId: invitation.credentialSetId, - userId: session.user.id, - }) - return NextResponse.json( - { error: 'This invitation was sent to a different email address' }, - { status: 403 } - ) - } - } - - const existingMember = await db - .select() - .from(credentialSetMember) - .where( - and( - eq(credentialSetMember.credentialSetId, invitation.credentialSetId), - eq(credentialSetMember.userId, session.user.id) - ) - ) - .limit(1) - - if (existingMember.length > 0) { - return NextResponse.json( - { error: 'Already a member of this credential set' }, - { status: 409 } - ) - } - - const now = new Date() - const requestId = generateId().slice(0, 8) - - await db.transaction(async (tx) => { - await tx.insert(credentialSetMember).values({ - id: generateId(), - credentialSetId: invitation.credentialSetId, - userId: session.user.id, - status: 'active', - joinedAt: now, - invitedBy: invitation.invitedBy, - createdAt: now, - updatedAt: now, - }) - - await tx - .update(credentialSetInvitation) - .set({ - status: 'accepted', - acceptedAt: now, - acceptedByUserId: session.user.id, - }) - .where(eq(credentialSetInvitation.id, invitation.id)) - - if (invitation.email) { - await tx - .update(credentialSetInvitation) - .set({ - status: 'accepted', - acceptedAt: now, - acceptedByUserId: session.user.id, - }) - .where( - and( - eq(credentialSetInvitation.credentialSetId, invitation.credentialSetId), - eq(credentialSetInvitation.email, invitation.email), - eq(credentialSetInvitation.status, 'pending') - ) - ) - } - }) - - // Runs after the membership commits: the sync performs external HTTP - // (OAuth refresh, provider unsubscribe) and must not hold a pooled - // connection. A sync failure must not fail the committed mutation — - // it self-heals on the next membership change/deploy. - try { - const syncResult = await syncAllWebhooksForCredentialSet( - invitation.credentialSetId, - requestId - ) - logger.info('Synced webhooks after member joined', { - credentialSetId: invitation.credentialSetId, - ...syncResult, - }) - } catch (syncError) { - logger.error('Webhook sync failed after invitation accept', { - credentialSetId: invitation.credentialSetId, - error: syncError, - }) - } - - logger.info('Accepted credential set invitation', { - invitationId: invitation.id, - credentialSetId: invitation.credentialSetId, - userId: session.user.id, - }) - - recordAudit({ - actorId: session.user.id, - actorName: session.user.name, - actorEmail: session.user.email, - action: AuditAction.CREDENTIAL_SET_INVITATION_ACCEPTED, - resourceType: AuditResourceType.CREDENTIAL_SET, - resourceId: invitation.credentialSetId, - resourceName: invitation.credentialSetName, - description: `Accepted credential set invitation`, - metadata: { - invitationId: invitation.id, - credentialSetId: invitation.credentialSetId, - providerId: invitation.providerId, - credentialSetName: invitation.credentialSetName, - }, - request: req, - }) - - return NextResponse.json({ - success: true, - credentialSetId: invitation.credentialSetId, - providerId: invitation.providerId, - }) - } catch (error) { - logger.error('Error accepting invitation', error) - return NextResponse.json({ error: 'Failed to accept invitation' }, { status: 500 }) - } - } -) diff --git a/apps/sim/app/api/credential-sets/memberships/route.ts b/apps/sim/app/api/credential-sets/memberships/route.ts deleted file mode 100644 index 8ce556f5b5d..00000000000 --- a/apps/sim/app/api/credential-sets/memberships/route.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' -import { db } from '@sim/db' -import { credentialSet, credentialSetMember, organization } from '@sim/db/schema' -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import { generateId } from '@sim/utils/id' -import { and, eq } from 'drizzle-orm' -import { type NextRequest, NextResponse } from 'next/server' -import { leaveCredentialSetQuerySchema } from '@/lib/api/contracts/credential-sets' -import { getValidationErrorMessage } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { syncAllWebhooksForCredentialSet } from '@/lib/webhooks/utils.server' - -const logger = createLogger('CredentialSetMemberships') - -export const GET = withRouteHandler(async () => { - const session = await getSession() - - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - try { - const memberships = await db - .select({ - membershipId: credentialSetMember.id, - status: credentialSetMember.status, - joinedAt: credentialSetMember.joinedAt, - credentialSetId: credentialSet.id, - credentialSetName: credentialSet.name, - credentialSetDescription: credentialSet.description, - providerId: credentialSet.providerId, - organizationId: organization.id, - organizationName: organization.name, - }) - .from(credentialSetMember) - .innerJoin(credentialSet, eq(credentialSetMember.credentialSetId, credentialSet.id)) - .innerJoin(organization, eq(credentialSet.organizationId, organization.id)) - .where(eq(credentialSetMember.userId, session.user.id)) - - return NextResponse.json({ memberships }) - } catch (error) { - logger.error('Error fetching credential set memberships', error) - return NextResponse.json({ error: 'Failed to fetch memberships' }, { status: 500 }) - } -}) - -/** - * Leave a credential set (self-revocation). - * Sets status to 'revoked' immediately (blocks execution), then syncs webhooks to clean up. - */ -export const DELETE = withRouteHandler(async (req: NextRequest) => { - const session = await getSession() - - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const { searchParams } = new URL(req.url) - const validation = leaveCredentialSetQuerySchema.safeParse({ - credentialSetId: searchParams.get('credentialSetId') ?? '', - }) - - if (!validation.success) { - return NextResponse.json( - { error: getValidationErrorMessage(validation.error) }, - { status: 400 } - ) - } - - const { credentialSetId } = validation.data - - try { - const requestId = generateId().slice(0, 8) - - await db.transaction(async (tx) => { - // Find and verify membership - const [membership] = await tx - .select() - .from(credentialSetMember) - .where( - and( - eq(credentialSetMember.credentialSetId, credentialSetId), - eq(credentialSetMember.userId, session.user.id) - ) - ) - .limit(1) - - if (!membership) { - throw new Error('Not a member of this credential set') - } - - if (membership.status === 'revoked') { - throw new Error('Already left this credential set') - } - - // Set status to 'revoked' - this immediately blocks credential from being used - await tx - .update(credentialSetMember) - .set({ - status: 'revoked', - updatedAt: new Date(), - }) - .where(eq(credentialSetMember.id, membership.id)) - }) - - // Runs after the revocation commits: the sync performs external HTTP - // (OAuth refresh, provider unsubscribe) and must not hold a pooled - // connection. A sync failure must not fail the committed mutation — - // it self-heals on the next membership change/deploy. - try { - const syncResult = await syncAllWebhooksForCredentialSet(credentialSetId, requestId) - logger.info('Synced webhooks after member left', { - credentialSetId, - userId: session.user.id, - ...syncResult, - }) - } catch (syncError) { - logger.error('Webhook sync failed after member left', { - credentialSetId, - userId: session.user.id, - error: syncError, - }) - } - - logger.info('User left credential set', { - credentialSetId, - userId: session.user.id, - }) - - recordAudit({ - actorId: session.user.id, - actorName: session.user.name, - actorEmail: session.user.email, - action: AuditAction.CREDENTIAL_SET_MEMBER_LEFT, - resourceType: AuditResourceType.CREDENTIAL_SET, - resourceId: credentialSetId, - description: `Left credential set`, - metadata: { credentialSetId }, - request: req, - }) - - return NextResponse.json({ success: true }) - } catch (error) { - const message = getErrorMessage(error, 'Failed to leave credential set') - logger.error('Error leaving credential set', error) - return NextResponse.json({ error: message }, { status: 500 }) - } -}) diff --git a/apps/sim/app/api/credential-sets/route.ts b/apps/sim/app/api/credential-sets/route.ts deleted file mode 100644 index 0221ca44b93..00000000000 --- a/apps/sim/app/api/credential-sets/route.ts +++ /dev/null @@ -1,216 +0,0 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' -import { db } from '@sim/db' -import { credentialSet, credentialSetMember, member, organization, user } from '@sim/db/schema' -import { createLogger } from '@sim/logger' -import { generateId } from '@sim/utils/id' -import { and, count, desc, eq } from 'drizzle-orm' -import { type NextRequest, NextResponse } from 'next/server' -import { - createCredentialSetContract, - listCredentialSetsQuerySchema, -} from '@/lib/api/contracts/credential-sets' -import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { hasCredentialSetsAccess } from '@/lib/billing' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' - -const logger = createLogger('CredentialSets') - -export const GET = withRouteHandler(async (req: Request) => { - const session = await getSession() - - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - // Check plan access (team/enterprise) or env var override - const hasAccess = await hasCredentialSetsAccess(session.user.id) - if (!hasAccess) { - return NextResponse.json( - { error: 'Credential sets require a Team or Enterprise plan' }, - { status: 403 } - ) - } - - const { searchParams } = new URL(req.url) - const validation = listCredentialSetsQuerySchema.safeParse({ - organizationId: searchParams.get('organizationId') ?? '', - }) - - if (!validation.success) { - return NextResponse.json( - { error: getValidationErrorMessage(validation.error) }, - { status: 400 } - ) - } - - const { organizationId } = validation.data - - const membership = await db - .select({ id: member.id, role: member.role }) - .from(member) - .where(and(eq(member.userId, session.user.id), eq(member.organizationId, organizationId))) - .limit(1) - - if (membership.length === 0) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) - } - - const sets = await db - .select({ - id: credentialSet.id, - name: credentialSet.name, - description: credentialSet.description, - providerId: credentialSet.providerId, - createdBy: credentialSet.createdBy, - createdAt: credentialSet.createdAt, - updatedAt: credentialSet.updatedAt, - creatorName: user.name, - creatorEmail: user.email, - }) - .from(credentialSet) - .leftJoin(user, eq(credentialSet.createdBy, user.id)) - .where(eq(credentialSet.organizationId, organizationId)) - .orderBy(desc(credentialSet.createdAt)) - - const setsWithCounts = await Promise.all( - sets.map(async (set) => { - const [memberCount] = await db - .select({ count: count() }) - .from(credentialSetMember) - .where( - and( - eq(credentialSetMember.credentialSetId, set.id), - eq(credentialSetMember.status, 'active') - ) - ) - - return { - ...set, - memberCount: memberCount?.count ?? 0, - } - }) - ) - - return NextResponse.json({ credentialSets: setsWithCounts }) -}) - -export const POST = withRouteHandler(async (req: NextRequest) => { - const session = await getSession() - - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - // Check plan access (team/enterprise) or env var override - const hasAccess = await hasCredentialSetsAccess(session.user.id) - if (!hasAccess) { - return NextResponse.json( - { error: 'Credential sets require a Team or Enterprise plan' }, - { status: 403 } - ) - } - - try { - const parsed = await parseRequest( - createCredentialSetContract, - req, - {}, - { - validationErrorResponse: (error) => - NextResponse.json({ error: getValidationErrorMessage(error) }, { status: 400 }), - } - ) - if (!parsed.success) return parsed.response - - const { organizationId, name, description, providerId } = parsed.data.body - - const membership = await db - .select({ id: member.id, role: member.role }) - .from(member) - .where(and(eq(member.userId, session.user.id), eq(member.organizationId, organizationId))) - .limit(1) - - const role = membership[0]?.role - if (membership.length === 0 || (role !== 'admin' && role !== 'owner')) { - return NextResponse.json( - { error: 'Admin or owner permissions required to create credential sets' }, - { status: 403 } - ) - } - - // Check org existence and name uniqueness in parallel - const [orgExists, existingSet] = await Promise.all([ - db - .select({ id: organization.id }) - .from(organization) - .where(eq(organization.id, organizationId)) - .limit(1), - db - .select({ id: credentialSet.id }) - .from(credentialSet) - .where(and(eq(credentialSet.organizationId, organizationId), eq(credentialSet.name, name))) - .limit(1), - ]) - - if (orgExists.length === 0) { - return NextResponse.json({ error: 'Organization not found' }, { status: 404 }) - } - - if (existingSet.length > 0) { - return NextResponse.json( - { error: 'A credential set with this name already exists' }, - { status: 409 } - ) - } - - const now = new Date() - const newCredentialSet = { - id: generateId(), - organizationId, - name, - description: description || null, - providerId, - createdBy: session.user.id, - createdAt: now, - updatedAt: now, - } - - await db.insert(credentialSet).values(newCredentialSet) - - logger.info('Created credential set', { - credentialSetId: newCredentialSet.id, - organizationId, - userId: session.user.id, - }) - - recordAudit({ - workspaceId: null, - actorId: session.user.id, - action: AuditAction.CREDENTIAL_SET_CREATED, - resourceType: AuditResourceType.CREDENTIAL_SET, - resourceId: newCredentialSet.id, - actorName: session.user.name ?? undefined, - actorEmail: session.user.email ?? undefined, - resourceName: name, - description: `Created credential set "${name}"`, - metadata: { organizationId, providerId, credentialSetName: name }, - request: req, - }) - - return NextResponse.json( - { - credentialSet: { - ...newCredentialSet, - creatorName: session.user.name ?? null, - creatorEmail: session.user.email ?? null, - memberCount: 0, - }, - }, - { status: 201 } - ) - } catch (error) { - logger.error('Error creating credential set', error) - return NextResponse.json({ error: 'Failed to create credential set' }, { status: 500 }) - } -}) diff --git a/apps/sim/app/api/webhooks/[id]/route.ts b/apps/sim/app/api/webhooks/[id]/route.ts index 7ad630c5ff6..92506cc8627 100644 --- a/apps/sim/app/api/webhooks/[id]/route.ts +++ b/apps/sim/app/api/webhooks/[id]/route.ts @@ -223,69 +223,21 @@ export const DELETE = withRouteHandler( await assertWorkflowMutable(webhookData.workflow.id) const foundWebhook = webhookData.webhook - const credentialSetId = foundWebhook.credentialSetId as string | undefined - const blockId = foundWebhook.blockId as string | undefined - - if (credentialSetId && blockId) { - const allCredentialSetWebhooks = await db - .select() - .from(webhook) - .where( - and( - eq(webhook.workflowId, webhookData.workflow.id), - eq(webhook.blockId, blockId), - isNull(webhook.archivedAt) - ) - ) - - const webhooksToDelete = allCredentialSetWebhooks.filter( - (w) => w.credentialSetId === credentialSetId - ) - - for (const w of webhooksToDelete) { - await cleanupExternalWebhook(w, webhookData.workflow, requestId) - } - - const idsToDelete = webhooksToDelete.map((w) => w.id) - for (const wId of idsToDelete) { - await db.delete(webhook).where(eq(webhook.id, wId)) - } - - try { - for (const wId of idsToDelete) { - PlatformEvents.webhookDeleted({ - webhookId: wId, - workflowId: webhookData.workflow.id, - }) - } - } catch { - // Telemetry should not fail the operation - } - - logger.info( - `[${requestId}] Successfully deleted ${idsToDelete.length} webhooks for credential set`, - { - credentialSetId, - blockId, - deletedIds: idsToDelete, - } - ) - } else { - await cleanupExternalWebhook(foundWebhook, webhookData.workflow, requestId) - await db.delete(webhook).where(eq(webhook.id, id)) - - try { - PlatformEvents.webhookDeleted({ - webhookId: id, - workflowId: webhookData.workflow.id, - }) - } catch { - // Telemetry should not fail the operation - } - - logger.info(`[${requestId}] Successfully deleted webhook: ${id}`) + + await cleanupExternalWebhook(foundWebhook, webhookData.workflow, requestId) + await db.delete(webhook).where(eq(webhook.id, id)) + + try { + PlatformEvents.webhookDeleted({ + webhookId: id, + workflowId: webhookData.workflow.id, + }) + } catch { + // Telemetry should not fail the operation } + logger.info(`[${requestId}] Successfully deleted webhook: ${id}`) + recordAudit({ workspaceId: webhookData.workflow.workspaceId || null, actorId: userId, @@ -301,7 +253,6 @@ export const DELETE = withRouteHandler( workflowId: webhookData.workflow.id, webhookPath: foundWebhook.path || undefined, blockId: foundWebhook.blockId || undefined, - credentialSetId: credentialSetId || undefined, }, request, }) diff --git a/apps/sim/app/api/webhooks/route.ts b/apps/sim/app/api/webhooks/route.ts index 8c7fdec2ee8..a27b0d599b0 100644 --- a/apps/sim/app/api/webhooks/route.ts +++ b/apps/sim/app/api/webhooks/route.ts @@ -17,7 +17,6 @@ import { getSession } from '@/lib/auth' import { PlatformEvents } from '@/lib/core/telemetry' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { getProviderIdFromServiceId } from '@/lib/oauth' import { captureServerEvent } from '@/lib/posthog/server' import { resolveEnvVarsInObject } from '@/lib/webhooks/env-resolver' import { @@ -27,12 +26,8 @@ import { } from '@/lib/webhooks/provider-subscriptions' import { getProviderHandler } from '@/lib/webhooks/providers' import { mergeNonUserFields } from '@/lib/webhooks/utils' -import { - findConflictingWebhookPathOwner, - syncWebhooksForCredentialSet, -} from '@/lib/webhooks/utils.server' +import { findConflictingWebhookPathOwner } from '@/lib/webhooks/utils.server' import { listAccessibleWorkspaceRowsForUser } from '@/lib/workspaces/utils' -import { extractCredentialSetId, isCredentialSetValue } from '@/executor/constants' const logger = createLogger('WebhooksAPI') @@ -52,7 +47,6 @@ async function revertSavedWebhook( path: existingWebhook.path, provider: existingWebhook.provider, providerConfig: existingWebhook.providerConfig, - credentialSetId: existingWebhook.credentialSetId, isActive: existingWebhook.isActive, archivedAt: existingWebhook.archivedAt, updatedAt: existingWebhook.updatedAt, @@ -367,144 +361,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { workflowRecord.workspaceId || undefined ) - // For credential sets, we fan out to create one webhook per credential at save time. - // This applies to all OAuth-based triggers, not just polling ones. - // Check for credentialSetId directly (frontend may already extract it) or credential set value in credential fields - const rawCredentialId = (resolvedProviderConfig?.credentialId || - resolvedProviderConfig?.triggerCredentials) as string | undefined - const directCredentialSetId = resolvedProviderConfig?.credentialSetId as string | undefined - - if (directCredentialSetId || rawCredentialId) { - const credentialSetId = - directCredentialSetId || - (rawCredentialId && isCredentialSetValue(rawCredentialId) - ? extractCredentialSetId(rawCredentialId) - : null) - - if (credentialSetId) { - logger.info( - `[${requestId}] Credential set detected for ${provider} trigger. Syncing webhooks for set ${credentialSetId}` - ) - - const oauthProviderId = getProviderIdFromServiceId(provider) - - const { - credentialId: _cId, - triggerCredentials: _tCred, - credentialSetId: _csId, - ...baseProviderConfig - } = resolvedProviderConfig - - try { - const syncResult = await syncWebhooksForCredentialSet({ - workflowId, - blockId: blockId || '', - provider, - basePath: finalPath, - credentialSetId, - oauthProviderId, - providerConfig: baseProviderConfig, - requestId, - }) - - if (syncResult.webhooks.length === 0) { - logger.error( - `[${requestId}] No webhooks created for credential set - no valid credentials found` - ) - return NextResponse.json( - { - error: `No valid credentials found in credential set for ${provider}`, - details: 'Please ensure team members have connected their accounts', - }, - { status: 400 } - ) - } - - const providerHandler = getProviderHandler(provider) - - if (providerHandler.configurePolling) { - const configureErrors: string[] = [] - - for (const wh of syncResult.webhooks) { - if (wh.isNew) { - const webhookRows = await db - .select() - .from(webhook) - .where(and(eq(webhook.id, wh.id), isNull(webhook.archivedAt))) - .limit(1) - - if (webhookRows.length > 0) { - const success = await providerHandler.configurePolling({ - webhook: webhookRows[0], - requestId, - }) - if (!success) { - configureErrors.push( - `Failed to configure webhook for credential ${wh.credentialId}` - ) - logger.warn( - `[${requestId}] Failed to configure ${provider} polling for webhook ${wh.id}` - ) - } - } - } - } - - if ( - configureErrors.length > 0 && - configureErrors.length === syncResult.webhooks.length - ) { - logger.error(`[${requestId}] All webhook configurations failed, rolling back`) - for (const wh of syncResult.webhooks) { - await db.delete(webhook).where(eq(webhook.id, wh.id)) - } - return NextResponse.json( - { - error: `Failed to configure ${provider} polling`, - details: 'Please check account permissions and try again', - }, - { status: 500 } - ) - } - } - - logger.info( - `[${requestId}] Successfully synced ${syncResult.webhooks.length} webhooks for credential set ${credentialSetId}` - ) - - // Return the first webhook as the "primary" for the UI - // The UI will query by credentialSetId to get all of them - const primaryWebhookRows = await db - .select() - .from(webhook) - .where(and(eq(webhook.id, syncResult.webhooks[0].id), isNull(webhook.archivedAt))) - .limit(1) - - return NextResponse.json( - { - webhook: primaryWebhookRows[0], - credentialSetInfo: { - credentialSetId, - totalWebhooks: syncResult.webhooks.length, - created: syncResult.created, - updated: syncResult.updated, - deleted: syncResult.deleted, - }, - }, - { status: syncResult.created > 0 ? 201 : 200 } - ) - } catch (err) { - logger.error(`[${requestId}] Error syncing webhooks for credential set`, err) - return NextResponse.json( - { - error: `Failed to configure ${provider} webhook`, - details: getErrorMessage(err, 'Unknown error'), - }, - { status: 500 } - ) - } - } - } let externalSubscriptionCreated = false const createTempWebhookData = (providerConfigOverride = resolvedProviderConfig) => ({ id: targetWebhookId || generateShortId(), @@ -580,8 +436,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { blockId, provider, providerConfig: configToSave, - credentialSetId: - ((configToSave as Record)?.credentialSetId as string | null) || null, isActive: true, updatedAt: new Date(), }) @@ -605,8 +459,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { path: finalPath, provider, providerConfig: configToSave, - credentialSetId: - ((configToSave as Record)?.credentialSetId as string | null) || null, isActive: true, createdAt: new Date(), updatedAt: new Date(), diff --git a/apps/sim/app/api/webhooks/trigger/[path]/route.ts b/apps/sim/app/api/webhooks/trigger/[path]/route.ts index f43807815bc..1c103ddad72 100644 --- a/apps/sim/app/api/webhooks/trigger/[path]/route.ts +++ b/apps/sim/app/api/webhooks/trigger/[path]/route.ts @@ -104,7 +104,7 @@ async function handleWebhookPost( return challengeResponse } - // Find all webhooks for this path (supports credential set fan-out where multiple webhooks share a path) + // Find all webhooks for this path (multiple webhooks in one workflow may share a path) const allWebhooksForPath = await findAllWebhooksForPath({ requestId, path }) // Internal trigger providers (sim, table) are fired in-process, never over @@ -134,8 +134,7 @@ async function handleWebhookPost( return new NextResponse('Not Found', { status: 404 }) } - // Process each webhook - // For credential sets with shared paths, each webhook represents a different credential + // Process each webhook matched on this path const responses: NextResponse[] = [] let billingBlocked = false @@ -229,9 +228,7 @@ async function handleWebhookPost( } // For multiple webhooks, return success if at least one succeeded - logger.info( - `[${requestId}] Processed ${responses.length} webhooks for path: ${path} (credential set fan-out)` - ) + logger.info(`[${requestId}] Processed ${responses.length} webhooks for path: ${path}`) return NextResponse.json({ success: true, webhooksProcessed: responses.length, diff --git a/apps/sim/app/credential-account/[token]/loading.tsx b/apps/sim/app/credential-account/[token]/loading.tsx deleted file mode 100644 index 275aa3b854d..00000000000 --- a/apps/sim/app/credential-account/[token]/loading.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { Skeleton } from '@sim/emcn' - -export default function CredentialAccountLoading() { - return ( -
-
-
-
- - - - - -
-
-
-
- ) -} diff --git a/apps/sim/app/credential-account/[token]/page.tsx b/apps/sim/app/credential-account/[token]/page.tsx deleted file mode 100644 index a9ba15dca2a..00000000000 --- a/apps/sim/app/credential-account/[token]/page.tsx +++ /dev/null @@ -1,275 +0,0 @@ -'use client' - -import { useCallback, useEffect, useState } from 'react' -import { createLogger } from '@sim/logger' -import { Mail } from 'lucide-react' -import { useParams, useRouter } from 'next/navigation' -import { GmailIcon, OutlookIcon } from '@/components/icons' -import { ApiClientError } from '@/lib/api/client/errors' -import { requestJson } from '@/lib/api/client/request' -import { - acceptCredentialSetInvitationContract, - type CredentialSetInvitePreview, - getCredentialSetInvitationContract, -} from '@/lib/api/contracts' -import { listOAuthConnectionsContract } from '@/lib/api/contracts/oauth-connections' -import { client, useSession } from '@/lib/auth/auth-client' -import { getProviderDisplayName, isPollingProvider } from '@/lib/credential-sets/providers' -import { InviteLayout, InviteStatusCard } from '@/app/invite/components' - -const logger = createLogger('CredentialAccount') - -type AcceptedState = 'connecting' | 'already-connected' - -function getErrorMessageFromBody(body: unknown): string | undefined { - if (!body || typeof body !== 'object') return undefined - const error = (body as { error?: unknown }).error - return typeof error === 'string' ? error : undefined -} - -export default function CredentialAccountInvitePage() { - const params = useParams() - const router = useRouter() - const token = params.token as string - - const { data: session, isPending: sessionLoading } = useSession() - - const [invitation, setInvitation] = useState(null) - const [loading, setLoading] = useState(true) - const [error, setError] = useState(null) - const [accepting, setAccepting] = useState(false) - const [acceptedState, setAcceptedState] = useState(null) - - useEffect(() => { - async function fetchInvitation() { - try { - const data = await requestJson(getCredentialSetInvitationContract, { - params: { token }, - }) - setInvitation(data.invitation) - } catch (fetchError) { - if (fetchError instanceof ApiClientError) { - setError(getErrorMessageFromBody(fetchError.body) || fetchError.message) - } else { - setError('Failed to load invitation') - } - } finally { - setLoading(false) - } - } - - fetchInvitation() - }, [token]) - - const handleAccept = useCallback(async () => { - if (!session?.user?.id) { - // Include invite_flow=true so the login page preserves callbackUrl when linking to signup - const callbackUrl = encodeURIComponent(`/credential-account/${token}`) - router.push(`/login?invite_flow=true&callbackUrl=${callbackUrl}`) - return - } - - setAccepting(true) - try { - const acceptResponse = await requestJson(acceptCredentialSetInvitationContract, { - params: { token }, - }).catch((acceptError: unknown) => { - const fallback = 'Failed to accept invitation' - if (acceptError instanceof ApiClientError) { - setError(getErrorMessageFromBody(acceptError.body) || acceptError.message || fallback) - } else { - setError(fallback) - } - return null - }) - - if (!acceptResponse) { - return - } - - const credentialSetProviderId = acceptResponse.providerId || invitation?.providerId - - // Check if user already has this provider connected - let isAlreadyConnected = false - if (credentialSetProviderId && isPollingProvider(credentialSetProviderId)) { - try { - const connectionsData = await requestJson(listOAuthConnectionsContract, {}) - isAlreadyConnected = (connectionsData.connections ?? []).some( - (conn) => - conn.provider === credentialSetProviderId && conn.accounts && conn.accounts.length > 0 - ) - } catch { - // If we can't check connections, proceed with OAuth flow - } - } - - if (isAlreadyConnected) { - // Already connected - redirect to workspace - setAcceptedState('already-connected') - setTimeout(() => { - router.push('/workspace') - }, 2000) - } else if (credentialSetProviderId && isPollingProvider(credentialSetProviderId)) { - // Not connected - start OAuth flow - setAcceptedState('connecting') - - // Small delay to show success message before redirect - setTimeout(async () => { - try { - await client.oauth2.link({ - providerId: credentialSetProviderId, - callbackURL: `${window.location.origin}/workspace`, - }) - } catch (oauthError) { - // OAuth redirect will happen, this catch is for any pre-redirect errors - logger.error('OAuth initiation error:', oauthError) - // If OAuth fails, redirect to workspace where they can connect manually - router.push('/workspace') - } - }, 1500) - } else { - // No provider specified - just redirect to workspace - router.push('/workspace') - } - } catch { - setError('Failed to accept invitation') - } finally { - setAccepting(false) - } - }, [session?.user?.id, token, router, invitation?.providerId]) - - const providerName = invitation?.providerId - ? getProviderDisplayName(invitation.providerId) - : 'email' - - const ProviderIcon = - invitation?.providerId === 'outlook' - ? OutlookIcon - : invitation?.providerId === 'google-email' - ? GmailIcon - : Mail - - const providerWithIcon = ( - - - {providerName} - - ) - - const getCallbackUrl = () => `/credential-account/${token}` - - if (loading || sessionLoading) { - return ( - - - - ) - } - - if (error) { - return ( - - router.push('/'), - }, - ]} - /> - - ) - } - - if (acceptedState === 'already-connected') { - return ( - - - - ) - } - - if (acceptedState === 'connecting') { - return ( - - - - ) - } - - // Not logged in - if (!session?.user) { - const callbackUrl = encodeURIComponent(getCallbackUrl()) - - return ( - - router.push(`/login?callbackUrl=${callbackUrl}&invite_flow=true`), - }, - { - label: 'Create an account', - onClick: () => - router.push(`/signup?callbackUrl=${callbackUrl}&invite_flow=true&new=true`), - }, - { - label: 'Return to Home', - onClick: () => router.push('/'), - }, - ]} - /> - - ) - } - - // Logged in - show invitation - return ( - - - You've been invited to join {invitation?.credentialSetName} by{' '} - {invitation?.organizationName}. - {invitation?.providerId && ( - <> You'll be asked to connect your {providerWithIcon} account after accepting. - )} - - } - icon='mail' - actions={[ - { - label: `Accept & Connect ${providerName}`, - onClick: handleAccept, - disabled: accepting, - loading: accepting, - }, - { - label: 'Return to Home', - onClick: () => router.push('/'), - }, - ]} - /> - - ) -} diff --git a/apps/sim/app/robots.ts b/apps/sim/app/robots.ts index e2aef2bb753..ca4b523265f 100644 --- a/apps/sim/app/robots.ts +++ b/apps/sim/app/robots.ts @@ -9,7 +9,6 @@ const DISALLOWED_PATHS = [ '/invite/', '/unsubscribe/', '/w/', - '/credential-account/', '/_next/', '/private/', ] diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx b/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx index 88a3ec5f519..f0ac5684c16 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx @@ -35,7 +35,6 @@ const SECTION_TITLES: Record = { mcp: 'MCP Tools', 'custom-tools': 'Custom Tools', 'workflow-mcp-servers': 'MCP Servers', - 'credential-sets': 'Email Polling', 'data-retention': 'Data Retention', 'recently-deleted': 'Recently Deleted', debug: 'Debug', diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx b/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx index b2fc4a7daa5..dc181b229b6 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx @@ -9,10 +9,7 @@ import { General } from '@/app/workspace/[workspaceId]/settings/components/gener import { SettingsSectionProvider } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' import { useSettingsBeforeUnload } from '@/app/workspace/[workspaceId]/settings/hooks/use-settings-before-unload' import type { SettingsSection } from '@/app/workspace/[workspaceId]/settings/navigation' -import { - isBillingEnabled, - isCredentialSetsEnabled, -} from '@/app/workspace/[workspaceId]/settings/navigation' +import { isBillingEnabled } from '@/app/workspace/[workspaceId]/settings/navigation' const Admin = dynamic(() => import('@/app/workspace/[workspaceId]/settings/components/admin/admin').then((m) => m.Admin) @@ -28,11 +25,6 @@ const BYOK = dynamic(() => const Copilot = dynamic(() => import('@/app/workspace/[workspaceId]/settings/components/copilot/copilot').then((m) => m.Copilot) ) -const CredentialSets = dynamic(() => - import('@/app/workspace/[workspaceId]/settings/components/credential-sets/credential-sets').then( - (m) => m.CredentialSets - ) -) const Secrets = dynamic(() => import('@/app/workspace/[workspaceId]/settings/components/secrets/secrets').then((m) => m.Secrets) ) @@ -114,13 +106,11 @@ export function SettingsPage({ section }: SettingsPageProps) { const effectiveSection = !isBillingEnabled && (normalizedSection === 'billing' || normalizedSection === 'organization') ? 'general' - : normalizedSection === 'credential-sets' && !isCredentialSetsEnabled + : normalizedSection === 'admin' && !sessionLoading && !isAdminRole ? 'general' - : normalizedSection === 'admin' && !sessionLoading && !isAdminRole + : normalizedSection === 'mothership' && !sessionLoading && !isAdminRole ? 'general' - : normalizedSection === 'mothership' && !sessionLoading && !isAdminRole - ? 'general' - : normalizedSection + : normalizedSection useEffect(() => { if (sessionLoading) return @@ -131,7 +121,6 @@ export function SettingsPage({ section }: SettingsPageProps) { {effectiveSection === 'general' && } {effectiveSection === 'secrets' && } - {effectiveSection === 'credential-sets' && } {effectiveSection === 'access-control' && } {effectiveSection === 'audit-logs' && } {effectiveSection === 'apikeys' && } diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/credential-sets/credential-sets.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/credential-sets/credential-sets.tsx deleted file mode 100644 index 22728319e20..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/credential-sets/credential-sets.tsx +++ /dev/null @@ -1,818 +0,0 @@ -'use client' - -import { useCallback, useMemo, useState } from 'react' -import { - Avatar, - AvatarFallback, - AvatarImage, - Badge, - ButtonGroup, - ButtonGroupItem, - Chip, - ChipConfirmModal, - ChipModal, - ChipModalBody, - ChipModalError, - ChipModalField, - ChipModalFooter, - ChipModalHeader, - type FileInputOptions, - TagInput, - type TagItem, -} from '@sim/emcn' -import { ArrowLeft } from '@sim/emcn/icons' -import { createLogger } from '@sim/logger' -import { isOrgAdminRole } from '@sim/platform-authz/predicates' -import { Plus } from 'lucide-react' -import { GmailIcon, OutlookIcon } from '@/components/icons' -import { useSession } from '@/lib/auth/auth-client' -import { getSubscriptionAccessState } from '@/lib/billing/client' -import { getProviderDisplayName, type PollingProvider } from '@/lib/credential-sets/providers' -import { quickValidateEmail } from '@/lib/messaging/email/validation' -import { getUserColor } from '@/lib/workspaces/colors' -import { getUserRole } from '@/lib/workspaces/organization' -import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu' -import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' -import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' -import { SettingsResourceRow } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' -import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' -import { - type CredentialSet, - useAcceptCredentialSetInvitation, - useCancelCredentialSetInvitation, - useCreateCredentialSet, - useCreateCredentialSetInvitation, - useCredentialSetInvitations, - useCredentialSetInvitationsDetail, - useCredentialSetMembers, - useCredentialSetMemberships, - useCredentialSets, - useDeleteCredentialSet, - useLeaveCredentialSet, - useRemoveCredentialSetMember, - useResendCredentialSetInvitation, -} from '@/hooks/queries/credential-sets' -import { useOrganizations } from '@/hooks/queries/organization' -import { useSubscriptionData } from '@/hooks/queries/subscription' - -const logger = createLogger('EmailPolling') - -function getProviderIcon(providerId: string | null) { - if (providerId === 'outlook') return - return -} - -export function CredentialSets() { - const { data: session } = useSession() - const { data: organizationsData } = useOrganizations() - const { data: subscriptionData } = useSubscriptionData() - - const activeOrganization = organizationsData?.activeOrganization - const subscriptionAccess = getSubscriptionAccessState(subscriptionData?.data) - const hasTeamPlan = subscriptionAccess.hasUsableTeamAccess - const userRole = getUserRole(activeOrganization, session?.user?.email) - const isAdmin = isOrgAdminRole(userRole) - const canManageCredentialSets = hasTeamPlan && isAdmin && !!activeOrganization?.id - - const { data: memberships = [], isPending: membershipsLoading } = useCredentialSetMemberships() - const { data: invitations = [], isPending: invitationsLoading } = useCredentialSetInvitations() - const { data: ownedSets = [], isPending: ownedSetsLoading } = useCredentialSets( - activeOrganization?.id, - canManageCredentialSets - ) - - const acceptInvitation = useAcceptCredentialSetInvitation() - const createCredentialSet = useCreateCredentialSet() - const createInvitation = useCreateCredentialSetInvitation() - - const [searchTerm, setSearchTerm] = useState('') - const [showCreateModal, setShowCreateModal] = useState(false) - const [viewingSet, setViewingSet] = useState(null) - const [newSetName, setNewSetName] = useState('') - const [newSetDescription, setNewSetDescription] = useState('') - const [newSetProvider, setNewSetProvider] = useState('google-email') - const [createError, setCreateError] = useState(null) - const [emailItems, setEmailItems] = useState([]) - const [emailError, setEmailError] = useState(null) - const [leavingMembership, setLeavingMembership] = useState<{ - credentialSetId: string - name: string - } | null>(null) - - const { data: members = [], isPending: membersLoading } = useCredentialSetMembers(viewingSet?.id) - const { data: pendingInvitations = [], isPending: pendingInvitationsLoading } = - useCredentialSetInvitationsDetail(viewingSet?.id) - const removeMember = useRemoveCredentialSetMember() - const leaveCredentialSet = useLeaveCredentialSet() - const deleteCredentialSet = useDeleteCredentialSet() - const cancelInvitation = useCancelCredentialSetInvitation() - const resendInvitation = useResendCredentialSetInvitation() - - const [deletingSet, setDeletingSet] = useState<{ id: string; name: string } | null>(null) - const [deletingSetIds, setDeletingSetIds] = useState>(() => new Set()) - const [cancellingInvitations, setCancellingInvitations] = useState>(() => new Set()) - const [resendingInvitations, setResendingInvitations] = useState>(() => new Set()) - const [resendCooldowns, setResendCooldowns] = useState>({}) - - const addEmail = useCallback( - (email: string) => { - if (!email.trim()) return false - - const normalized = email.trim().toLowerCase() - const validation = quickValidateEmail(normalized) - const isValid = validation.isValid - - if (emailItems.some((item) => item.value === normalized)) { - return false - } - - const isPendingInvitation = pendingInvitations.some( - (inv) => inv.email?.toLowerCase() === normalized - ) - if (isPendingInvitation) { - setEmailError(`${normalized} already has a pending invitation`) - return false - } - - const isActiveMember = members.some( - (m) => m.userEmail?.toLowerCase() === normalized && m.status === 'active' - ) - if (isActiveMember) { - setEmailError(`${normalized} is already a member of this group`) - return false - } - - setEmailItems((prev) => [ - ...prev, - { - value: normalized, - isValid, - error: isValid ? undefined : (validation.reason ?? 'Invalid email format'), - }, - ]) - - if (isValid) { - setEmailError(null) - } - - return isValid - }, - [emailItems, pendingInvitations, members] - ) - - const removeEmailItem = useCallback((_value: string, index: number, _isValid: boolean) => { - setEmailItems((prev) => prev.filter((_, i) => i !== index)) - }, []) - - const fileInputOptions: FileInputOptions = useMemo( - () => ({ - enabled: true, - accept: '.csv,.txt,text/csv,text/plain', - extractValues: (text: string) => { - const emailRegex = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g - const matches = text.match(emailRegex) || [] - return [...new Set(matches.map((e) => e.toLowerCase()))] - }, - }), - [] - ) - - const handleRemoveMember = useCallback( - async (memberId: string) => { - if (!viewingSet) return - try { - await removeMember.mutateAsync({ - credentialSetId: viewingSet.id, - memberId, - }) - } catch (error) { - logger.error('Failed to remove member', error) - } - }, - [viewingSet, removeMember] - ) - - const handleLeave = useCallback((credentialSetId: string, name: string) => { - setLeavingMembership({ credentialSetId, name }) - }, []) - - const confirmLeave = useCallback(async () => { - if (!leavingMembership) return - try { - await leaveCredentialSet.mutateAsync(leavingMembership.credentialSetId) - setLeavingMembership(null) - } catch (error) { - logger.error('Failed to leave polling group', error) - } - }, [leavingMembership, leaveCredentialSet]) - - const handleAcceptInvitation = useCallback( - async (token: string) => { - try { - await acceptInvitation.mutateAsync(token) - } catch (error) { - logger.error('Failed to accept invitation', error) - } - }, - [acceptInvitation] - ) - - const handleCreateCredentialSet = useCallback(async () => { - if (!newSetName.trim() || !activeOrganization?.id) return - setCreateError(null) - try { - const result = await createCredentialSet.mutateAsync({ - organizationId: activeOrganization.id, - name: newSetName.trim(), - description: newSetDescription.trim() || undefined, - providerId: newSetProvider, - }) - setShowCreateModal(false) - setNewSetName('') - setNewSetDescription('') - setNewSetProvider('google-email') - - if (result?.credentialSet) { - setViewingSet(result.credentialSet) - } - } catch (error) { - logger.error('Failed to create polling group', error) - if (error instanceof Error) { - setCreateError(error.message) - } else { - setCreateError('Failed to create polling group') - } - } - }, [newSetName, newSetDescription, newSetProvider, activeOrganization?.id, createCredentialSet]) - - const validEmails = useMemo(() => { - const result: string[] = [] - for (const item of emailItems) { - if (item.isValid) result.push(item.value) - } - return result - }, [emailItems]) - - const handleInviteMembers = useCallback(async () => { - if (!viewingSet?.id) return - - if (validEmails.length === 0) return - - try { - for (const email of validEmails) { - await createInvitation.mutateAsync({ - credentialSetId: viewingSet.id, - email, - }) - } - setEmailItems([]) - setEmailError(null) - } catch (error) { - logger.error('Failed to create invitations', error) - } - }, [viewingSet?.id, validEmails, createInvitation]) - - const handleCloseCreateModal = useCallback(() => { - setShowCreateModal(false) - setNewSetName('') - setNewSetDescription('') - setNewSetProvider('google-email') - setCreateError(null) - }, []) - - const handleBackToList = useCallback(() => { - setViewingSet(null) - setEmailItems([]) - setEmailError(null) - }, []) - - const handleCancelInvitation = useCallback( - async (invitationId: string) => { - if (!viewingSet?.id) return - - setCancellingInvitations((prev) => new Set([...prev, invitationId])) - try { - await cancelInvitation.mutateAsync({ - credentialSetId: viewingSet.id, - invitationId, - }) - } catch (error) { - logger.error('Failed to cancel invitation', error) - } finally { - setCancellingInvitations((prev) => { - const next = new Set(prev) - next.delete(invitationId) - return next - }) - } - }, - [viewingSet?.id, cancelInvitation] - ) - - const handleResendInvitation = useCallback( - async (invitationId: string, email: string) => { - if (!viewingSet?.id) return - - const secondsLeft = resendCooldowns[invitationId] - if (secondsLeft && secondsLeft > 0) return - - setResendingInvitations((prev) => new Set([...prev, invitationId])) - try { - await resendInvitation.mutateAsync({ - credentialSetId: viewingSet.id, - invitationId, - email, - }) - - setResendCooldowns((prev) => ({ ...prev, [invitationId]: 60 })) - const interval = setInterval(() => { - setResendCooldowns((prev) => { - const current = prev[invitationId] - if (current === undefined) return prev - if (current <= 1) { - const next = { ...prev } - delete next[invitationId] - clearInterval(interval) - return next - } - return { ...prev, [invitationId]: current - 1 } - }) - }, 1000) - } catch (error) { - logger.error('Failed to resend invitation', error) - } finally { - setResendingInvitations((prev) => { - const next = new Set(prev) - next.delete(invitationId) - return next - }) - } - }, - [viewingSet?.id, resendInvitation, resendCooldowns] - ) - - const handleDeleteClick = useCallback((set: CredentialSet) => { - setDeletingSet({ id: set.id, name: set.name }) - }, []) - - const confirmDelete = useCallback(async () => { - if (!deletingSet || !activeOrganization?.id) return - setDeletingSetIds((prev) => new Set(prev).add(deletingSet.id)) - try { - await deleteCredentialSet.mutateAsync({ - credentialSetId: deletingSet.id, - organizationId: activeOrganization.id, - }) - setDeletingSet(null) - } catch (error) { - logger.error('Failed to delete polling group', error) - } finally { - setDeletingSetIds((prev) => { - const next = new Set(prev) - next.delete(deletingSet.id) - return next - }) - } - }, [deletingSet, activeOrganization?.id, deleteCredentialSet]) - - const activeMemberships = useMemo( - () => memberships.filter((m) => m.status === 'active'), - [memberships] - ) - - const filteredInvitations = useMemo(() => { - if (!searchTerm.trim()) return invitations - const searchLower = searchTerm.toLowerCase() - return invitations.filter( - (inv) => - inv.credentialSetName.toLowerCase().includes(searchLower) || - inv.organizationName.toLowerCase().includes(searchLower) - ) - }, [invitations, searchTerm]) - - const filteredMemberships = useMemo(() => { - if (!searchTerm.trim()) return activeMemberships - const searchLower = searchTerm.toLowerCase() - return activeMemberships.filter( - (m) => - m.credentialSetName.toLowerCase().includes(searchLower) || - m.organizationName.toLowerCase().includes(searchLower) - ) - }, [activeMemberships, searchTerm]) - - const filteredOwnedSets = useMemo(() => { - if (!searchTerm.trim()) return ownedSets - const searchLower = searchTerm.toLowerCase() - return ownedSets.filter((set) => set.name.toLowerCase().includes(searchLower)) - }, [ownedSets, searchTerm]) - - const hasNoContent = - invitations.length === 0 && activeMemberships.length === 0 && ownedSets.length === 0 - const hasNoResults = - searchTerm.trim() && - filteredInvitations.length === 0 && - filteredMemberships.length === 0 && - filteredOwnedSets.length === 0 && - !hasNoContent - - if (membershipsLoading || invitationsLoading) { - return null - } - - if (viewingSet) { - const activeMembers = members.filter((m) => m.status === 'active') - const totalCount = activeMembers.length + pendingInvitations.length - - return ( - -
- -
-
- Group Name - {viewingSet.name} -
-
-
- Provider -
- {getProviderIcon(viewingSet.providerId)} - - {getProviderDisplayName(viewingSet.providerId as PollingProvider)} - -
-
-
- - - -
-
- addEmail(value)} - onRemove={removeEmailItem} - placeholder='Enter email addresses' - placeholderWithTags='Add another email' - disabled={createInvitation.isPending} - fileInputOptions={fileInputOptions} - className='flex-1' - /> - - {createInvitation.isPending ? 'Sending...' : 'Invite'} - -
- {emailError &&

{emailError}

} -
-
- - - {membersLoading || pendingInvitationsLoading ? null : totalCount === 0 ? ( -

- No members yet. Send invitations above. -

- ) : ( -
- {activeMembers.map((member) => { - const name = member.userName || 'Unknown' - const avatarInitial = name.charAt(0).toUpperCase() - - return ( -
-
- - {member.userImage && } - - {avatarInitial} - - - -
-
- - {name} - - {member.credentials.length === 0 && ( - - Disconnected - - )} -
-
- {member.userEmail} -
-
-
- -
- handleRemoveMember(member.id), - }, - ]} - /> -
-
- ) - })} - - {pendingInvitations.map((invitation) => { - const email = invitation.email || 'Unknown' - const emailPrefix = email.split('@')[0] - const avatarInitial = emailPrefix.charAt(0).toUpperCase() - - return ( -
-
- - - {avatarInitial} - - - -
-
- - {emailPrefix} - - - Pending - -
-
- {email} -
-
-
- -
- 0, - onSelect: () => handleResendInvitation(invitation.id, email), - }, - { - label: cancellingInvitations.has(invitation.id) - ? 'Cancelling...' - : 'Cancel', - destructive: true, - disabled: cancellingInvitations.has(invitation.id), - onSelect: () => handleCancelInvitation(invitation.id), - }, - ]} - /> -
-
- ) - })} -
- )} -
-
- - ) - } - - return ( - <> - setShowCreateModal(true), - }, - ] - : undefined - } - > -
- {hasNoContent && !canManageCredentialSets ? ( - - You're not a member of any polling groups yet. When someone invites you, it will - appear here. - - ) : hasNoResults ? ( - - No results found matching "{searchTerm}" - - ) : ( -
- {filteredInvitations.length > 0 && ( - -
- {filteredInvitations.map((invitation) => ( - handleAcceptInvitation(invitation.token)} - disabled={acceptInvitation.isPending} - > - {acceptInvitation.isPending ? 'Accepting...' : 'Accept'} - - } - /> - ))} -
-
- )} - - {filteredMemberships.length > 0 && ( - -
- {filteredMemberships.map((membership) => ( - - handleLeave(membership.credentialSetId, membership.credentialSetName) - } - disabled={leaveCredentialSet.isPending} - > - Leave - - } - /> - ))} -
-
- )} - - {canManageCredentialSets && - (filteredOwnedSets.length > 0 || - ownedSetsLoading || - (!searchTerm.trim() && ownedSets.length === 0)) && ( - - {ownedSetsLoading ? null : !searchTerm.trim() && ownedSets.length === 0 ? ( -
- No polling groups created yet -
- ) : ( -
- {filteredOwnedSets.map((set) => ( - setViewingSet(set) }, - { - label: 'Delete', - destructive: true, - disabled: deletingSetIds.has(set.id), - onSelect: () => handleDeleteClick(set), - }, - ]} - /> - } - /> - ))} -
- )} -
- )} -
- )} -
-
- - - Create Polling Group - - { - setNewSetName(value) - if (createError) setCreateError(null) - }} - required - placeholder='e.g., Marketing Team' - /> - - - setNewSetProvider(v as PollingProvider)} - > - Gmail - Outlook - -

- Members will connect their {getProviderDisplayName(newSetProvider)} account -

-
- {createError} -
- -
- - { - if (!open) setLeavingMembership(null) - }} - srTitle='Leave Polling Group' - title='Leave Polling Group' - text={[ - 'Are you sure you want to leave ', - { text: leavingMembership?.name ?? 'this group', bold: true }, - '? Your email account will no longer be polled in workflows using this group.', - ]} - confirm={{ - label: 'Leave', - onClick: confirmLeave, - pending: leaveCredentialSet.isPending, - pendingLabel: 'Leaving...', - }} - /> - - { - if (!open) setDeletingSet(null) - }} - srTitle='Delete Polling Group' - title='Delete Polling Group' - text={[ - 'Are you sure you want to delete ', - { text: deletingSet?.name ?? 'this group', bold: true }, - '? This action cannot be undone.', - ]} - confirm={{ - label: 'Delete', - onClick: confirmDelete, - pending: deleteCredentialSet.isPending, - pendingLabel: 'Deleting...', - }} - /> - - ) -} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/credential-sets/index.ts b/apps/sim/app/workspace/[workspaceId]/settings/components/credential-sets/index.ts deleted file mode 100644 index eb992fc6b13..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/credential-sets/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { CredentialSets } from './credential-sets' diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/settings-resource-row/settings-resource-row.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/settings-resource-row/settings-resource-row.tsx index a3bf1b764d6..51a7dbd9619 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/settings-resource-row/settings-resource-row.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/settings-resource-row/settings-resource-row.tsx @@ -6,7 +6,7 @@ import type { ReactNode } from 'react' * (action chips, a {@link RowActionsMenu}, a status label, etc.). * * Single source of truth for the credential-style row shared by the BYOK key - * manager, credential sets, and recently-deleted lists — never re-derive the + * manager and recently-deleted lists — never re-derive the * tile/text chrome per consumer. The tile force-sizes any ``/`` it * contains to 20px, so callers pass their raw icon node without pre-sizing it. */ diff --git a/apps/sim/app/workspace/[workspaceId]/settings/navigation.ts b/apps/sim/app/workspace/[workspaceId]/settings/navigation.ts index 538e7182899..5cbe90e4780 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/navigation.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/navigation.ts @@ -6,7 +6,6 @@ import { KeySquare, Lock, LogIn, - Mail, Palette, Send, Server, @@ -25,7 +24,6 @@ import { getEnv, isTruthy } from '@/lib/core/config/env' export type SettingsSection = | 'general' | 'secrets' - | 'credential-sets' | 'access-control' | 'audit-logs' | 'apikeys' @@ -79,7 +77,6 @@ export interface NavigationItem { } const isSSOEnabled = isTruthy(getEnv('NEXT_PUBLIC_SSO_ENABLED')) -const isCredentialSetsEnabled = isTruthy(getEnv('NEXT_PUBLIC_CREDENTIAL_SETS_ENABLED')) const isAccessControlEnabled = isTruthy(getEnv('NEXT_PUBLIC_ACCESS_CONTROL_ENABLED')) const isInboxEnabled = isTruthy(getEnv('NEXT_PUBLIC_INBOX_ENABLED')) const isWhitelabelingEnabled = isTruthy(getEnv('NEXT_PUBLIC_WHITELABELING_ENABLED')) @@ -88,7 +85,6 @@ const isDataRetentionEnabled = isTruthy(getEnv('NEXT_PUBLIC_DATA_RETENTION_ENABL const isDataDrainsEnabled = isTruthy(getEnv('NEXT_PUBLIC_DATA_DRAINS_ENABLED')) export const isBillingEnabled = isTruthy(getEnv('NEXT_PUBLIC_BILLING_ENABLED')) -export { isCredentialSetsEnabled } export const sectionConfig: { key: NavigationSection; title: string }[] = [ { key: 'account', title: 'Account' }, @@ -216,17 +212,6 @@ export const allNavigationItems: NavigationItem[] = [ selfHostedOverride: isInboxEnabled, showWhenLocked: true, }, - ...(isCredentialSetsEnabled - ? [ - { - id: 'credential-sets' as const, - label: 'Email polling', - description: 'Share email-polling credentials across your team.', - icon: Mail, - section: 'system' as const, - }, - ] - : []), { id: 'recently-deleted', label: 'Recently deleted', diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx index b060f525835..72fa3638a55 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx @@ -2,10 +2,8 @@ import { useCallback, useMemo, useState } from 'react' import { Button, Combobox } from '@sim/emcn' -import { ExternalLink, KeyRound, Users } from 'lucide-react' +import { ExternalLink, KeyRound } from 'lucide-react' import { useParams } from 'next/navigation' -import { getSubscriptionAccessState } from '@/lib/billing/client' -import { getPollingProviderFromOAuth } from '@/lib/credential-sets/providers' import { consumeOAuthReturnContext, writeOAuthReturnContext } from '@/lib/credentials/client-state' import { getCanonicalScopesForProvider, @@ -16,7 +14,6 @@ import { } from '@/lib/oauth' import { getMissingRequiredScopes } from '@/lib/oauth/utils' import { ConnectOAuthModal } from '@/app/workspace/[workspaceId]/components/connect-oauth-modal' -import { isBillingEnabled } from '@/app/workspace/[workspaceId]/settings/navigation' import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text' import { getWorkflowSearchLabelHighlight } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/workflow-search-highlight' import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate' @@ -24,12 +21,8 @@ import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/c import { useActiveSearchTarget } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/providers/active-search-target-provider' import { getBareIconStyle, type StyleableIcon } from '@/blocks/icon-color' import type { SubBlockConfig } from '@/blocks/types' -import { CREDENTIAL_SET } from '@/executor/constants' -import { useCredentialSets } from '@/hooks/queries/credential-sets' import { useWorkspaceCredential, useWorkspaceCredentials } from '@/hooks/queries/credentials' import { useOAuthCredentials } from '@/hooks/queries/oauth/oauth-credentials' -import { useOrganizations } from '@/hooks/queries/organization' -import { useSubscriptionData } from '@/hooks/queries/subscription' import { useCredentialRefreshTriggers } from '@/hooks/use-credential-refresh-triggers' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' @@ -64,19 +57,6 @@ export function CredentialSelector({ const label = subBlock.placeholder || 'Select credential' const serviceId = subBlock.serviceId || '' const isAllCredentials = !serviceId - const supportsCredentialSets = subBlock.supportsCredentialSets || false - - const { data: organizationsData } = useOrganizations() - const { data: subscriptionData } = useSubscriptionData({ enabled: isBillingEnabled }) - const activeOrganization = organizationsData?.activeOrganization - const subscriptionAccess = getSubscriptionAccessState(subscriptionData?.data) - const hasTeamPlan = subscriptionAccess.hasUsableTeamAccess - const canUseCredentialSets = supportsCredentialSets && hasTeamPlan && !!activeOrganization?.id - - const { data: credentialSets = [] } = useCredentialSets( - activeOrganization?.id, - canUseCredentialSets - ) const { depsSatisfied, dependsOn } = useDependsOnGate(blockId, subBlock, { disabled, @@ -88,12 +68,7 @@ export function CredentialSelector({ const effectiveDisabled = disabled || (hasDependencies && !depsSatisfied) const effectiveValue = isPreview && previewValue !== undefined ? previewValue : storeValue - const rawSelectedId = typeof effectiveValue === 'string' ? effectiveValue : '' - const isCredentialSetSelected = rawSelectedId.startsWith(CREDENTIAL_SET.PREFIX) - const selectedId = isCredentialSetSelected ? '' : rawSelectedId - const selectedCredentialSetId = isCredentialSetSelected - ? rawSelectedId.slice(CREDENTIAL_SET.PREFIX.length) - : '' + const selectedId = typeof effectiveValue === 'string' ? effectiveValue : '' const effectiveProviderId = useMemo( () => getProviderIdFromServiceId(serviceId) as OAuthProvider, @@ -147,11 +122,6 @@ export function CredentialSelector({ [selectedCredential, selectedAllCredential] ) - const selectedCredentialSet = useMemo( - () => credentialSets.find((cs) => cs.id === selectedCredentialSetId), - [credentialSets, selectedCredentialSetId] - ) - const { data: inaccessibleCredential } = useWorkspaceCredential( selectedId || undefined, Boolean(selectedId) && @@ -163,12 +133,11 @@ export function CredentialSelector({ const inaccessibleCredentialName = inaccessibleCredential?.displayName ?? null const resolvedLabel = useMemo(() => { - if (selectedCredentialSet) return selectedCredentialSet.name if (selectedAllCredential) return selectedAllCredential.displayName if (selectedCredential) return selectedCredential.name if (inaccessibleCredentialName) return inaccessibleCredentialName return '' - }, [selectedCredentialSet, selectedAllCredential, selectedCredential, inaccessibleCredentialName]) + }, [selectedAllCredential, selectedCredential, inaccessibleCredentialName]) const displayValue = isEditing ? editingValue : resolvedLabel @@ -208,15 +177,6 @@ export function CredentialSelector({ [isPreview, setStoreValue] ) - const handleCredentialSetSelect = useCallback( - (credentialSetId: string) => { - if (isPreview) return - setStoreValue(`${CREDENTIAL_SET.PREFIX}${credentialSetId}`) - setIsEditing(false) - }, - [isPreview, setStoreValue] - ) - const handleAddCredential = useCallback(() => { setShowConnectModal(true) }, []) @@ -246,57 +206,10 @@ export function CredentialSelector({ .join(' ') }, []) - const { comboboxOptions, comboboxGroups } = useMemo(() => { + const comboboxOptions = useMemo(() => { if (isAllCredentials) { const oauthCredentials = allWorkspaceCredentials.filter((c) => c.type === 'oauth') - const options = oauthCredentials.map((cred) => ({ label: cred.displayName, value: cred.id })) - return { comboboxOptions: options, comboboxGroups: undefined } - } - - const pollingProviderId = getPollingProviderFromOAuth(effectiveProviderId) - // Handle both old ('gmail') and new ('google-email') provider IDs for backwards compatibility - const matchesProvider = (csProviderId: string | null) => { - if (!csProviderId || !pollingProviderId) return false - if (csProviderId === pollingProviderId) return true - // Handle legacy 'gmail' mapping to 'google-email' - if (pollingProviderId === 'google-email' && csProviderId === 'gmail') return true - return false - } - const filteredCredentialSets = pollingProviderId - ? credentialSets.filter((cs) => matchesProvider(cs.providerId)) - : [] - - if (canUseCredentialSets && filteredCredentialSets.length > 0) { - const groups = [] - - groups.push({ - section: 'Polling Groups', - items: filteredCredentialSets.map((cs) => ({ - label: cs.name, - value: `${CREDENTIAL_SET.PREFIX}${cs.id}`, - })), - }) - - const credentialItems = credentials.map((cred) => ({ - label: cred.name, - value: cred.id, - iconElement: getProviderIcon((cred.provider ?? provider) as OAuthProvider), - })) - credentialItems.push({ - label: - credentials.length > 0 - ? `Connect another ${getProviderName(provider)} account` - : `Connect ${getProviderName(provider)} account`, - value: '__connect_account__', - iconElement: , - }) - - groups.push({ - section: 'Personal Credential', - items: credentialItems, - }) - - return { comboboxOptions: [], comboboxGroups: groups } + return oauthCredentials.map((cred) => ({ label: cred.displayName, value: cred.id })) } const options = credentials.map((cred) => ({ @@ -314,17 +227,14 @@ export function CredentialSelector({ iconElement: , }) - return { comboboxOptions: options, comboboxGroups: undefined } + return options }, [ isAllCredentials, allWorkspaceCredentials, credentials, provider, - effectiveProviderId, getProviderIcon, getProviderName, - canUseCredentialSets, - credentialSets, ]) const selectedCredentialProvider = selectedCredential?.provider ?? provider @@ -338,19 +248,6 @@ export function CredentialSelector({ const overlayContent = useMemo(() => { if (!displayValue) return null - if (isCredentialSetSelected && selectedCredentialSet) { - return ( -
-
- -
- - {formatDisplayText(displayValue, { workflowSearchHighlight })} - -
- ) - } - if (isAllCredentials && selectedAllCredential) { return (
@@ -378,8 +275,6 @@ export function CredentialSelector({ getProviderIcon, displayValue, selectedCredentialProvider, - isCredentialSetSelected, - selectedCredentialSet, isAllCredentials, selectedAllCredential, workflowSearchHighlight, @@ -392,15 +287,6 @@ export function CredentialSelector({ return } - if (value.startsWith(CREDENTIAL_SET.PREFIX)) { - const credentialSetId = value.slice(CREDENTIAL_SET.PREFIX.length) - const matchedSet = credentialSets.find((cs) => cs.id === credentialSetId) - if (matchedSet) { - handleCredentialSetSelect(credentialSetId) - return - } - } - const matchedCred = ( isAllCredentials ? allWorkspaceCredentials.filter((c) => c.type === 'oauth') : credentials ).find((c) => c.id === value) @@ -412,24 +298,15 @@ export function CredentialSelector({ setIsEditing(true) setEditingValue(value) }, - [ - isAllCredentials, - allWorkspaceCredentials, - credentials, - credentialSets, - handleAddCredential, - handleSelect, - handleCredentialSetSelect, - ] + [isAllCredentials, allWorkspaceCredentials, credentials, handleAddCredential, handleSelect] ) return (
- Hello, - - {inviterName} from {organizationName} has invited you to - join the polling group {pollingGroupName} on {brand.name}. - - - - By accepting this invitation, your {providerName} account will be connected to enable email - polling for automated workflows. - - - - Accept Invitation - - - {/* Divider */} -
- - - This invitation expires in 7 days. If you weren't expecting this email, you can safely - ignore it. - - - ) -} - -export default PollingGroupInvitationEmail diff --git a/apps/sim/components/emails/render.ts b/apps/sim/components/emails/render.ts index 3747122ce49..652d6e7df2a 100644 --- a/apps/sim/components/emails/render.ts +++ b/apps/sim/components/emails/render.ts @@ -20,7 +20,6 @@ import { import { BatchInvitationEmail, InvitationEmail, - PollingGroupInvitationEmail, WorkspaceAddedEmail, WorkspaceInvitationEmail, } from '@/components/emails/invitations' @@ -244,24 +243,6 @@ export async function renderWorkspaceAddedEmail( ) } -export async function renderPollingGroupInvitationEmail(params: { - inviterName: string - organizationName: string - pollingGroupName: string - provider: 'google-email' | 'outlook' - inviteLink: string -}): Promise { - return await render( - PollingGroupInvitationEmail({ - inviterName: params.inviterName, - organizationName: params.organizationName, - pollingGroupName: params.pollingGroupName, - provider: params.provider, - inviteLink: params.inviteLink, - }) - ) -} - export async function renderPaymentFailedEmail(params: { userName?: string amountDue: number diff --git a/apps/sim/components/emails/subjects.ts b/apps/sim/components/emails/subjects.ts index 5714f2e2529..8eeb9b06fdf 100644 --- a/apps/sim/components/emails/subjects.ts +++ b/apps/sim/components/emails/subjects.ts @@ -12,7 +12,6 @@ export type EmailSubjectType = | 'invitation' | 'batch-invitation' | 'workspace-added' - | 'polling-group-invitation' | 'help-confirmation' | 'enterprise-subscription' | 'usage-threshold' @@ -52,8 +51,6 @@ export function getEmailSubject(type: EmailSubjectType): string { return `You've been invited to join a team and workspaces on ${brandName}` case 'workspace-added': return `You've been added to a workspace on ${brandName}` - case 'polling-group-invitation': - return `You've been invited to join an email polling group on ${brandName}` case 'help-confirmation': return 'Your request has been received' case 'enterprise-subscription': diff --git a/apps/sim/executor/constants.ts b/apps/sim/executor/constants.ts index 08fdf93a171..b5883c61905 100644 --- a/apps/sim/executor/constants.ts +++ b/apps/sim/executor/constants.ts @@ -221,18 +221,6 @@ export const MCP = { TOOL_PREFIX: 'mcp-', } as const -export const CREDENTIAL_SET = { - PREFIX: 'credentialSet:', -} as const - -export function isCredentialSetValue(value: string | null | undefined): boolean { - return typeof value === 'string' && value.startsWith(CREDENTIAL_SET.PREFIX) -} - -export function extractCredentialSetId(value: string): string { - return value.slice(CREDENTIAL_SET.PREFIX.length) -} - export const MEMORY = { DEFAULT_SLIDING_WINDOW_SIZE: 10, DEFAULT_SLIDING_WINDOW_TOKENS: 4000, diff --git a/apps/sim/hooks/queries/credential-sets.ts b/apps/sim/hooks/queries/credential-sets.ts deleted file mode 100644 index a984e339eea..00000000000 --- a/apps/sim/hooks/queries/credential-sets.ts +++ /dev/null @@ -1,318 +0,0 @@ -'use client' - -import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import { requestJson } from '@/lib/api/client/request' -import type { - ContractBodyInput, - ContractParamsInput, - ContractQueryInput, -} from '@/lib/api/contracts' -import { - acceptCredentialSetInvitationContract, - type CreateCredentialSetData, - type CredentialSet, - type CredentialSetInvitation, - type CredentialSetInvitationListItem, - type CredentialSetMember, - type CredentialSetMembership, - cancelCredentialSetInvitationContract, - createCredentialSetContract, - createCredentialSetInvitationContract, - deleteCredentialSetContract, - leaveCredentialSetContract, - listCredentialSetInvitationDetailsContract, - listCredentialSetInvitationsContract, - listCredentialSetMembersContract, - listCredentialSetMembershipsContract, - listCredentialSetsContract, - removeCredentialSetMemberContract, - resendCredentialSetInvitationContract, -} from '@/lib/api/contracts' -import { fetchCredentialSetById } from '@/hooks/queries/utils/fetch-credential-set' - -export type { - CreateCredentialSetData, - CredentialSet, - CredentialSetInvitation, - CredentialSetInvitationListItem, - CredentialSetMember, - CredentialSetMembership, -} - -export const CREDENTIAL_SET_LIST_STALE_TIME = 60 * 1000 -export const CREDENTIAL_SET_DETAIL_STALE_TIME = 60 * 1000 -export const CREDENTIAL_SET_MEMBERSHIP_STALE_TIME = 60 * 1000 -export const CREDENTIAL_SET_INVITATION_LIST_STALE_TIME = 30 * 1000 -export const CREDENTIAL_SET_MEMBER_LIST_STALE_TIME = 30 * 1000 -export const CREDENTIAL_SET_INVITATION_DETAIL_STALE_TIME = 30 * 1000 - -export const credentialSetKeys = { - all: ['credentialSets'] as const, - lists: () => [...credentialSetKeys.all, 'list'] as const, - list: (organizationId?: string) => - [...credentialSetKeys.lists(), organizationId ?? 'none'] as const, - details: () => [...credentialSetKeys.all, 'detail'] as const, - detail: (id?: string) => [...credentialSetKeys.details(), id ?? 'none'] as const, - detailMembers: (credentialSetId?: string) => - [...credentialSetKeys.detail(credentialSetId), 'members'] as const, - detailInvitations: (credentialSetId?: string) => - [...credentialSetKeys.detail(credentialSetId), 'invitations'] as const, - memberships: () => [...credentialSetKeys.all, 'memberships'] as const, - invitations: () => [...credentialSetKeys.all, 'invitations'] as const, -} - -async function fetchCredentialSets( - organizationId: string, - signal?: AbortSignal -): Promise { - if (!organizationId) return [] - const data = await requestJson(listCredentialSetsContract, { - query: { organizationId }, - signal, - }) - return data.credentialSets ?? [] -} - -export function useCredentialSets(organizationId?: string, enabled = true) { - return useQuery({ - queryKey: credentialSetKeys.list(organizationId), - queryFn: ({ signal }) => fetchCredentialSets(organizationId ?? '', signal), - enabled: Boolean(organizationId) && enabled, - staleTime: CREDENTIAL_SET_LIST_STALE_TIME, - placeholderData: keepPreviousData, - }) -} - -export function useCredentialSetDetail(id?: string, enabled = true) { - return useQuery({ - queryKey: credentialSetKeys.detail(id), - queryFn: ({ signal }) => fetchCredentialSetById(id ?? '', signal), - enabled: Boolean(id) && enabled, - staleTime: CREDENTIAL_SET_DETAIL_STALE_TIME, - placeholderData: keepPreviousData, - }) -} - -export function useCredentialSetMemberships() { - return useQuery({ - queryKey: credentialSetKeys.memberships(), - queryFn: async ({ signal }) => { - const data = await requestJson(listCredentialSetMembershipsContract, { signal }) - return data.memberships ?? [] - }, - staleTime: CREDENTIAL_SET_MEMBERSHIP_STALE_TIME, - }) -} - -export function useCredentialSetInvitations() { - return useQuery({ - queryKey: credentialSetKeys.invitations(), - queryFn: async ({ signal }) => { - const data = await requestJson(listCredentialSetInvitationsContract, { signal }) - return data.invitations ?? [] - }, - staleTime: CREDENTIAL_SET_INVITATION_LIST_STALE_TIME, - }) -} - -export function useAcceptCredentialSetInvitation() { - const queryClient = useQueryClient() - - return useMutation({ - mutationFn: async (token: string) => { - return requestJson(acceptCredentialSetInvitationContract, { - params: { token }, - }) - }, - onSettled: () => { - return Promise.all([ - queryClient.invalidateQueries({ queryKey: credentialSetKeys.memberships() }), - queryClient.invalidateQueries({ queryKey: credentialSetKeys.invitations() }), - ]) - }, - }) -} - -export function useCreateCredentialSet() { - const queryClient = useQueryClient() - - return useMutation({ - mutationFn: async (data: CreateCredentialSetData) => { - return requestJson(createCredentialSetContract, { body: data }) - }, - onSettled: (_data, _error, variables) => { - return queryClient.invalidateQueries({ - queryKey: credentialSetKeys.list(variables.organizationId), - }) - }, - }) -} - -export function useCreateCredentialSetInvitation() { - const queryClient = useQueryClient() - - return useMutation({ - mutationFn: async ( - data: { credentialSetId: string } & ContractBodyInput< - typeof createCredentialSetInvitationContract - > - ) => { - return requestJson(createCredentialSetInvitationContract, { - params: { id: data.credentialSetId }, - body: { email: data.email }, - }) - }, - onSettled: (_data, _error, variables) => { - return Promise.all([ - queryClient.invalidateQueries({ - queryKey: credentialSetKeys.detailInvitations(variables.credentialSetId), - }), - queryClient.invalidateQueries({ queryKey: credentialSetKeys.invitations() }), - ]) - }, - }) -} - -export function useCredentialSetMembers(credentialSetId?: string) { - return useQuery({ - queryKey: credentialSetKeys.detailMembers(credentialSetId), - queryFn: async ({ signal }) => { - if (!credentialSetId) return [] - const data = await requestJson(listCredentialSetMembersContract, { - params: { id: credentialSetId }, - signal, - }) - return data.members ?? [] - }, - enabled: Boolean(credentialSetId), - staleTime: CREDENTIAL_SET_MEMBER_LIST_STALE_TIME, - }) -} - -export function useRemoveCredentialSetMember() { - const queryClient = useQueryClient() - - return useMutation({ - mutationFn: async ( - data: { credentialSetId: string } & ContractQueryInput< - typeof removeCredentialSetMemberContract - > - ) => { - return requestJson(removeCredentialSetMemberContract, { - params: { id: data.credentialSetId }, - query: { memberId: data.memberId }, - }) - }, - onSettled: (_data, _error, variables) => { - return Promise.all([ - queryClient.invalidateQueries({ - queryKey: credentialSetKeys.detailMembers(variables.credentialSetId), - }), - queryClient.invalidateQueries({ queryKey: credentialSetKeys.memberships() }), - ]) - }, - }) -} - -export function useLeaveCredentialSet() { - const queryClient = useQueryClient() - - return useMutation({ - mutationFn: async (credentialSetId: string) => { - return requestJson(leaveCredentialSetContract, { - query: { credentialSetId }, - }) - }, - onSettled: () => { - return queryClient.invalidateQueries({ queryKey: credentialSetKeys.memberships() }) - }, - }) -} - -export interface DeleteCredentialSetParams { - credentialSetId: string - organizationId: string -} - -export function useDeleteCredentialSet() { - const queryClient = useQueryClient() - - return useMutation({ - mutationFn: async ({ credentialSetId }: DeleteCredentialSetParams) => { - return requestJson(deleteCredentialSetContract, { - params: { id: credentialSetId }, - }) - }, - onSettled: (_data, _error, variables) => { - return Promise.all([ - queryClient.invalidateQueries({ - queryKey: credentialSetKeys.list(variables.organizationId), - }), - queryClient.invalidateQueries({ queryKey: credentialSetKeys.memberships() }), - queryClient.invalidateQueries({ - queryKey: credentialSetKeys.detail(variables.credentialSetId), - }), - ]) - }, - }) -} - -export function useCredentialSetInvitationsDetail(credentialSetId?: string) { - return useQuery({ - queryKey: credentialSetKeys.detailInvitations(credentialSetId), - queryFn: async ({ signal }) => { - if (!credentialSetId) return [] - const data = await requestJson(listCredentialSetInvitationDetailsContract, { - params: { id: credentialSetId }, - signal, - }) - return (data.invitations ?? []).filter((inv) => inv.status === 'pending') - }, - enabled: Boolean(credentialSetId), - staleTime: CREDENTIAL_SET_INVITATION_DETAIL_STALE_TIME, - }) -} - -export function useCancelCredentialSetInvitation() { - const queryClient = useQueryClient() - - return useMutation({ - mutationFn: async ( - data: { credentialSetId: string } & ContractQueryInput< - typeof cancelCredentialSetInvitationContract - > - ) => { - return requestJson(cancelCredentialSetInvitationContract, { - params: { id: data.credentialSetId }, - query: { invitationId: data.invitationId }, - }) - }, - onSettled: (_data, _error, variables) => { - return queryClient.invalidateQueries({ - queryKey: credentialSetKeys.detailInvitations(variables.credentialSetId), - }) - }, - }) -} - -export function useResendCredentialSetInvitation() { - const queryClient = useQueryClient() - - return useMutation({ - mutationFn: async ( - data: { credentialSetId: string; email: string } & Pick< - ContractParamsInput, - 'invitationId' - > - ) => { - return requestJson(resendCredentialSetInvitationContract, { - params: { id: data.credentialSetId, invitationId: data.invitationId }, - }) - }, - onSettled: (_data, _error, variables) => { - return queryClient.invalidateQueries({ - queryKey: credentialSetKeys.detailInvitations(variables.credentialSetId), - }) - }, - }) -} diff --git a/apps/sim/hooks/queries/oauth/oauth-credentials.ts b/apps/sim/hooks/queries/oauth/oauth-credentials.ts index bfe0d75c403..2cf9f22cc16 100644 --- a/apps/sim/hooks/queries/oauth/oauth-credentials.ts +++ b/apps/sim/hooks/queries/oauth/oauth-credentials.ts @@ -2,8 +2,6 @@ import { useQuery } from '@tanstack/react-query' import { requestJson } from '@/lib/api/client/request' import { listOAuthCredentialsContract } from '@/lib/api/contracts' import type { Credential } from '@/lib/oauth' -import { CREDENTIAL_SET } from '@/executor/constants' -import { useCredentialSetDetail } from '@/hooks/queries/credential-sets' import { useWorkspaceCredential } from '@/hooks/queries/credentials' export const OAUTH_CREDENTIAL_LIST_STALE_TIME = 60 * 1000 @@ -128,22 +126,10 @@ export function useCredentialName( workflowId?: string, workspaceId?: string ) { - // Check if this is a credential set value - const isCredentialSet = credentialId?.startsWith(CREDENTIAL_SET.PREFIX) ?? false - const credentialSetId = isCredentialSet - ? credentialId?.slice(CREDENTIAL_SET.PREFIX.length) - : undefined - - // Fetch credential set by ID directly - const { data: credentialSetData, isFetching: credentialSetLoading } = useCredentialSetDetail( - credentialSetId, - isCredentialSet - ) - const { data: credentials = [], isFetching: credentialsLoading } = useOAuthCredentials( providerId, { - enabled: Boolean(providerId) && !isCredentialSet, + enabled: Boolean(providerId), workspaceId, workflowId, } @@ -151,9 +137,7 @@ export function useCredentialName( const selectedCredential = credentials.find((cred) => cred.id === credentialId) - const shouldFetchDetail = Boolean( - credentialId && !selectedCredential && providerId && workflowId && !isCredentialSet - ) + const shouldFetchDetail = Boolean(credentialId && !selectedCredential && providerId && workflowId) const { data: foreignCredentials = [], isFetching: foreignLoading } = useOAuthCredentialDetail( shouldFetchDetail ? credentialId : undefined, @@ -163,25 +147,17 @@ export function useCredentialName( // Fallback for credential blocks that have no serviceId/providerId — look up by ID directly const { data: workspaceCredential, isFetching: workspaceCredentialLoading } = - useWorkspaceCredential(!providerId && !isCredentialSet ? credentialId : undefined) + useWorkspaceCredential(!providerId ? credentialId : undefined) const detailCredential = foreignCredentials[0] const hasForeignMeta = foreignCredentials.length > 0 const displayName = - credentialSetData?.name ?? - selectedCredential?.name ?? - detailCredential?.name ?? - workspaceCredential?.displayName ?? - null + selectedCredential?.name ?? detailCredential?.name ?? workspaceCredential?.displayName ?? null return { displayName, - isLoading: - credentialsLoading || - foreignLoading || - workspaceCredentialLoading || - (isCredentialSet && credentialSetLoading && !credentialSetData), + isLoading: credentialsLoading || foreignLoading || workspaceCredentialLoading, hasForeignMeta, } } diff --git a/apps/sim/hooks/queries/utils/fetch-credential-set.ts b/apps/sim/hooks/queries/utils/fetch-credential-set.ts deleted file mode 100644 index c9523d53633..00000000000 --- a/apps/sim/hooks/queries/utils/fetch-credential-set.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { requestJson } from '@/lib/api/client/request' -import { type CredentialSet, getCredentialSetContract } from '@/lib/api/contracts' - -/** - * Fetches a credential set by id (returns `null` for an empty id). - * - * Lives in this standalone (non-`'use client'`) module so server-reachable - * workflow-comparison helpers can import it without pulling client-reference - * stubs from the `'use client'` `@/hooks/queries/credential-sets` module. - */ -export async function fetchCredentialSetById( - id: string, - signal?: AbortSignal -): Promise { - if (!id) return null - const data = await requestJson(getCredentialSetContract, { - params: { id }, - signal, - }) - return data.credentialSet ?? null -} diff --git a/apps/sim/hooks/use-webhook-management.ts b/apps/sim/hooks/use-webhook-management.ts index 1d534a48463..9b03b378b3b 100644 --- a/apps/sim/hooks/use-webhook-management.ts +++ b/apps/sim/hooks/use-webhook-management.ts @@ -157,7 +157,6 @@ export function useWebhookManagement({ const { credentialId: _credId, - credentialSetId: _credSetId, userId: _userId, historyId: _historyId, lastCheckedTimestamp: _lastChecked, diff --git a/apps/sim/lib/api/contracts/credential-sets.ts b/apps/sim/lib/api/contracts/credential-sets.ts deleted file mode 100644 index b191979a714..00000000000 --- a/apps/sim/lib/api/contracts/credential-sets.ts +++ /dev/null @@ -1,344 +0,0 @@ -import { z } from 'zod' -import { defineRouteContract } from '@/lib/api/contracts/types' - -export const credentialSetSchema = z.object({ - id: z.string(), - name: z.string(), - description: z.string().nullable(), - providerId: z.string().nullable(), - createdBy: z.string(), - createdAt: z.string(), - updatedAt: z.string(), - creatorName: z.string().nullable(), - creatorEmail: z.string().nullable(), - memberCount: z.number(), -}) - -export const credentialSetWriteSchema = z.object({ - id: z.string(), - organizationId: z.string(), - name: z.string(), - description: z.string().nullable(), - providerId: z.string().nullable(), - createdBy: z.string(), - createdAt: z.string(), - updatedAt: z.string(), -}) - -export const credentialSetMembershipSchema = z.object({ - membershipId: z.string(), - status: z.string(), - joinedAt: z.string().nullable(), - credentialSetId: z.string(), - credentialSetName: z.string(), - credentialSetDescription: z.string().nullable(), - providerId: z.string().nullable(), - organizationId: z.string(), - organizationName: z.string(), -}) - -export const credentialSetInvitationSchema = z.object({ - invitationId: z.string(), - token: z.string(), - status: z.string(), - expiresAt: z.string(), - createdAt: z.string(), - credentialSetId: z.string(), - credentialSetName: z.string(), - providerId: z.string().nullable(), - organizationId: z.string(), - organizationName: z.string(), - invitedByName: z.string().nullable(), - invitedByEmail: z.string().nullable(), -}) - -export const credentialSetInvitationDetailSchema = z.object({ - id: z.string(), - credentialSetId: z.string(), - email: z.string().nullable(), - token: z.string(), - status: z.string(), - expiresAt: z.string(), - createdAt: z.string(), - invitedBy: z.string(), -}) - -/** - * Management-list view of an invitation. Omits the bearer `token` — the secret - * redeemed via the invite link — so listing a credential set's invitations never - * broadcasts it. Only the creating admin receives the token (and its `inviteUrl`) - * in the create response. - */ -export const credentialSetInvitationListItemSchema = credentialSetInvitationDetailSchema.omit({ - token: true, -}) - -export const credentialSetInvitePreviewSchema = z.object({ - credentialSetName: z.string(), - organizationName: z.string(), - providerId: z.string().nullable(), - email: z.string().nullable(), -}) - -export const credentialSetMemberSchema = z.object({ - id: z.string(), - userId: z.string(), - status: z.string(), - joinedAt: z.string().nullable(), - createdAt: z.string(), - userName: z.string().nullable(), - userEmail: z.string().nullable(), - userImage: z.string().nullable(), - credentials: z.array( - z.object({ - providerId: z.string(), - accountId: z.string(), - }) - ), -}) - -export type CredentialSet = z.output -export type CredentialSetMembership = z.output -export type CredentialSetInvitation = z.output -export type CredentialSetMember = z.output -export type CredentialSetInvitationDetail = z.output -export type CredentialSetInvitationListItem = z.output -export type CredentialSetInvitePreview = z.output - -export const listCredentialSetsQuerySchema = z.object({ - organizationId: z.string().min(1), -}) - -export const createCredentialSetBodySchema = z.object({ - organizationId: z.string().min(1), - name: z.string().trim().min(1).max(100), - description: z.string().max(500).optional(), - providerId: z.enum(['google-email', 'outlook']), -}) - -export type CreateCredentialSetData = z.input - -export const credentialSetIdParamsSchema = z.object({ - id: z.string().min(1), -}) - -export const credentialSetInviteTokenParamsSchema = z.object({ - token: z.string().min(1), -}) - -export const credentialSetInvitationParamsSchema = z.object({ - id: z.string().min(1), - invitationId: z.string().min(1), -}) - -export const createCredentialSetInvitationBodySchema = z.object({ - email: z.string().email().optional(), -}) - -export const removeCredentialSetMemberQuerySchema = z.object({ - memberId: z.string().min(1), -}) - -export const leaveCredentialSetQuerySchema = z.object({ - credentialSetId: z.string().min(1), -}) - -export const cancelCredentialSetInvitationQuerySchema = z.object({ - invitationId: z.string().min(1), -}) - -export const updateCredentialSetBodySchema = z.object({ - name: z.string().trim().min(1).max(100).optional(), - description: z.string().max(500).nullable().optional(), -}) - -const successResponseSchema = z.object({ - success: z.literal(true), -}) - -export const listCredentialSetsContract = defineRouteContract({ - method: 'GET', - path: '/api/credential-sets', - query: listCredentialSetsQuerySchema, - response: { - mode: 'json', - schema: z.object({ - credentialSets: z.array(credentialSetSchema).optional(), - }), - }, -}) - -export const createCredentialSetContract = defineRouteContract({ - method: 'POST', - path: '/api/credential-sets', - body: createCredentialSetBodySchema, - response: { - mode: 'json', - schema: z.object({ - credentialSet: credentialSetSchema, - }), - }, -}) - -export const getCredentialSetContract = defineRouteContract({ - method: 'GET', - path: '/api/credential-sets/[id]', - params: credentialSetIdParamsSchema, - response: { - mode: 'json', - schema: z.object({ - credentialSet: credentialSetSchema.optional(), - }), - }, -}) - -export const deleteCredentialSetContract = defineRouteContract({ - method: 'DELETE', - path: '/api/credential-sets/[id]', - params: credentialSetIdParamsSchema, - response: { - mode: 'json', - schema: successResponseSchema, - }, -}) - -export const updateCredentialSetContract = defineRouteContract({ - method: 'PUT', - path: '/api/credential-sets/[id]', - params: credentialSetIdParamsSchema, - body: updateCredentialSetBodySchema, - response: { - mode: 'json', - schema: z.object({ - credentialSet: credentialSetWriteSchema.passthrough().optional(), - }), - }, -}) - -export const listCredentialSetMembershipsContract = defineRouteContract({ - method: 'GET', - path: '/api/credential-sets/memberships', - response: { - mode: 'json', - schema: z.object({ - memberships: z.array(credentialSetMembershipSchema).optional(), - }), - }, -}) - -export const listCredentialSetInvitationsContract = defineRouteContract({ - method: 'GET', - path: '/api/credential-sets/invitations', - response: { - mode: 'json', - schema: z.object({ - invitations: z.array(credentialSetInvitationSchema).optional(), - }), - }, -}) - -export const listCredentialSetMembersContract = defineRouteContract({ - method: 'GET', - path: '/api/credential-sets/[id]/members', - params: credentialSetIdParamsSchema, - response: { - mode: 'json', - schema: z.object({ - members: z.array(credentialSetMemberSchema).optional(), - }), - }, -}) - -export const listCredentialSetInvitationDetailsContract = defineRouteContract({ - method: 'GET', - path: '/api/credential-sets/[id]/invite', - params: credentialSetIdParamsSchema, - response: { - mode: 'json', - schema: z.object({ - invitations: z.array(credentialSetInvitationListItemSchema).optional(), - }), - }, -}) - -export const getCredentialSetInvitationContract = defineRouteContract({ - method: 'GET', - path: '/api/credential-sets/invite/[token]', - params: credentialSetInviteTokenParamsSchema, - response: { - mode: 'json', - schema: z.object({ - invitation: credentialSetInvitePreviewSchema, - }), - }, -}) - -export const acceptCredentialSetInvitationContract = defineRouteContract({ - method: 'POST', - path: '/api/credential-sets/invite/[token]', - params: credentialSetInviteTokenParamsSchema, - response: { - mode: 'json', - schema: successResponseSchema.extend({ - credentialSetId: z.string(), - providerId: z.string().nullable(), - }), - }, -}) - -export const createCredentialSetInvitationContract = defineRouteContract({ - method: 'POST', - path: '/api/credential-sets/[id]/invite', - params: credentialSetIdParamsSchema, - body: createCredentialSetInvitationBodySchema, - response: { - mode: 'json', - schema: z.object({ - invitation: credentialSetInvitationDetailSchema.extend({ - inviteUrl: z.string(), - }), - }), - }, -}) - -export const removeCredentialSetMemberContract = defineRouteContract({ - method: 'DELETE', - path: '/api/credential-sets/[id]/members', - params: credentialSetIdParamsSchema, - query: removeCredentialSetMemberQuerySchema, - response: { - mode: 'json', - schema: successResponseSchema, - }, -}) - -export const leaveCredentialSetContract = defineRouteContract({ - method: 'DELETE', - path: '/api/credential-sets/memberships', - query: leaveCredentialSetQuerySchema, - response: { - mode: 'json', - schema: successResponseSchema, - }, -}) - -export const cancelCredentialSetInvitationContract = defineRouteContract({ - method: 'DELETE', - path: '/api/credential-sets/[id]/invite', - params: credentialSetIdParamsSchema, - query: cancelCredentialSetInvitationQuerySchema, - response: { - mode: 'json', - schema: successResponseSchema, - }, -}) - -export const resendCredentialSetInvitationContract = defineRouteContract({ - method: 'POST', - path: '/api/credential-sets/[id]/invite/[invitationId]', - params: credentialSetInvitationParamsSchema, - response: { - mode: 'json', - schema: successResponseSchema, - }, -}) diff --git a/apps/sim/lib/api/contracts/index.ts b/apps/sim/lib/api/contracts/index.ts index 690c60f25c5..ef339965c5b 100644 --- a/apps/sim/lib/api/contracts/index.ts +++ b/apps/sim/lib/api/contracts/index.ts @@ -5,7 +5,6 @@ export * from './byok-keys' export * from './chats' export * from './common' export * from './copilot' -export * from './credential-sets' export * from './credentials' export * from './demo-requests' export * from './environment' diff --git a/apps/sim/lib/api/contracts/webhooks.ts b/apps/sim/lib/api/contracts/webhooks.ts index ca309c6f44e..4903d558614 100644 --- a/apps/sim/lib/api/contracts/webhooks.ts +++ b/apps/sim/lib/api/contracts/webhooks.ts @@ -133,15 +133,6 @@ const listWebhooksResponseSchema = z.object({ const upsertWebhookResponseSchema = z.object({ webhook: webhookDataSchema, - credentialSetInfo: z - .object({ - credentialSetId: z.string(), - totalWebhooks: z.number(), - created: z.number(), - updated: z.number(), - deleted: z.number(), - }) - .optional(), }) const getWebhookResponseSchema = z.object({ diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts index 3640723a32f..8accab00a40 100644 --- a/apps/sim/lib/auth/auth.ts +++ b/apps/sim/lib/auth/auth.ts @@ -84,7 +84,6 @@ import { quickValidateEmail } from '@/lib/messaging/email/validation' import { validateSignupEmailMx } from '@/lib/messaging/email/validation.server' import { scheduleLifecycleEmail } from '@/lib/messaging/lifecycle' import { captureServerEvent, getPostHogClient } from '@/lib/posthog/server' -import { syncAllWebhooksForCredentialSet } from '@/lib/webhooks/utils.server' import { disableUserResources } from '@/lib/workflows/lifecycle' import { SSO_TRUSTED_PROVIDERS } from '@/ee/sso/constants' import { createAnonymousSession, ensureAnonymousUserExists } from './anonymous' @@ -550,46 +549,6 @@ export const auth = betterAuth({ .where(eq(schema.account.id, account.id)) } - // Sync webhooks for credential sets after connecting a new credential - const requestId = generateId().slice(0, 8) - const userMemberships = await db - .select({ - credentialSetId: schema.credentialSetMember.credentialSetId, - providerId: schema.credentialSet.providerId, - }) - .from(schema.credentialSetMember) - .innerJoin( - schema.credentialSet, - eq(schema.credentialSetMember.credentialSetId, schema.credentialSet.id) - ) - .where( - and( - eq(schema.credentialSetMember.userId, account.userId), - eq(schema.credentialSetMember.status, 'active') - ) - ) - - for (const membership of userMemberships) { - if (membership.providerId === account.providerId) { - try { - await syncAllWebhooksForCredentialSet(membership.credentialSetId, requestId) - logger.info('[account.create.after] Synced webhooks after credential connect', { - credentialSetId: membership.credentialSetId, - providerId: account.providerId, - }) - } catch (error) { - logger.error( - '[account.create.after] Failed to sync webhooks after credential connect', - { - credentialSetId: membership.credentialSetId, - providerId: account.providerId, - error, - } - ) - } - } - } - try { PlatformEvents.oauthConnected({ userId: account.userId, diff --git a/apps/sim/lib/billing/core/subscription.test.ts b/apps/sim/lib/billing/core/subscription.test.ts index 4d25f9ec426..6f82cdba7ed 100644 --- a/apps/sim/lib/billing/core/subscription.test.ts +++ b/apps/sim/lib/billing/core/subscription.test.ts @@ -33,7 +33,6 @@ vi.mock('@/lib/billing/subscriptions/utils', () => ({ vi.mock('@/lib/core/config/env-flags', () => ({ isAccessControlEnabled: false, isBillingEnabled: true, - isCredentialSetsEnabled: false, isHosted: true, isInboxEnabled: false, isSsoEnabled: false, diff --git a/apps/sim/lib/billing/core/subscription.ts b/apps/sim/lib/billing/core/subscription.ts index 5084ce91081..cd0eb71f9ec 100644 --- a/apps/sim/lib/billing/core/subscription.ts +++ b/apps/sim/lib/billing/core/subscription.ts @@ -25,7 +25,6 @@ import { import { isAccessControlEnabled, isBillingEnabled, - isCredentialSetsEnabled, isHosted, isInboxEnabled, isSsoEnabled, @@ -336,91 +335,6 @@ export async function isEnterpriseOrgAdminOrOwner(userId: string): Promise { - try { - if (!isBillingEnabled) { - return true - } - - const [memberRecord] = await db - .select({ - organizationId: member.organizationId, - role: member.role, - }) - .from(member) - .where(eq(member.userId, userId)) - .limit(1) - - if (!memberRecord) { - return false - } - - if (memberRecord.role !== 'owner' && memberRecord.role !== 'admin') { - return false - } - - const billingStatus = await getEffectiveBillingStatus(userId) - if (billingStatus.billingBlocked) { - return false - } - - const orgSub = await getOrganizationSubscriptionUsable(memberRecord.organizationId) - - const hasTeamPlan = orgSub && (checkTeamPlan(orgSub) || checkEnterprisePlan(orgSub)) - - if (hasTeamPlan) { - logger.info('User is team org admin/owner', { - userId, - organizationId: memberRecord.organizationId, - role: memberRecord.role, - plan: orgSub.plan, - }) - } - - return !!hasTeamPlan - } catch (error) { - logger.error('Error checking team org admin/owner status', { error, userId }) - return false - } -} - -/** - * Check if an organization has team or enterprise plan - * Used at execution time (e.g., polling services) to check org billing directly - */ -export async function isOrganizationOnTeamOrEnterprisePlan( - organizationId: string -): Promise { - try { - if (!isBillingEnabled) { - return true - } - - if (isCredentialSetsEnabled && !isHosted) { - return true - } - - if (await isOrganizationBillingBlocked(organizationId)) { - return false - } - - const orgSub = await getOrganizationSubscriptionUsable(organizationId) - - return !!orgSub && (checkTeamPlan(orgSub) || checkEnterprisePlan(orgSub)) - } catch (error) { - logger.error('Error checking organization plan status', { error, organizationId }) - return false - } -} - /** * Check if an organization has an enterprise plan * Used for Access Control (Permission Groups) feature gating @@ -448,27 +362,6 @@ export async function isOrganizationOnEnterprisePlan(organizationId: string): Pr } } -/** - * Check if user has access to credential sets (email polling) feature - * Returns true if: - * - CREDENTIAL_SETS_ENABLED env var is set (self-hosted override), OR - * - User is admin/owner of a team/enterprise organization - * - * In non-production environments, returns true for convenience. - */ -export async function hasCredentialSetsAccess(userId: string): Promise { - try { - if (isCredentialSetsEnabled && !isHosted) { - return true - } - - return isTeamOrgAdminOrOwner(userId) - } catch (error) { - logger.error('Error checking credential sets access', { error, userId }) - return false - } -} - /** * Check if user has access to SSO feature * Returns true if: diff --git a/apps/sim/lib/billing/index.ts b/apps/sim/lib/billing/index.ts index eff8e32c75f..8c5c8b36d45 100644 --- a/apps/sim/lib/billing/index.ts +++ b/apps/sim/lib/billing/index.ts @@ -10,15 +10,12 @@ export * from '@/lib/billing/core/organization' export * from '@/lib/billing/core/subscription' export { getHighestPrioritySubscription as getActiveSubscription, - hasCredentialSetsAccess, hasPaidSubscription, hasSSOAccess, isEnterpriseOrgAdminOrOwner, isEnterprisePlan as hasEnterprisePlan, isOrganizationOnEnterprisePlan, - isOrganizationOnTeamOrEnterprisePlan, isProPlan as hasProPlan, - isTeamOrgAdminOrOwner, isTeamPlan as hasTeamPlan, isWorkspaceOnEnterprisePlan, sendPlanWelcomeEmail, diff --git a/apps/sim/lib/compare/data/competitors/tines.ts b/apps/sim/lib/compare/data/competitors/tines.ts index 00437900da7..4c062b7b6a0 100644 --- a/apps/sim/lib/compare/data/competitors/tines.ts +++ b/apps/sim/lib/compare/data/competitors/tines.ts @@ -818,7 +818,7 @@ export const tinesProfile: CompetitorProfile = { value: "Yes: credentials are scoped to Teams by default, and Team Admin/Editor roles control which teams a credential can be shared with. Sensitive settings like Access (where a credential can be used) and Domains (allowed outbound hosts/paths) are restricted to Team Admins or the credential's creator. Custom roles can extend the default viewer/builder/manager roles for finer-grained control.", detail: - "Governance operates at the team/role level with per-credential Access and Domain restrictions, not a credential-set-to-role assignment matrix like Sim's, but reaches a similar outcome.", + "Governance operates at the team/role level with per-credential Access and Domain restrictions, not a credential-to-role assignment matrix like Sim's, but reaches a similar outcome.", shortValue: 'Yes: team-scoped credential access rules', confidence: 'verified', sources: [ diff --git a/apps/sim/lib/compare/data/feature-catalog.ts b/apps/sim/lib/compare/data/feature-catalog.ts index f5c4f55f027..4f9c4b614db 100644 --- a/apps/sim/lib/compare/data/feature-catalog.ts +++ b/apps/sim/lib/compare/data/feature-catalog.ts @@ -439,7 +439,7 @@ export const SIM_FEATURES: SimFeature[] = [ category: 'environments-enterprise', tags: ['enterprise', 'security', 'flagship'], description: - "Forking never copies credentials. All credential references are cleared in the child workspace at creation time. Instead, an admin explicitly maps each source OAuth/service-account credential to the target workspace's own credential via a dedicated mapping UI/API before promoting; environment variables remap by name (including rewriting {{ENV_KEY}} references inside copied custom-tool code or MCP headers if renamed, e.g. SLACK_API_KEY → SLACK_API_KEY_TEST). Credential and env-var mappings are required. An unmapped one blocks the promote rather than silently syncing a secret across environments, while optional resources (knowledge bases, tables, files, MCP servers) clear gracefully if unmapped. Org-scoped shared credential sets are preserved verbatim across environments.", + "Forking never copies credentials. All credential references are cleared in the child workspace at creation time. Instead, an admin explicitly maps each source OAuth/service-account credential to the target workspace's own credential via a dedicated mapping UI/API before promoting; environment variables remap by name (including rewriting {{ENV_KEY}} references inside copied custom-tool code or MCP headers if renamed, e.g. SLACK_API_KEY → SLACK_API_KEY_TEST). Credential and env-var mappings are required. An unmapped one blocks the promote rather than silently syncing a secret across environments, while optional resources (knowledge bases, tables, files, MCP servers) clear gracefully if unmapped.", competitiveNote: 'Treating credentials/env-vars as required-and-blocking on promote (rather than silently copying secrets) is a specific, auditable safety design for enterprise dev→qa→prod pipelines.', sources: [ diff --git a/apps/sim/lib/core/config/env-flags.ts b/apps/sim/lib/core/config/env-flags.ts index ce8aef23e50..b9f3478159f 100644 --- a/apps/sim/lib/core/config/env-flags.ts +++ b/apps/sim/lib/core/config/env-flags.ts @@ -139,12 +139,6 @@ export const isTriggerDevEnabled = isTruthy(env.TRIGGER_DEV_ENABLED) */ export const isSsoEnabled = isTruthy(env.SSO_ENABLED) -/** - * Is credential sets (email polling) enabled via env var override - * This bypasses plan requirements for self-hosted deployments - */ -export const isCredentialSetsEnabled = isTruthy(env.CREDENTIAL_SETS_ENABLED) - /** * Is access control (permission groups) enabled via env var override * This bypasses plan requirements for self-hosted deployments diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index 7f5f117b961..5985231962e 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -412,9 +412,6 @@ export const env = createEnv({ MOTHERSHIP_E2B_DOC_TEMPLATE_ID: z.string().optional(), // Dedicated E2B template with python-pptx/docx/openpyxl/reportlab for document generation; when set (and E2B enabled), docs compile via Python instead of the JS isolated-vm path E2B_PI_TEMPLATE_ID: z.string().optional(), // E2B template ID/alias with the Pi CLI + git baked in (Pi Coding Agent cloud mode) - // Credential Sets (Email Polling) - for self-hosted deployments - CREDENTIAL_SETS_ENABLED: z.boolean().optional(), // Enable credential sets on self-hosted (bypasses plan requirements) - // Access Control (Permission Groups) - for self-hosted deployments ACCESS_CONTROL_ENABLED: z.boolean().optional(), // Enable access control on self-hosted (bypasses plan requirements) @@ -518,7 +515,6 @@ export const env = createEnv({ // Feature Flags NEXT_PUBLIC_SSO_ENABLED: z.boolean().optional(), // Enable SSO login UI components - NEXT_PUBLIC_CREDENTIAL_SETS_ENABLED: z.boolean().optional(), // Enable credential sets (email polling) on self-hosted NEXT_PUBLIC_ACCESS_CONTROL_ENABLED: z.boolean().optional(), // Enable access control (permission groups) on self-hosted NEXT_PUBLIC_WHITELABELING_ENABLED: z.boolean().optional(), // Enable whitelabeling on self-hosted (bypasses hosted requirements) NEXT_PUBLIC_AUDIT_LOGS_ENABLED: z.boolean().optional(), // Enable audit logs on self-hosted (bypasses hosted requirements) @@ -558,7 +554,6 @@ export const env = createEnv({ NEXT_PUBLIC_BRAND_ACCENT_HOVER_COLOR: process.env.NEXT_PUBLIC_BRAND_ACCENT_HOVER_COLOR, NEXT_PUBLIC_BRAND_BACKGROUND_COLOR: process.env.NEXT_PUBLIC_BRAND_BACKGROUND_COLOR, NEXT_PUBLIC_SSO_ENABLED: process.env.NEXT_PUBLIC_SSO_ENABLED, - NEXT_PUBLIC_CREDENTIAL_SETS_ENABLED: process.env.NEXT_PUBLIC_CREDENTIAL_SETS_ENABLED, NEXT_PUBLIC_ACCESS_CONTROL_ENABLED: process.env.NEXT_PUBLIC_ACCESS_CONTROL_ENABLED, NEXT_PUBLIC_WHITELABELING_ENABLED: process.env.NEXT_PUBLIC_WHITELABELING_ENABLED, NEXT_PUBLIC_AUDIT_LOGS_ENABLED: process.env.NEXT_PUBLIC_AUDIT_LOGS_ENABLED, diff --git a/apps/sim/lib/core/security/input-validation.test.ts b/apps/sim/lib/core/security/input-validation.test.ts index e1fbf1f7171..b4c87e09374 100644 --- a/apps/sim/lib/core/security/input-validation.test.ts +++ b/apps/sim/lib/core/security/input-validation.test.ts @@ -2109,7 +2109,7 @@ describe('validateCallbackUrl', () => { ['/invite/abc-123'], ['/invite/abc?foo=bar&baz=qux'], ['/workspace#section'], - ['/credential-account/456'], + ['/workspace/456'], ['?reset=true'], ['/'], ['https://sim.app/workspace'], diff --git a/apps/sim/lib/core/telemetry.ts b/apps/sim/lib/core/telemetry.ts index 1b67c865edb..125592d0ad7 100644 --- a/apps/sim/lib/core/telemetry.ts +++ b/apps/sim/lib/core/telemetry.ts @@ -789,17 +789,6 @@ export const PlatformEvents = { }) }, - /** - * Track credential set created - */ - credentialSetCreated: (attrs: { credentialSetId: string; userId: string; name: string }) => { - trackPlatformEvent('platform.credential_set.created', { - 'credential_set.id': attrs.credentialSetId, - 'credential_set.name': attrs.name, - 'user.id': attrs.userId, - }) - }, - /** * Track webhook created */ diff --git a/apps/sim/lib/credential-sets/providers.ts b/apps/sim/lib/credential-sets/providers.ts deleted file mode 100644 index 4de42ffb783..00000000000 --- a/apps/sim/lib/credential-sets/providers.ts +++ /dev/null @@ -1,27 +0,0 @@ -export type PollingProvider = 'google-email' | 'outlook' - -export const POLLING_PROVIDERS: Record = { - 'google-email': { displayName: 'Gmail' }, - outlook: { displayName: 'Outlook' }, -} - -export function getProviderDisplayName(providerId: string): string { - if (providerId === 'google-email') return 'Gmail' - if (providerId === 'outlook') return 'Outlook' - return providerId -} - -export function isPollingProvider(provider: string): provider is PollingProvider { - return provider === 'google-email' || provider === 'outlook' -} - -/** - * Maps an OAuth provider ID to its corresponding polling provider ID. - * Since credential sets now store the OAuth provider ID directly, this is primarily - * used in the credential selector to match OAuth providers to credential sets. - */ -export function getPollingProviderFromOAuth(oauthProviderId: string): PollingProvider | null { - if (oauthProviderId === 'google-email') return 'google-email' - if (oauthProviderId === 'outlook') return 'outlook' - return null -} diff --git a/apps/sim/lib/webhooks/deploy.test.ts b/apps/sim/lib/webhooks/deploy.test.ts index ca7e1949af7..f5c111ed68a 100644 --- a/apps/sim/lib/webhooks/deploy.test.ts +++ b/apps/sim/lib/webhooks/deploy.test.ts @@ -9,7 +9,6 @@ import type { BlockState } from '@/stores/workflows/workflow/types' // buildProviderConfig (a pure function), so stub them to keep this unit test fast and isolated. vi.mock('@/blocks', () => ({ getBlock: vi.fn() })) vi.mock('@/triggers', () => ({ getTrigger: vi.fn(), isTriggerValid: vi.fn(() => true) })) -vi.mock('@/lib/oauth', () => ({ getProviderIdFromServiceId: vi.fn() })) vi.mock('@/lib/webhooks/providers', () => ({ getProviderHandler: vi.fn() })) vi.mock('@/lib/webhooks/provider-subscriptions', () => ({ cleanupExternalWebhook: vi.fn(), @@ -18,7 +17,6 @@ vi.mock('@/lib/webhooks/provider-subscriptions', () => ({ })) vi.mock('@/lib/webhooks/utils.server', () => ({ findConflictingWebhookPathOwner: vi.fn(), - syncWebhooksForCredentialSet: vi.fn(), })) vi.mock('@/lib/webhooks/pending-verification', () => ({ PendingWebhookVerificationTracker: vi.fn(), diff --git a/apps/sim/lib/webhooks/deploy.ts b/apps/sim/lib/webhooks/deploy.ts index 0fcb668f8e1..93e21eb6b87 100644 --- a/apps/sim/lib/webhooks/deploy.ts +++ b/apps/sim/lib/webhooks/deploy.ts @@ -1,11 +1,9 @@ import { db } from '@sim/db' -import { account, credentialSetMember, webhook, workflowDeploymentVersion } from '@sim/db/schema' +import { webhook, workflowDeploymentVersion } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { generateShortId } from '@sim/utils/id' -import { and, eq, inArray, isNotNull, isNull, or } from 'drizzle-orm' +import { and, eq, inArray, isNull } from 'drizzle-orm' import type { NextRequest } from 'next/server' -import type { DbOrTx } from '@/lib/db/types' -import { getProviderIdFromServiceId } from '@/lib/oauth' import { PendingWebhookVerificationTracker } from '@/lib/webhooks/pending-verification' import { cleanupExternalWebhook, @@ -13,10 +11,7 @@ import { shouldRecreateExternalWebhookSubscription, } from '@/lib/webhooks/provider-subscriptions' import { getProviderHandler } from '@/lib/webhooks/providers' -import { - findConflictingWebhookPathOwner, - syncWebhooksForCredentialSet, -} from '@/lib/webhooks/utils.server' +import { findConflictingWebhookPathOwner } from '@/lib/webhooks/utils.server' import { buildCanonicalIndex, buildSubBlockValues, @@ -30,7 +25,6 @@ import { getTrigger, isTriggerValid } from '@/triggers' import { SYSTEM_SUBBLOCK_IDS } from '@/triggers/constants' const logger = createLogger('DeployWebhookSync') -const CREDENTIAL_SET_PREFIX = 'credentialSet:' interface TriggerSaveError { message: string @@ -39,12 +33,10 @@ interface TriggerSaveError { interface TriggerSaveResult { success: boolean error?: TriggerSaveError - warnings?: string[] } export async function validateTriggerWebhookConfigForDeploy( - blocks: Record, - executor: DbOrTx = db + blocks: Record ): Promise { const triggerBlocks = Object.values(blocks || {}).filter((b) => b && b.enabled !== false) @@ -53,7 +45,6 @@ export async function validateTriggerWebhookConfigForDeploy( if (!triggerId || !isTriggerValid(triggerId)) continue const triggerDef = getTrigger(triggerId) - const provider = triggerDef.provider const { providerConfig, missingFields } = buildProviderConfig(block, triggerId, triggerDef) if (missingFields.length > 0) { @@ -76,69 +67,11 @@ export async function validateTriggerWebhookConfigForDeploy( }, } } - - if (providerConfig.credentialSetId) { - const oauthProviderId = getProviderIdFromServiceId(provider) - const hasCredential = await credentialSetHasProviderCredential( - providerConfig.credentialSetId as string, - oauthProviderId, - executor - ) - if (!hasCredential) { - return { - success: false, - error: { - message: `No valid credentials found in credential set for ${provider}. Please connect accounts and try again.`, - status: 400, - }, - } - } - } } return { success: true } } -async function credentialSetHasProviderCredential( - credentialSetId: string, - providerId: string, - executor: DbOrTx -): Promise { - const members = await executor - .select({ userId: credentialSetMember.userId }) - .from(credentialSetMember) - .where( - and( - eq(credentialSetMember.credentialSetId, credentialSetId), - eq(credentialSetMember.status, 'active') - ) - ) - - if (members.length === 0) return false - - const [credential] = await executor - .select({ id: account.id }) - .from(account) - .where( - and( - inArray( - account.userId, - members.map((member) => member.userId) - ), - eq(account.providerId, providerId), - or(isNotNull(account.accessToken), isNotNull(account.refreshToken)) - ) - ) - .limit(1) - - return Boolean(credential) -} - -interface CredentialSetSyncResult { - error: TriggerSaveError | null - warnings: string[] -} - interface SaveTriggerWebhooksInput { request: NextRequest workflowId: string @@ -269,7 +202,6 @@ export function buildProviderConfig( providerConfig: Record missingFields: string[] credentialId?: string - credentialSetId?: string triggerPath: string } { const triggerConfigValue = getSubBlockValue(block, 'triggerConfig') @@ -351,15 +283,9 @@ export function buildProviderConfig( } let credentialId: string | undefined - let credentialSetId: string | undefined if (typeof triggerCredentials === 'string' && triggerCredentials.length > 0) { - if (triggerCredentials.startsWith(CREDENTIAL_SET_PREFIX)) { - credentialSetId = triggerCredentials.slice(CREDENTIAL_SET_PREFIX.length) - providerConfig.credentialSetId = credentialSetId - } else { - credentialId = triggerCredentials - providerConfig.credentialId = credentialId - } + credentialId = triggerCredentials + providerConfig.credentialId = credentialId } providerConfig.triggerId = triggerId @@ -370,7 +296,7 @@ export function buildProviderConfig( ? triggerPathValue : block.id - return { providerConfig, missingFields, credentialId, credentialSetId, triggerPath } + return { providerConfig, missingFields, credentialId, triggerPath } } async function configurePollingIfNeeded( @@ -395,91 +321,6 @@ async function configurePollingIfNeeded( return null } -async function syncCredentialSetWebhooks(params: { - workflowId: string - blockId: string - provider: string - triggerPath: string - providerConfig: Record - requestId: string - deploymentVersionId?: string -}): Promise { - const { - workflowId, - blockId, - provider, - triggerPath, - providerConfig, - requestId, - deploymentVersionId, - } = params - - const credentialSetId = providerConfig.credentialSetId as string | undefined - if (!credentialSetId) { - return { error: null, warnings: [] } - } - - const oauthProviderId = getProviderIdFromServiceId(provider) - - const { credentialId: _cId, credentialSetId: _csId, userId: _uId, ...baseConfig } = providerConfig - - const syncResult = await syncWebhooksForCredentialSet({ - workflowId, - blockId, - provider, - basePath: triggerPath, - credentialSetId, - oauthProviderId, - providerConfig: baseConfig as Record, - requestId, - deploymentVersionId, - }) - - const warnings: string[] = [] - - if (syncResult.failed.length > 0) { - const failedCount = syncResult.failed.length - const totalCount = syncResult.webhooks.length + failedCount - warnings.push( - `${failedCount} of ${totalCount} credentials in the set failed to sync for ${provider}. Some team members may not receive triggers.` - ) - } - - if (syncResult.webhooks.length === 0) { - return { - error: { - message: `No valid credentials found in credential set for ${provider}. Please connect accounts and try again.`, - status: 400, - }, - warnings, - } - } - - const handler = getProviderHandler(provider) - if (handler.configurePolling) { - for (const wh of syncResult.webhooks) { - if (wh.isNew) { - const rows = await db.select().from(webhook).where(eq(webhook.id, wh.id)).limit(1) - if (rows.length > 0) { - const success = await handler.configurePolling({ webhook: rows[0], requestId }) - if (!success) { - await db.delete(webhook).where(eq(webhook.id, wh.id)) - return { - error: { - message: `Failed to configure ${provider} polling. Please check account permissions.`, - status: 500, - }, - warnings, - } - } - } - } - } - } - - return { error: null, warnings } -} - /** * Saves trigger webhook configurations as part of workflow deployment. * Uses delete + create approach for changed/deleted webhooks. @@ -534,13 +375,11 @@ export async function saveTriggerWebhooksForDeploy({ provider: string providerConfig: Record triggerPath: string - triggerDef: ReturnType } const webhookConfigs = new Map() const webhooksToDelete: typeof existingWebhooks = [] const blocksNeedingWebhook: BlockState[] = [] - const blocksNeedingCredentialSetSync: BlockState[] = [] for (const block of triggerBlocks) { const triggerId = resolveTriggerId(block) @@ -592,12 +431,7 @@ export async function saveTriggerWebhooksForDeploy({ } } - webhookConfigs.set(block.id, { provider, providerConfig, triggerPath, triggerDef }) - - if (providerConfig.credentialSetId) { - blocksNeedingCredentialSetSync.push(block) - continue - } + webhookConfigs.set(block.id, { provider, providerConfig, triggerPath }) const existingForBlock = webhooksByBlockId.get(block.id) ?? [] if (existingForBlock.length === 0) { @@ -669,45 +503,6 @@ export async function saveTriggerWebhooksForDeploy({ } } - const collectedWarnings: string[] = [] - - for (const block of blocksNeedingCredentialSetSync) { - const config = webhookConfigs.get(block.id) - if (!config) continue - - const { provider, providerConfig, triggerPath } = config - - try { - const syncResult = await syncCredentialSetWebhooks({ - workflowId, - blockId: block.id, - provider, - triggerPath, - providerConfig, - requestId, - deploymentVersionId, - }) - - if (syncResult.warnings.length > 0) { - collectedWarnings.push(...syncResult.warnings) - } - - if (syncResult.error) { - return { success: false, error: syncResult.error, warnings: collectedWarnings } - } - } catch (error: unknown) { - logger.error(`[${requestId}] Failed to create webhook for ${block.id}`, error) - return { - success: false, - error: { - message: (error as Error)?.message || 'Failed to save trigger configuration', - status: 500, - }, - warnings: collectedWarnings, - } - } - } - // 5. Create webhooks for blocks that need them (two-phase approach for atomicity) const createdSubscriptions: Array<{ webhookId: string @@ -815,8 +610,6 @@ export async function saveTriggerWebhooksForDeploy({ path: sub.triggerPath, provider: sub.provider, providerConfig: sub.updatedProviderConfig, - credentialSetId: - (sub.updatedProviderConfig.credentialSetId as string | undefined) || null, isActive: true, createdAt: new Date(), updatedAt: new Date(), @@ -915,7 +708,7 @@ export async function saveTriggerWebhooksForDeploy({ } } - return { success: true, warnings: collectedWarnings.length > 0 ? collectedWarnings : undefined } + return { success: true } } async function persistCreatedWebhookRecordAfterCleanupFailure({ @@ -944,7 +737,6 @@ async function persistCreatedWebhookRecordAfterCleanupFailure({ path: sub.triggerPath, provider: sub.provider, providerConfig: sub.updatedProviderConfig, - credentialSetId: (sub.updatedProviderConfig.credentialSetId as string | undefined) || null, isActive: true, createdAt: new Date(), updatedAt: new Date(), diff --git a/apps/sim/lib/webhooks/polling/gmail.ts b/apps/sim/lib/webhooks/polling/gmail.ts index cc85dc8c930..b2deb341b52 100644 --- a/apps/sim/lib/webhooks/polling/gmail.ts +++ b/apps/sim/lib/webhooks/polling/gmail.ts @@ -68,12 +68,7 @@ export const gmailPollingHandler: PollingProviderHandler = { const webhookId = webhookData.id try { - const accessToken = await resolveOAuthCredential( - webhookData, - 'google-email', - requestId, - logger - ) + const accessToken = await resolveOAuthCredential(webhookData, 'google-email', requestId) const config = getProviderConfig(webhookData.providerConfig) const now = new Date() diff --git a/apps/sim/lib/webhooks/polling/google-calendar.ts b/apps/sim/lib/webhooks/polling/google-calendar.ts index 22ebfb0de0b..94a3c120e8c 100644 --- a/apps/sim/lib/webhooks/polling/google-calendar.ts +++ b/apps/sim/lib/webhooks/polling/google-calendar.ts @@ -99,12 +99,7 @@ export const googleCalendarPollingHandler: PollingProviderHandler = { const webhookId = webhookData.id try { - const accessToken = await resolveOAuthCredential( - webhookData, - 'google-calendar', - requestId, - logger - ) + const accessToken = await resolveOAuthCredential(webhookData, 'google-calendar', requestId) const config = getProviderConfig(webhookData.providerConfig) // Canonical key `calendarId` first; `manualCalendarId` is a transitional basic-first diff --git a/apps/sim/lib/webhooks/polling/google-drive.ts b/apps/sim/lib/webhooks/polling/google-drive.ts index 57a7ceba0f8..aaf321c14e8 100644 --- a/apps/sim/lib/webhooks/polling/google-drive.ts +++ b/apps/sim/lib/webhooks/polling/google-drive.ts @@ -87,12 +87,7 @@ export const googleDrivePollingHandler: PollingProviderHandler = { const webhookId = webhookData.id try { - const accessToken = await resolveOAuthCredential( - webhookData, - 'google-drive', - requestId, - logger - ) + const accessToken = await resolveOAuthCredential(webhookData, 'google-drive', requestId) const config = getProviderConfig(webhookData.providerConfig) diff --git a/apps/sim/lib/webhooks/polling/google-sheets.ts b/apps/sim/lib/webhooks/polling/google-sheets.ts index 1239993ffb6..72147225469 100644 --- a/apps/sim/lib/webhooks/polling/google-sheets.ts +++ b/apps/sim/lib/webhooks/polling/google-sheets.ts @@ -56,12 +56,7 @@ export const googleSheetsPollingHandler: PollingProviderHandler = { const webhookId = webhookData.id try { - const accessToken = await resolveOAuthCredential( - webhookData, - 'google-sheets', - requestId, - logger - ) + const accessToken = await resolveOAuthCredential(webhookData, 'google-sheets', requestId) const config = getProviderConfig(webhookData.providerConfig) // Canonical keys (`spreadsheetId`/`sheetName`) first; the `manual*` keys are a transitional diff --git a/apps/sim/lib/webhooks/polling/hubspot.ts b/apps/sim/lib/webhooks/polling/hubspot.ts index a714d269264..65c64ecffa7 100644 --- a/apps/sim/lib/webhooks/polling/hubspot.ts +++ b/apps/sim/lib/webhooks/polling/hubspot.ts @@ -190,7 +190,7 @@ export const hubspotPollingHandler: PollingProviderHandler = { const webhookId = webhookData.id try { - const accessToken = await resolveOAuthCredential(webhookData, 'hubspot', requestId, logger) + const accessToken = await resolveOAuthCredential(webhookData, 'hubspot', requestId) const config = getProviderConfig(webhookData.providerConfig) if (config.objectType === 'list_membership') { diff --git a/apps/sim/lib/webhooks/polling/outlook.ts b/apps/sim/lib/webhooks/polling/outlook.ts index 660c81f4972..4cedf69a4dd 100644 --- a/apps/sim/lib/webhooks/polling/outlook.ts +++ b/apps/sim/lib/webhooks/polling/outlook.ts @@ -117,7 +117,7 @@ export const outlookPollingHandler: PollingProviderHandler = { try { logger.info(`[${requestId}] Processing Outlook webhook: ${webhookId}`) - const accessToken = await resolveOAuthCredential(webhookData, 'outlook', requestId, logger) + const accessToken = await resolveOAuthCredential(webhookData, 'outlook', requestId) const config = getProviderConfig(webhookData.providerConfig) const now = new Date() diff --git a/apps/sim/lib/webhooks/polling/utils.test.ts b/apps/sim/lib/webhooks/polling/utils.test.ts index d8f4472c32a..137c70b3667 100644 --- a/apps/sim/lib/webhooks/polling/utils.test.ts +++ b/apps/sim/lib/webhooks/polling/utils.test.ts @@ -3,14 +3,25 @@ */ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockUpdate, mockSet, mockWhere, sqlCalls } = vi.hoisted(() => ({ - mockUpdate: vi.fn(), - mockSet: vi.fn(), - mockWhere: vi.fn(), - sqlCalls: [] as Array<{ strings: readonly string[]; values: unknown[] }>, -})) +const { mockUpdate, mockSet, mockWhere, mockSelect, mockSelectRows, sqlCalls } = vi.hoisted(() => { + const mockSelectRows = vi.fn() + return { + mockUpdate: vi.fn(), + mockSet: vi.fn(), + mockWhere: vi.fn(), + mockSelectRows, + mockSelect: vi.fn(() => ({ + from: vi.fn(() => ({ + where: vi.fn(() => ({ + limit: mockSelectRows, + })), + })), + })), + sqlCalls: [] as Array<{ strings: readonly string[]; values: unknown[] }>, + } +}) -vi.mock('@sim/db', () => ({ db: { update: mockUpdate } })) +vi.mock('@sim/db', () => ({ db: { update: mockUpdate, select: mockSelect } })) vi.mock('@sim/db/schema', () => ({ webhook: { id: 'webhook.id', @@ -18,7 +29,6 @@ vi.mock('@sim/db/schema', () => ({ updatedAt: 'webhook.updatedAt', }, account: {}, - credentialSet: {}, workflow: {}, workflowDeploymentVersion: {}, })) @@ -34,7 +44,6 @@ vi.mock('drizzle-orm', () => ({ ne: vi.fn(), or: vi.fn(), })) -vi.mock('@/lib/billing', () => ({ isOrganizationOnTeamOrEnterprisePlan: vi.fn() })) vi.mock('@/app/api/auth/oauth/utils', () => ({ getOAuthToken: vi.fn(), refreshAccessTokenIfNeeded: vi.fn(), @@ -42,7 +51,13 @@ vi.mock('@/app/api/auth/oauth/utils', () => ({ })) vi.mock('@/triggers/constants', () => ({ MAX_CONSECUTIVE_FAILURES: 5 })) -import { updateWebhookProviderConfig } from '@/lib/webhooks/polling/utils' +import type { WebhookRecord } from '@/lib/webhooks/polling/types' +import { resolveOAuthCredential, updateWebhookProviderConfig } from '@/lib/webhooks/polling/utils' +import { + getOAuthToken, + refreshAccessTokenIfNeeded, + resolveOAuthAccountId, +} from '@/app/api/auth/oauth/utils' const logger = { error: vi.fn() } as never @@ -92,3 +107,79 @@ describe('updateWebhookProviderConfig (atomic jsonb merge)', () => { expect(sqlText).toContain(')::json') }) }) + +describe('resolveOAuthCredential (single-credential polling)', () => { + const makeWebhook = (providerConfig: Record): WebhookRecord => + ({ id: 'wh-1', providerConfig }) as unknown as WebhookRecord + + beforeEach(() => { + vi.clearAllMocks() + mockSelectRows.mockResolvedValue([]) + }) + + it('resolves via credentialId: account lookup then token refresh', async () => { + vi.mocked(resolveOAuthAccountId).mockResolvedValue({ + accountId: 'acc-1', + } as Awaited>) + mockSelectRows.mockResolvedValue([{ userId: 'owner-1' }]) + vi.mocked(refreshAccessTokenIfNeeded).mockResolvedValue('tok-abc') + + const token = await resolveOAuthCredential( + makeWebhook({ credentialId: 'cred-1' }), + 'google-email', + 'req-1' + ) + + expect(token).toBe('tok-abc') + expect(resolveOAuthAccountId).toHaveBeenCalledWith('cred-1') + expect(refreshAccessTokenIfNeeded).toHaveBeenCalledWith('acc-1', 'owner-1', 'req-1') + expect(getOAuthToken).not.toHaveBeenCalled() + }) + + it('throws when the credential cannot be resolved to an OAuth account', async () => { + vi.mocked(resolveOAuthAccountId).mockResolvedValue(null) + + await expect( + resolveOAuthCredential(makeWebhook({ credentialId: 'cred-gone' }), 'google-email', 'req-1') + ).rejects.toThrow('Failed to resolve OAuth account for credential cred-gone') + }) + + it('throws when the resolved account row does not exist', async () => { + vi.mocked(resolveOAuthAccountId).mockResolvedValue({ + accountId: 'acc-missing', + } as Awaited>) + mockSelectRows.mockResolvedValue([]) + + await expect( + resolveOAuthCredential(makeWebhook({ credentialId: 'cred-1' }), 'google-email', 'req-1') + ).rejects.toThrow('Credential cred-1 not found for webhook wh-1') + }) + + it('falls back to the legacy userId path via getOAuthToken', async () => { + vi.mocked(getOAuthToken).mockResolvedValue('tok-legacy') + + const token = await resolveOAuthCredential( + makeWebhook({ userId: 'user-1' }), + 'outlook', + 'req-1' + ) + + expect(token).toBe('tok-legacy') + expect(getOAuthToken).toHaveBeenCalledWith('user-1', 'outlook') + expect(resolveOAuthAccountId).not.toHaveBeenCalled() + }) + + it('throws when neither credentialId nor userId is present', async () => { + await expect(resolveOAuthCredential(makeWebhook({}), 'gmail', 'req-1')).rejects.toThrow( + 'Missing credential info for webhook wh-1' + ) + }) + + it('throws when the legacy userId path yields no token', async () => { + vi.mocked(getOAuthToken).mockResolvedValue(null) + + await expect( + resolveOAuthCredential(makeWebhook({ userId: 'user-1' }), 'outlook', 'req-1') + ).rejects.toThrow('Failed to get outlook access token for webhook wh-1') + }) +}) diff --git a/apps/sim/lib/webhooks/polling/utils.ts b/apps/sim/lib/webhooks/polling/utils.ts index 400b36f686d..91f65015b69 100644 --- a/apps/sim/lib/webhooks/polling/utils.ts +++ b/apps/sim/lib/webhooks/polling/utils.ts @@ -1,14 +1,7 @@ import { db } from '@sim/db' -import { - account, - credentialSet, - webhook, - workflow, - workflowDeploymentVersion, -} from '@sim/db/schema' +import { account, webhook, workflow, workflowDeploymentVersion } from '@sim/db/schema' import type { Logger } from '@sim/logger' import { and, eq, isNull, ne, or, sql } from 'drizzle-orm' -import { isOrganizationOnTeamOrEnterprisePlan } from '@/lib/billing' import type { WebhookRecord, WorkflowRecord } from '@/lib/webhooks/polling/types' import { getOAuthToken, @@ -191,41 +184,16 @@ export async function updateWebhookProviderConfig( export async function resolveOAuthCredential( webhookData: WebhookRecord, oauthProvider: string, - requestId: string, - logger: Logger + requestId: string ): Promise { const metadata = webhookData.providerConfig as Record | null const credentialId = metadata?.credentialId as string | undefined const userId = metadata?.userId as string | undefined - const credentialSetId = (webhookData.credentialSetId as string | undefined) ?? undefined if (!credentialId && !userId) { throw new Error(`Missing credential info for webhook ${webhookData.id}`) } - if (credentialSetId) { - const [cs] = await db - .select({ organizationId: credentialSet.organizationId }) - .from(credentialSet) - .where(eq(credentialSet.id, credentialSetId)) - .limit(1) - - if (cs?.organizationId) { - const hasAccess = await isOrganizationOnTeamOrEnterprisePlan(cs.organizationId) - if (!hasAccess) { - logger.error( - `[${requestId}] Polling Group plan restriction: Your current plan does not support Polling Groups. Upgrade to Team or Enterprise to use this feature.`, - { - webhookId: webhookData.id, - credentialSetId, - organizationId: cs.organizationId, - } - ) - throw new Error('Polling Group plan restriction') - } - } - } - let accessToken: string | null = null if (credentialId) { diff --git a/apps/sim/lib/webhooks/processor.test.ts b/apps/sim/lib/webhooks/processor.test.ts index 60f26d86c33..1e823ed8af5 100644 --- a/apps/sim/lib/webhooks/processor.test.ts +++ b/apps/sim/lib/webhooks/processor.test.ts @@ -139,7 +139,7 @@ describe('findAllWebhooksForPath cross-tenant collision', () => { workflow: { id: workflowId }, }) - it('returns all rows when they belong to a single workflow (credential set fan-out)', async () => { + it('returns all rows when they belong to a single workflow', async () => { mockWebhookLookupResult.rows = [ makeRow('workflow-1', 'wh-a', new Date('2026-01-01')), makeRow('workflow-1', 'wh-b', new Date('2026-01-02')), @@ -163,7 +163,7 @@ describe('findAllWebhooksForPath cross-tenant collision', () => { expect(results[0].webhook.workflowId).toBe('victim-workflow') }) - it("preserves the owner's full credential-set fan-out while dropping a foreign row", async () => { + it("preserves the owner's full multi-webhook match while dropping a foreign row", async () => { const victimA = makeRow('victim-workflow', 'victim-wh-a', new Date('2026-01-01')) const victimB = makeRow('victim-workflow', 'victim-wh-b', new Date('2026-01-03')) const attacker = makeRow('attacker-workflow', 'attacker-wh', new Date('2026-05-01')) diff --git a/apps/sim/lib/webhooks/processor.ts b/apps/sim/lib/webhooks/processor.ts index dd6629215b3..67ca4ac030a 100644 --- a/apps/sim/lib/webhooks/processor.ts +++ b/apps/sim/lib/webhooks/processor.ts @@ -1,15 +1,12 @@ import { db, webhook, workflow, workflowDeploymentVersion } from '@sim/db' -import { credentialSet } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { and, eq, isNull, or } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { isOrganizationOnTeamOrEnterprisePlan } from '@/lib/billing/core/subscription' import { tryAdmit } from '@/lib/core/admission/gate' import { getInlineJobQueue, getJobQueue, shouldExecuteInline } from '@/lib/core/async-jobs' import type { AsyncExecutionCorrelation } from '@/lib/core/async-jobs/types' -import { isProd } from '@/lib/core/config/env-flags' import { assertContentLengthWithinLimit, isPayloadSizeLimitError, @@ -52,35 +49,6 @@ export interface WebhookPreprocessingResult { correlation?: AsyncExecutionCorrelation } -async function verifyCredentialSetBilling(credentialSetId: string): Promise<{ - valid: boolean - error?: string -}> { - if (!isProd) { - return { valid: true } - } - - const [set] = await db - .select({ organizationId: credentialSet.organizationId }) - .from(credentialSet) - .where(eq(credentialSet.id, credentialSetId)) - .limit(1) - - if (!set) { - return { valid: false, error: 'Credential set not found' } - } - - const hasTeamPlan = await isOrganizationOnTeamOrEnterprisePlan(set.organizationId) - if (!hasTeamPlan) { - return { - valid: false, - error: 'Credential sets require a Team or Enterprise plan. Please upgrade to continue.', - } - } - - return { valid: true } -} - const WEBHOOK_BODY_LABEL = 'Webhook request body' export async function parseWebhookBody( @@ -332,7 +300,7 @@ async function findWebhookAndWorkflow( /** * Finds all webhooks matching a path, scoped to a single workflow. * - * Legitimate fan-out (credential sets) is always within one workflow, but paths + * Legitimate multi-webhook matches are always within one workflow, but paths * are user-controlled and only unique per deployment version, so two tenants can * register the same path. On collision we keep only the workflow that registered * the path first, so one tenant can never receive another's webhook deliveries. @@ -398,9 +366,7 @@ export async function findAllWebhooksForPath( } if (results.length > 1) { - logger.info( - `[${options.requestId}] Found ${results.length} webhooks for path: ${options.path} (credential set fan-out)` - ) + logger.info(`[${options.requestId}] Found ${results.length} webhooks for path: ${options.path}`) } return results @@ -587,17 +553,6 @@ export async function queueWebhookExecution( } const credentialId = providerConfig.credentialId as string | undefined - const credentialSetId = foundWebhook.credentialSetId as string | undefined - - if (credentialSetId) { - const billingCheck = await verifyCredentialSetBilling(credentialSetId) - if (!billingCheck.valid) { - logger.warn( - `[${options.requestId}] Credential set billing check failed: ${billingCheck.error}` - ) - return NextResponse.json({ error: billingCheck.error }, { status: 403 }) - } - } const actorUserId = options.actorUserId if (!actorUserId) { @@ -780,15 +735,6 @@ export async function processPolledWebhookEvent( const providerConfig = (foundWebhook.providerConfig as Record) || {} const credentialId = providerConfig.credentialId as string | undefined - const credentialSetId = foundWebhook.credentialSetId as string | undefined - - if (credentialSetId) { - const billingCheck = await verifyCredentialSetBilling(credentialSetId) - if (!billingCheck.valid) { - logger.warn(`[${requestId}] Credential set billing check failed: ${billingCheck.error}`) - return { success: false, error: billingCheck.error, statusCode: 403 } - } - } const actorUserId = preprocessResult.actorUserId if (!actorUserId) { diff --git a/apps/sim/lib/webhooks/utils.server.ts b/apps/sim/lib/webhooks/utils.server.ts index 8a34efc72a7..0bff56de94e 100644 --- a/apps/sim/lib/webhooks/utils.server.ts +++ b/apps/sim/lib/webhooks/utils.server.ts @@ -1,14 +1,7 @@ -import { db, workflowDeploymentVersion } from '@sim/db' +import { db } from '@sim/db' import { webhook, workflow } from '@sim/db/schema' -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import { generateShortId } from '@sim/utils/id' -import { and, eq, isNull, or } from 'drizzle-orm' +import { and, eq, isNull } from 'drizzle-orm' import type { DbOrTx } from '@/lib/db/types' -import { getProviderIdFromServiceId } from '@/lib/oauth' -import { cleanupExternalWebhook } from '@/lib/webhooks/provider-subscriptions' -import { getCredentialsForCredentialSet } from '@/app/api/auth/oauth/utils' -import { isPollingWebhookProvider } from '@/triggers/constants' /** * Returns the id of a different workflow that already owns an active webhook on @@ -46,363 +39,3 @@ export async function findConflictingWebhookPathOwner(params: { const conflict = existing.find((row) => row.workflowId !== workflowId) return conflict ? conflict.workflowId : null } - -/** - * Result of syncing webhooks for a credential set - */ -export interface CredentialSetWebhookSyncResult { - webhooks: Array<{ - id: string - credentialId: string - isNew: boolean - }> - created: number - updated: number - deleted: number - failed: Array<{ - credentialId: string - error: string - }> -} - -/** - * Sync webhooks for a credential set. - * - * For credential sets, we create one webhook per credential in the set. - * Each webhook has its own state and credentialId. - * - * Path strategy: - * - Polling triggers (gmail, outlook): unique paths per credential (for independent polling) - * - External triggers (slack, etc.): shared path (external service sends to one URL) - * - * This function: - * 1. Gets all credentials in the credential set - * 2. Gets existing webhooks for this workflow+block with this credentialSetId - * 3. Creates webhooks for new credentials - * 4. Updates config for existing webhooks (preserving state) - * 5. Deletes webhooks for credentials no longer in the set - * - * Must run OUTSIDE any open transaction: credential resolution can trigger an - * OAuth token refresh (external HTTP + its own committed writes) and webhook - * cleanup can call external provider APIs, neither of which may execute while - * a pooled connection is held. - */ -export async function syncWebhooksForCredentialSet(params: { - workflowId: string - blockId: string - provider: string - basePath: string - credentialSetId: string - oauthProviderId: string - providerConfig: Record - requestId: string - deploymentVersionId?: string -}): Promise { - const { - workflowId, - blockId, - provider, - basePath, - credentialSetId, - oauthProviderId, - providerConfig, - requestId, - deploymentVersionId, - } = params - - const syncLogger = createLogger('CredentialSetWebhookSync') - syncLogger.info( - `[${requestId}] Syncing webhooks for credential set ${credentialSetId}, provider ${provider}` - ) - - const useUniquePaths = isPollingWebhookProvider(provider) - - const credentials = await getCredentialsForCredentialSet(credentialSetId, oauthProviderId) - - if (credentials.length === 0) { - syncLogger.warn( - `[${requestId}] No credentials found in credential set ${credentialSetId} for provider ${oauthProviderId}` - ) - return { webhooks: [], created: 0, updated: 0, deleted: 0, failed: [] } - } - - syncLogger.info( - `[${requestId}] Found ${credentials.length} credentials in set ${credentialSetId}` - ) - - const existingWebhooks = await db - .select() - .from(webhook) - .where( - deploymentVersionId - ? and( - eq(webhook.workflowId, workflowId), - eq(webhook.blockId, blockId), - eq(webhook.deploymentVersionId, deploymentVersionId), - isNull(webhook.archivedAt) - ) - : and( - eq(webhook.workflowId, workflowId), - eq(webhook.blockId, blockId), - isNull(webhook.archivedAt) - ) - ) - - const credentialSetWebhooks = existingWebhooks.filter( - (wh) => wh.credentialSetId === credentialSetId - ) - - syncLogger.info( - `[${requestId}] Found ${credentialSetWebhooks.length} existing webhooks for credential set` - ) - - const existingByCredentialId = new Map() - for (const wh of credentialSetWebhooks) { - const config = wh.providerConfig as Record - if (config?.credentialId) { - existingByCredentialId.set(config.credentialId as string, wh) - } - } - - const credentialIdsInSet = new Set(credentials.map((c) => c.credentialId)) - const [workflowRecord] = await db - .select({ - id: workflow.id, - userId: workflow.userId, - workspaceId: workflow.workspaceId, - }) - .from(workflow) - .where(eq(workflow.id, workflowId)) - .limit(1) - - const result: CredentialSetWebhookSyncResult = { - webhooks: [], - created: 0, - updated: 0, - deleted: 0, - failed: [], - } - - for (const cred of credentials) { - try { - const existingWebhook = existingByCredentialId.get(cred.credentialId) - - if (existingWebhook) { - const existingConfig = existingWebhook.providerConfig as Record - - const updatedConfig = { - ...providerConfig, - basePath, - credentialId: cred.credentialId, - credentialSetId: credentialSetId, - historyId: existingConfig?.historyId, - lastCheckedTimestamp: existingConfig?.lastCheckedTimestamp, - setupCompleted: existingConfig?.setupCompleted, - externalId: existingConfig?.externalId, - userId: cred.userId, - } - - await db - .update(webhook) - .set({ - ...(deploymentVersionId ? { deploymentVersionId } : {}), - providerConfig: updatedConfig, - isActive: true, - updatedAt: new Date(), - }) - .where(eq(webhook.id, existingWebhook.id)) - - result.webhooks.push({ - id: existingWebhook.id, - credentialId: cred.credentialId, - isNew: false, - }) - result.updated++ - - syncLogger.debug( - `[${requestId}] Updated webhook ${existingWebhook.id} for credential ${cred.credentialId}` - ) - } else { - const webhookId = generateShortId() - const webhookPath = useUniquePaths - ? `${basePath}-${cred.credentialId.slice(0, 8)}` - : basePath - - const newConfig = { - ...providerConfig, - basePath, - credentialId: cred.credentialId, - credentialSetId: credentialSetId, - userId: cred.userId, - } - - await db.insert(webhook).values({ - id: webhookId, - workflowId, - blockId, - path: webhookPath, - provider, - providerConfig: newConfig, - credentialSetId, - isActive: true, - ...(deploymentVersionId ? { deploymentVersionId } : {}), - createdAt: new Date(), - updatedAt: new Date(), - }) - - result.webhooks.push({ - id: webhookId, - credentialId: cred.credentialId, - isNew: true, - }) - result.created++ - - syncLogger.debug( - `[${requestId}] Created webhook ${webhookId} for credential ${cred.credentialId}` - ) - } - } catch (error) { - const errorMessage = getErrorMessage(error, 'Unknown error') - syncLogger.error( - `[${requestId}] Failed to sync webhook for credential ${cred.credentialId}: ${errorMessage}` - ) - result.failed.push({ - credentialId: cred.credentialId, - error: errorMessage, - }) - } - } - - for (const [credentialId, existingWebhook] of existingByCredentialId) { - if (!credentialIdsInSet.has(credentialId)) { - try { - if (workflowRecord) { - await cleanupExternalWebhook(existingWebhook, workflowRecord, requestId) - } - await db.delete(webhook).where(eq(webhook.id, existingWebhook.id)) - result.deleted++ - - syncLogger.debug( - `[${requestId}] Deleted webhook ${existingWebhook.id} for removed credential ${credentialId}` - ) - } catch (error) { - const errorMessage = getErrorMessage(error, 'Unknown error') - syncLogger.error( - `[${requestId}] Failed to delete webhook ${existingWebhook.id} for credential ${credentialId}: ${errorMessage}` - ) - result.failed.push({ - credentialId, - error: `Failed to delete: ${errorMessage}`, - }) - } - } - } - - syncLogger.info( - `[${requestId}] Credential set webhook sync complete: ${result.created} created, ${result.updated} updated, ${result.deleted} deleted, ${result.failed.length} failed` - ) - - return result -} - -/** - * Sync all webhooks that use a specific credential set. - * Called when credential set membership changes (member added/removed). - * - * This finds all workflows with webhooks using this credential set and resyncs them. - * Must run OUTSIDE any open transaction — see {@link syncWebhooksForCredentialSet}. - */ -export async function syncAllWebhooksForCredentialSet( - credentialSetId: string, - requestId: string -): Promise<{ workflowsUpdated: number; totalCreated: number; totalDeleted: number }> { - const syncLogger = createLogger('CredentialSetMembershipSync') - syncLogger.info(`[${requestId}] Syncing all webhooks for credential set ${credentialSetId}`) - - const webhooksForSet = await db - .select({ webhook }) - .from(webhook) - .leftJoin( - workflowDeploymentVersion, - and( - eq(workflowDeploymentVersion.workflowId, webhook.workflowId), - eq(workflowDeploymentVersion.isActive, true) - ) - ) - .where( - and( - eq(webhook.credentialSetId, credentialSetId), - isNull(webhook.archivedAt), - or( - eq(webhook.deploymentVersionId, workflowDeploymentVersion.id), - and(isNull(workflowDeploymentVersion.id), isNull(webhook.deploymentVersionId)) - ) - ) - ) - - if (webhooksForSet.length === 0) { - syncLogger.info(`[${requestId}] No webhooks found using credential set ${credentialSetId}`) - return { workflowsUpdated: 0, totalCreated: 0, totalDeleted: 0 } - } - - const triggerGroups = new Map() - for (const row of webhooksForSet) { - const wh = row.webhook - const key = `${wh.workflowId}:${wh.blockId}` - if (!triggerGroups.has(key)) { - triggerGroups.set(key, wh) - } - } - - syncLogger.info( - `[${requestId}] Found ${triggerGroups.size} triggers using credential set ${credentialSetId}` - ) - - let workflowsUpdated = 0 - let totalCreated = 0 - let totalDeleted = 0 - - for (const [key, representativeWebhook] of triggerGroups) { - if (!representativeWebhook.provider) { - syncLogger.warn(`[${requestId}] Skipping webhook without provider: ${key}`) - continue - } - - const config = representativeWebhook.providerConfig as Record - const oauthProviderId = getProviderIdFromServiceId(representativeWebhook.provider) - - const { credentialId: _cId, userId: _uId, basePath: _bp, ...baseConfig } = config - const basePath = - (config.basePath as string) || representativeWebhook.blockId || representativeWebhook.path - - try { - const syncResult = await syncWebhooksForCredentialSet({ - workflowId: representativeWebhook.workflowId, - blockId: representativeWebhook.blockId || '', - provider: representativeWebhook.provider, - basePath, - credentialSetId, - oauthProviderId, - providerConfig: baseConfig, - requestId, - deploymentVersionId: representativeWebhook.deploymentVersionId || undefined, - }) - - workflowsUpdated++ - totalCreated += syncResult.created - totalDeleted += syncResult.deleted - - syncLogger.debug( - `[${requestId}] Synced webhooks for ${key}: ${syncResult.created} created, ${syncResult.deleted} deleted` - ) - } catch (error) { - syncLogger.error(`[${requestId}] Error syncing webhooks for ${key}`, error) - } - } - - syncLogger.info( - `[${requestId}] Credential set membership sync complete: ${workflowsUpdated} workflows updated, ${totalCreated} webhooks created, ${totalDeleted} webhooks deleted` - ) - - return { workflowsUpdated, totalCreated, totalDeleted } -} diff --git a/apps/sim/lib/workflows/comparison/format-description.test.ts b/apps/sim/lib/workflows/comparison/format-description.test.ts index 22fb5b2fa05..c1051bafa10 100644 --- a/apps/sim/lib/workflows/comparison/format-description.test.ts +++ b/apps/sim/lib/workflows/comparison/format-description.test.ts @@ -21,7 +21,6 @@ vi.mock('@/blocks/types', () => ({ })) vi.mock('@/executor/constants', () => ({ - CREDENTIAL_SET: { PREFIX: 'cred_set_' }, isUuid: (v: string) => /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(v), })) @@ -36,10 +35,6 @@ vi.mock('@/lib/workflows/subblocks/context', () => ({ buildSelectorContextFromBlock: vi.fn(() => ({})), })) -vi.mock('@/hooks/queries/utils/fetch-credential-set', () => ({ - fetchCredentialSetById: vi.fn(), -})) - vi.mock('@/hooks/queries/oauth/oauth-credentials', () => ({ fetchOAuthCredentialDetail: vi.fn(() => []), })) diff --git a/apps/sim/lib/workflows/comparison/resolve-values.ts b/apps/sim/lib/workflows/comparison/resolve-values.ts index ac866070a14..64cfc114cef 100644 --- a/apps/sim/lib/workflows/comparison/resolve-values.ts +++ b/apps/sim/lib/workflows/comparison/resolve-values.ts @@ -3,9 +3,8 @@ import { truncate } from '@sim/utils/string' import { buildSelectorContextFromBlock } from '@/lib/workflows/subblocks/context' import { getBlock } from '@/blocks/registry' import { SELECTOR_TYPES_HYDRATION_REQUIRED, type SubBlockConfig } from '@/blocks/types' -import { CREDENTIAL_SET, isUuid } from '@/executor/constants' +import { isUuid } from '@/executor/constants' import { fetchOAuthCredentialDetail } from '@/hooks/queries/oauth/oauth-credentials' -import { fetchCredentialSetById } from '@/hooks/queries/utils/fetch-credential-set' import { getSelectorDefinition, loadAllSelectorOptions } from '@/hooks/selectors/registry' import { resolveSelectorForSubBlock } from '@/hooks/selectors/resolution' import type { SelectorContext, SelectorKey } from '@/hooks/selectors/types' @@ -50,12 +49,6 @@ function getSemanticFallback(subBlockConfig: SubBlockConfig): string { async function resolveCredential(credentialId: string, workflowId: string): Promise { try { - if (credentialId.startsWith(CREDENTIAL_SET.PREFIX)) { - const setId = credentialId.slice(CREDENTIAL_SET.PREFIX.length) - const credentialSet = await fetchCredentialSetById(setId) - return credentialSet?.name ?? null - } - const credentials = await fetchOAuthCredentialDetail(credentialId, workflowId) if (credentials.length > 0) { return credentials[0].name ?? null @@ -228,7 +221,7 @@ export async function resolveValueForDisplay( const isCredentialField = subBlockConfig.type === 'oauth-input' || context.subBlockId === 'credential' - if (isCredentialField && (value.startsWith(CREDENTIAL_SET.PREFIX) || isUuid(value))) { + if (isCredentialField && isUuid(value)) { const label = await resolveCredential(value, context.workflowId) if (label) { return { original: value, displayLabel: label, resolved: true } @@ -284,14 +277,6 @@ export async function resolveValueForDisplay( return { original: value, displayLabel: semanticFallback, resolved: true } } - if (value.startsWith(CREDENTIAL_SET.PREFIX)) { - const label = await resolveCredential(value, context.workflowId) - if (label) { - return { original: value, displayLabel: label, resolved: true } - } - return { original: value, displayLabel: semanticFallback, resolved: true } - } - return { original: value, displayLabel: formatValueForDisplay(value), diff --git a/apps/sim/lib/workflows/orchestration/deploy.ts b/apps/sim/lib/workflows/orchestration/deploy.ts index 26947600a0f..7f7617f3402 100644 --- a/apps/sim/lib/workflows/orchestration/deploy.ts +++ b/apps/sim/lib/workflows/orchestration/deploy.ts @@ -125,7 +125,7 @@ export async function performFullDeploy( workflowName: workflowName || workflowRecord.name || undefined, description: params.versionDescription, name: params.versionName, - validateWorkflowState: async (workflowState, executor) => { + validateWorkflowState: async (workflowState) => { const scheduleValidation = validateWorkflowSchedules(workflowState.blocks) if (!scheduleValidation.isValid) { return { @@ -134,10 +134,7 @@ export async function performFullDeploy( errorCode: 'validation', } } - const triggerValidation = await validateTriggerWebhookConfigForDeploy( - workflowState.blocks, - executor - ) + const triggerValidation = await validateTriggerWebhookConfigForDeploy(workflowState.blocks) if (!triggerValidation.success) { return { success: false, diff --git a/apps/sim/lib/workspaces/fork/mapping/dependent-reconfigs.test.ts b/apps/sim/lib/workspaces/fork/mapping/dependent-reconfigs.test.ts index 683d9a34511..9b9927cb489 100644 --- a/apps/sim/lib/workspaces/fork/mapping/dependent-reconfigs.test.ts +++ b/apps/sim/lib/workspaces/fork/mapping/dependent-reconfigs.test.ts @@ -255,7 +255,7 @@ describe('collectForkDependentReconfigs', () => { expect(collectForkDependentReconfigs([replaceItem], states, resolve)).toEqual([]) }) - it('skips create-mode targets and credentialSet refs', () => { + it('skips create-mode targets', () => { vi.mocked(getBlock).mockReturnValue( blockWith([ { id: 'credential', title: 'Credential', type: 'oauth-input' }, @@ -281,17 +281,6 @@ describe('collectForkDependentReconfigs', () => { resolve ) ).toEqual([]) - - const orgSet = new Map([ - [ - 'wf-src', - sourceState('gmail', { - credential: { value: 'credentialSet:cs-1' }, - folder: { value: 'INBOX' }, - }), - ], - ]) - expect(collectForkDependentReconfigs([replaceItem], orgSet, resolve)).toEqual([]) }) it('walks the transitive chain and tags the context key a re-pick provides', () => { diff --git a/apps/sim/lib/workspaces/fork/mapping/dependent-reconfigs.ts b/apps/sim/lib/workspaces/fork/mapping/dependent-reconfigs.ts index 9ffc55d9eb1..9b20a3dfaac 100644 --- a/apps/sim/lib/workspaces/fork/mapping/dependent-reconfigs.ts +++ b/apps/sim/lib/workspaces/fork/mapping/dependent-reconfigs.ts @@ -122,8 +122,7 @@ function emitAnchoredDependents(params: EmitAnchoredParams): void { ? resolveActiveCanonicalValue(group, values, canonicalModes) : values[anchorCfg.id] const parentSourceId = typeof rawValue === 'string' ? rawValue : '' - // Skip empty and org-scoped credential sets (those carry over unchanged). - if (!parentSourceId || parentSourceId.startsWith('credentialSet:')) continue + if (!parentSourceId) continue // Multi-value parents (comma-joined) can't match a single mapping entry; skip // (the field falls back to needs-config) rather than mis-bind to one of several. if (parentSourceId.includes(',')) continue diff --git a/apps/sim/lib/workspaces/fork/mapping/mapping-service.ts b/apps/sim/lib/workspaces/fork/mapping/mapping-service.ts index a2f05cea3f4..14410f2cf43 100644 --- a/apps/sim/lib/workspaces/fork/mapping/mapping-service.ts +++ b/apps/sim/lib/workspaces/fork/mapping/mapping-service.ts @@ -242,8 +242,8 @@ export function findDuplicateTargetEntry( const sourcesByTarget = new Map>() for (const entry of entries) { if (entry.targetId == null) continue - // Null-byte separator so a targetId containing ':' (e.g. credentialSet:...) can't - // be confused with a different (resourceType, targetId) pair. + // Null-byte separator so a targetId containing ':' can't be confused with a + // different (resourceType, targetId) pair. const key = `${entry.resourceType}\u0000${entry.targetId}` const sources = sourcesByTarget.get(key) if (!sources) { 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 d549381d74a..94ccc3845e0 100644 --- a/apps/sim/lib/workspaces/fork/remap/remap-references.test.ts +++ b/apps/sim/lib/workspaces/fork/remap/remap-references.test.ts @@ -128,24 +128,6 @@ describe('remapToolBlockResources', () => { expect(result.params).toEqual({ manualCredential: 'mc-src', knowledgeBaseId: 'kb-dst' }) }) - it('preserves an org-scoped credentialSet ref without remapping or recording it', () => { - const tool = { - type: 'testblock', - toolId: 'testblock_run', - params: { credential: 'credentialSet:cs-1' }, - } - const recorded: Array<{ kind: string; id: string; mapped: boolean }> = [] - const result = remapToolBlockResources(tool, { - resolve: () => null, - resolveFileKey: () => null, - record: (kind, id, mapped) => recorded.push({ kind, id, mapped }), - clearUnresolved: true, - blockConfigs, - }) - expect((result.params as Record).credential).toBe('credentialSet:cs-1') - expect(recorded).toHaveLength(0) - }) - it('drops only the uncopied entry in a mixed multi-value field', () => { const tool = { type: 'testblock', @@ -276,33 +258,6 @@ describe('remapForkSubBlocks', () => { expect(result.unmapped.every((r) => r.kind !== 'knowledge-base')).toBe(true) }) - it('promote mode: preserves a credentialSet ref without flagging it', () => { - const sb: SubBlockRecord = { - triggerCredentials: { - id: 'triggerCredentials', - type: 'oauth-input', - value: 'credentialSet:cs-1', - }, - } - const result = remapForkSubBlocks(sb, () => null, 'promote') - expect(result.subBlocks.triggerCredentials.value).toBe('credentialSet:cs-1') - expect(result.references).toHaveLength(0) - expect(result.unmapped).toHaveLength(0) - }) - - it('create mode: keeps a credentialSet ref (org-scoped, not cleared)', () => { - const sb: SubBlockRecord = { - triggerCredentials: { - id: 'triggerCredentials', - type: 'oauth-input', - value: 'credentialSet:cs-1', - }, - } - const result = remapForkSubBlocks(sb, () => null, 'create') - expect(result.subBlocks.triggerCredentials.value).toBe('credentialSet:cs-1') - expect(result.references).toHaveLength(0) - }) - it('promote mode: rewrites {{ENV}} nested in an array-form tool param', () => { const sb: SubBlockRecord = { tools: { diff --git a/apps/sim/lib/workspaces/fork/remap/remap-references.ts b/apps/sim/lib/workspaces/fork/remap/remap-references.ts index fe645f8b9ad..679ccdedd0f 100644 --- a/apps/sim/lib/workspaces/fork/remap/remap-references.ts +++ b/apps/sim/lib/workspaces/fork/remap/remap-references.ts @@ -112,15 +112,6 @@ export function rewriteEnvRefsInText( }) } -/** - * A `credentialSet:` reference points at an ORG-scoped credential set. A fork - * inherits its parent's org, so the set is already valid in the target — these refs - * are preserved verbatim and never treated as a workspace credential to remap/flag. - */ -function isCredentialSetRef(value: string): boolean { - return value.startsWith('credentialSet:') -} - /** * Resolves a source-workspace resource id (or env key, for `env-var`) to its * mapped target id. Returns the target id (which may equal the source for env @@ -257,7 +248,6 @@ export function remapToolBlockResources( if (!overrideKind) continue const currentValue = params[paramId] if (typeof currentValue !== 'string' || !currentValue) continue - if (overrideKind === 'credential' && isCredentialSetRef(currentValue)) continue const target = opts.resolve(overrideKind, currentValue) opts.record?.(overrideKind, currentValue, target != null) if (target != null) { @@ -324,7 +314,6 @@ export function remapToolBlockResources( for (const ref of refs) { if (seen.has(ref.rawValue)) continue seen.add(ref.rawValue) - if (forkKind === 'credential' && isCredentialSetRef(ref.rawValue)) continue const target = opts.resolve(forkKind, ref.rawValue) const mapped = target != null opts.record?.(forkKind, ref.rawValue, mapped) @@ -565,7 +554,6 @@ export function remapForkSubBlocks( for (const ref of parsed) { if (seen.has(ref.rawValue)) continue seen.add(ref.rawValue) - if (forkKind === 'credential' && isCredentialSetRef(ref.rawValue)) continue const required = REQUIRED_KINDS.has(forkKind) const reference: ForkReference = { kind: forkKind, diff --git a/apps/sim/triggers/gmail/poller.ts b/apps/sim/triggers/gmail/poller.ts index 2caabc2e87e..6689f3fc9cb 100644 --- a/apps/sim/triggers/gmail/poller.ts +++ b/apps/sim/triggers/gmail/poller.ts @@ -2,29 +2,11 @@ import { createLogger } from '@sim/logger' import { GmailIcon } from '@/components/icons' import { requestJson } from '@/lib/api/client/request' import { gmailLabelsSelectorContract } from '@/lib/api/contracts/selectors/google' -import { isCredentialSetValue } from '@/executor/constants' import { useSubBlockStore } from '@/stores/workflows/subblock/store' import type { TriggerConfig } from '@/triggers/types' const logger = createLogger('GmailPollingTrigger') -// Gmail system labels that exist for all accounts (used as defaults for credential sets) -const GMAIL_SYSTEM_LABELS = [ - { id: 'INBOX', label: 'Inbox' }, - { id: 'SENT', label: 'Sent' }, - { id: 'DRAFT', label: 'Drafts' }, - { id: 'SPAM', label: 'Spam' }, - { id: 'TRASH', label: 'Trash' }, - { id: 'STARRED', label: 'Starred' }, - { id: 'IMPORTANT', label: 'Important' }, - { id: 'UNREAD', label: 'Unread' }, - { id: 'CATEGORY_PERSONAL', label: 'Category: Personal' }, - { id: 'CATEGORY_SOCIAL', label: 'Category: Social' }, - { id: 'CATEGORY_PROMOTIONS', label: 'Category: Promotions' }, - { id: 'CATEGORY_UPDATES', label: 'Category: Updates' }, - { id: 'CATEGORY_FORUMS', label: 'Category: Forums' }, -] - export const gmailPollingTrigger: TriggerConfig = { id: 'gmail_poller', name: 'Gmail Email Trigger', @@ -44,7 +26,6 @@ export const gmailPollingTrigger: TriggerConfig = { requiredScopes: [], required: true, mode: 'trigger', - supportsCredentialSets: true, }, { id: 'labelIds', @@ -63,10 +44,6 @@ export const gmailPollingTrigger: TriggerConfig = { // Return a sentinel to prevent infinite retry loops when credential is missing throw new Error('No Gmail credential selected') } - // Return default system labels for credential sets (can't fetch user-specific labels for a pool) - if (isCredentialSetValue(credentialId)) { - return GMAIL_SYSTEM_LABELS - } try { const data = await requestJson(gmailLabelsSelectorContract, { query: { credentialId }, diff --git a/apps/sim/triggers/google-calendar/poller.ts b/apps/sim/triggers/google-calendar/poller.ts index 977cd2df4cb..4365cf37a2e 100644 --- a/apps/sim/triggers/google-calendar/poller.ts +++ b/apps/sim/triggers/google-calendar/poller.ts @@ -20,7 +20,6 @@ export const googleCalendarPollingTrigger: TriggerConfig = { requiredScopes: [], required: true, mode: 'trigger', - supportsCredentialSets: true, canonicalParamId: 'oauthCredential', }, { diff --git a/apps/sim/triggers/google-drive/poller.ts b/apps/sim/triggers/google-drive/poller.ts index 3f697a47b25..381c7c3bebc 100644 --- a/apps/sim/triggers/google-drive/poller.ts +++ b/apps/sim/triggers/google-drive/poller.ts @@ -30,7 +30,6 @@ export const googleDrivePollingTrigger: TriggerConfig = { requiredScopes: [], required: true, mode: 'trigger', - supportsCredentialSets: true, canonicalParamId: 'oauthCredential', }, { diff --git a/apps/sim/triggers/google-sheets/poller.ts b/apps/sim/triggers/google-sheets/poller.ts index ea5a51d1f51..4dabd7617e9 100644 --- a/apps/sim/triggers/google-sheets/poller.ts +++ b/apps/sim/triggers/google-sheets/poller.ts @@ -20,7 +20,6 @@ export const googleSheetsPollingTrigger: TriggerConfig = { requiredScopes: [], required: true, mode: 'trigger', - supportsCredentialSets: true, canonicalParamId: 'oauthCredential', }, { diff --git a/apps/sim/triggers/hubspot/poller.ts b/apps/sim/triggers/hubspot/poller.ts index 9f7015eb98b..75a58dbe569 100644 --- a/apps/sim/triggers/hubspot/poller.ts +++ b/apps/sim/triggers/hubspot/poller.ts @@ -8,7 +8,6 @@ import { hubspotPropertiesSelectorContract, } from '@/lib/api/contracts/selectors/hubspot' import { getScopesForService } from '@/lib/oauth/utils' -import { isCredentialSetValue } from '@/executor/constants' import { useSubBlockStore } from '@/stores/workflows/subblock/store' import type { TriggerConfig } from '@/triggers/types' @@ -38,7 +37,6 @@ async function fetchHubSpotProperties(blockId: string, objectType: string) { | string | null if (!credentialId) throw new Error('No HubSpot credential selected') - if (isCredentialSetValue(credentialId)) return [] const data = await requestJson(hubspotPropertiesSelectorContract, { query: { credentialId, objectType }, }) @@ -65,7 +63,6 @@ export const hubspotPollingTrigger: TriggerConfig = { requiredScopes: getScopesForService('hubspot'), required: true, mode: 'trigger', - supportsCredentialSets: true, }, { id: 'objectType', @@ -107,7 +104,6 @@ export const hubspotPollingTrigger: TriggerConfig = { | string | null if (!credentialId) throw new Error('No HubSpot credential selected') - if (isCredentialSetValue(credentialId)) return [] try { const data = await requestJson(hubspotListsSelectorContract, { query: { credentialId }, @@ -202,7 +198,6 @@ export const hubspotPollingTrigger: TriggerConfig = { | null const objectType = resolveSelectedObjectType(blockId) ?? 'contact' if (!credentialId) throw new Error('No HubSpot credential selected') - if (isCredentialSetValue(credentialId)) return [] try { const data = await requestJson(hubspotPipelinesSelectorContract, { query: { credentialId, objectType }, @@ -234,7 +229,6 @@ export const hubspotPollingTrigger: TriggerConfig = { | string | null if (!credentialId) throw new Error('No HubSpot credential selected') - if (isCredentialSetValue(credentialId)) return [] if (!pipelineId) return [] try { const data = await requestJson(hubspotPipelinesSelectorContract, { @@ -264,7 +258,6 @@ export const hubspotPollingTrigger: TriggerConfig = { | string | null if (!credentialId) throw new Error('No HubSpot credential selected') - if (isCredentialSetValue(credentialId)) return [] try { const data = await requestJson(hubspotOwnersSelectorContract, { query: { credentialId }, diff --git a/apps/sim/triggers/outlook/poller.ts b/apps/sim/triggers/outlook/poller.ts index 739809658dc..5496ac2f46f 100644 --- a/apps/sim/triggers/outlook/poller.ts +++ b/apps/sim/triggers/outlook/poller.ts @@ -2,23 +2,11 @@ import { createLogger } from '@sim/logger' import { OutlookIcon } from '@/components/icons' import { requestJson } from '@/lib/api/client/request' import { outlookFoldersSelectorContract } from '@/lib/api/contracts/selectors/microsoft' -import { isCredentialSetValue } from '@/executor/constants' import { useSubBlockStore } from '@/stores/workflows/subblock/store' import type { TriggerConfig } from '@/triggers/types' const logger = createLogger('OutlookPollingTrigger') -// Outlook well-known folders that exist for all accounts (used as defaults for credential sets) -const OUTLOOK_SYSTEM_FOLDERS = [ - { id: 'inbox', label: 'Inbox' }, - { id: 'drafts', label: 'Drafts' }, - { id: 'sentitems', label: 'Sent Items' }, - { id: 'deleteditems', label: 'Deleted Items' }, - { id: 'junkemail', label: 'Junk Email' }, - { id: 'archive', label: 'Archive' }, - { id: 'outbox', label: 'Outbox' }, -] - export const outlookPollingTrigger: TriggerConfig = { id: 'outlook_poller', name: 'Outlook Email Trigger', @@ -38,7 +26,6 @@ export const outlookPollingTrigger: TriggerConfig = { requiredScopes: [], required: true, mode: 'trigger', - supportsCredentialSets: true, }, { id: 'folderIds', @@ -56,10 +43,6 @@ export const outlookPollingTrigger: TriggerConfig = { if (!credentialId) { throw new Error('No Outlook credential selected') } - // Return default system folders for credential sets (can't fetch user-specific folders for a pool) - if (isCredentialSetValue(credentialId)) { - return OUTLOOK_SYSTEM_FOLDERS - } try { const data = await requestJson(outlookFoldersSelectorContract, { query: { credentialId }, diff --git a/helm/sim/values.yaml b/helm/sim/values.yaml index 91ae16747fe..f089cbb638a 100644 --- a/helm/sim/values.yaml +++ b/helm/sim/values.yaml @@ -222,8 +222,6 @@ app: SSO_TRUSTED_PROVIDER_IDS: "" # Enterprise Feature Overrides (self-hosted) - CREDENTIAL_SETS_ENABLED: "" # Enable credential sets (email polling) on self-hosted ("true" to enable) - NEXT_PUBLIC_CREDENTIAL_SETS_ENABLED: "" # Show credential sets settings page ("true" to enable) INBOX_ENABLED: "" # Enable Sim Mailer on self-hosted ("true" to enable) NEXT_PUBLIC_INBOX_ENABLED: "" # Show Sim Mailer settings page ("true" to enable) WHITELABELING_ENABLED: "" # Enable whitelabeling on self-hosted ("true" to enable) diff --git a/packages/audit/src/types.ts b/packages/audit/src/types.ts index 0d5bbe02388..3ad3ad9dc9a 100644 --- a/packages/audit/src/types.ts +++ b/packages/audit/src/types.ts @@ -34,17 +34,6 @@ export const AuditAction = { // Billing CREDIT_PURCHASED: 'credit.purchased', - // Credential Sets - CREDENTIAL_SET_CREATED: 'credential_set.created', - CREDENTIAL_SET_UPDATED: 'credential_set.updated', - CREDENTIAL_SET_DELETED: 'credential_set.deleted', - CREDENTIAL_SET_MEMBER_REMOVED: 'credential_set_member.removed', - CREDENTIAL_SET_MEMBER_LEFT: 'credential_set_member.left', - CREDENTIAL_SET_INVITATION_CREATED: 'credential_set_invitation.created', - CREDENTIAL_SET_INVITATION_ACCEPTED: 'credential_set_invitation.accepted', - CREDENTIAL_SET_INVITATION_RESENT: 'credential_set_invitation.resent', - CREDENTIAL_SET_INVITATION_REVOKED: 'credential_set_invitation.revoked', - // Connector Documents CONNECTOR_DOCUMENT_RESTORED: 'connector_document.restored', CONNECTOR_DOCUMENT_EXCLUDED: 'connector_document.excluded', @@ -199,7 +188,6 @@ export const AuditResourceType = { CHAT: 'chat', CONNECTOR: 'connector', CREDENTIAL: 'credential', - CREDENTIAL_SET: 'credential_set', CUSTOM_TOOL: 'custom_tool', DATA_DRAIN: 'data_drain', DOCUMENT: 'document', diff --git a/packages/db/migrations/0255_remove_credential_sets.sql b/packages/db/migrations/0255_remove_credential_sets.sql new file mode 100644 index 00000000000..23dce3250df --- /dev/null +++ b/packages/db/migrations/0255_remove_credential_sets.sql @@ -0,0 +1,12 @@ +-- migration-safe: credential-sets (email polling groups) feature fully removed in this change; prod verified 2026-07-06 at 0 invitations, 0 members, 0 webhooks with credential_set_id, 0 workflow-config references +DROP TABLE "credential_set_invitation" CASCADE;--> statement-breakpoint +-- migration-safe: credential_set_member readers removed with the credential-sets API/fan-out in this change; prod has 0 rows +DROP TABLE "credential_set_member" CASCADE;--> statement-breakpoint +-- migration-safe: credential_set readers removed in this change; CASCADE also drops the webhook.credential_set_id FK constraint; prod has 1 orphaned row with no members, invitations, or webhooks +DROP TABLE "credential_set" CASCADE;--> statement-breakpoint +-- migration-safe: webhook.credential_set_id readers/writers (deploy fan-out, polling plan gate, processor billing gate, webhook routes) all removed in this change; prod verified at 0 non-null values; drops webhook_credential_set_id_idx with it +ALTER TABLE "webhook" DROP COLUMN "credential_set_id";--> statement-breakpoint +-- migration-safe: credential_set_invitation_status enum is only referenced by the credential_set_invitation table dropped above +DROP TYPE "public"."credential_set_invitation_status";--> statement-breakpoint +-- migration-safe: credential_set_member_status enum is only referenced by the credential_set_member table dropped above +DROP TYPE "public"."credential_set_member_status"; diff --git a/packages/db/migrations/meta/0255_snapshot.json b/packages/db/migrations/meta/0255_snapshot.json new file mode 100644 index 00000000000..11e2a922f43 --- /dev/null +++ b/packages/db/migrations/meta/0255_snapshot.json @@ -0,0 +1,16681 @@ +{ + "id": "89ca1946-db4f-4726-b15f-b7869d9bcf41", + "prevId": "1439d1e8-9a5e-4002-a68a-a9bbdd22290f", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.academy_certificate": { + "name": "academy_certificate", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "course_id": { + "name": "course_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "academy_cert_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "issued_at": { + "name": "issued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "certificate_number": { + "name": "certificate_number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "academy_certificate_user_id_idx": { + "name": "academy_certificate_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_course_id_idx": { + "name": "academy_certificate_course_id_idx", + "columns": [ + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_user_course_unique": { + "name": "academy_certificate_user_course_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_number_idx": { + "name": "academy_certificate_number_idx", + "columns": [ + { + "expression": "certificate_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_status_idx": { + "name": "academy_certificate_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "academy_certificate_user_id_user_id_fk": { + "name": "academy_certificate_user_id_user_id_fk", + "tableFrom": "academy_certificate", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "academy_certificate_certificate_number_unique": { + "name": "academy_certificate_certificate_number_unique", + "nullsNotDistinct": false, + "columns": ["certificate_number"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_user_id_idx": { + "name": "account_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_account_on_account_id_provider_id": { + "name": "idx_account_on_account_id_provider_id", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_key": { + "name": "api_key", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'personal'" + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "api_key_workspace_type_idx": { + "name": "api_key_workspace_type_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_key_user_type_idx": { + "name": "api_key_user_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_key_key_hash_idx": { + "name": "api_key_key_hash_idx", + "columns": [ + { + "expression": "key_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "api_key_user_id_user_id_fk": { + "name": "api_key_user_id_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_workspace_id_workspace_id_fk": { + "name": "api_key_workspace_id_workspace_id_fk", + "tableFrom": "api_key", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_created_by_user_id_fk": { + "name": "api_key_created_by_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_key_key_unique": { + "name": "api_key_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": { + "workspace_type_check": { + "name": "workspace_type_check", + "value": "(type = 'workspace' AND workspace_id IS NOT NULL) OR (type = 'personal' AND workspace_id IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.async_jobs": { + "name": "async_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "run_at": { + "name": "run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "output": { + "name": "output", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "async_jobs_status_started_at_idx": { + "name": "async_jobs_status_started_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_status_completed_at_idx": { + "name": "async_jobs_status_completed_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "completed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_schedule_pending_run_at_idx": { + "name": "async_jobs_schedule_pending_run_at_idx", + "columns": [ + { + "expression": "run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_schedule_processing_started_at_idx": { + "name": "async_jobs_schedule_processing_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" = 'processing'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_name": { + "name": "actor_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_email": { + "name": "actor_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource_name": { + "name": "resource_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_log_workspace_created_idx": { + "name": "audit_log_workspace_created_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_workspace_created_at_id_idx": { + "name": "audit_log_workspace_created_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"created_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_actor_created_idx": { + "name": "audit_log_actor_created_idx", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_resource_idx": { + "name": "audit_log_resource_idx", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_action_idx": { + "name": "audit_log_action_idx", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_log_workspace_id_workspace_id_fk": { + "name": "audit_log_workspace_id_workspace_id_fk", + "tableFrom": "audit_log", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "audit_log_actor_id_user_id_fk": { + "name": "audit_log_actor_id_user_id_fk", + "tableFrom": "audit_log", + "tableTo": "user", + "columnsFrom": ["actor_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.background_work_status": { + "name": "background_work_status", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "background_work_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "background_work_status_value", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "background_work_status_workspace_status_idx": { + "name": "background_work_status_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_workflow_status_idx": { + "name": "background_work_status_workflow_status_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "background_work_status_workspace_id_workspace_id_fk": { + "name": "background_work_status_workspace_id_workspace_id_fk", + "tableFrom": "background_work_status", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "background_work_status_workflow_id_workflow_id_fk": { + "name": "background_work_status_workflow_id_workflow_id_fk", + "tableFrom": "background_work_status", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat": { + "name": "chat", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "customizations": { + "name": "customizations", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_emails": { + "name": "allowed_emails", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "output_configs": { + "name": "output_configs", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "identifier_idx": { + "name": "identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"chat\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_archived_at_partial_idx": { + "name": "chat_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"chat\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_chat_on_workflow_id_archived_at": { + "name": "idx_chat_on_workflow_id_archived_at", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_workflow_id_workflow_id_fk": { + "name": "chat_workflow_id_workflow_id_fk", + "tableFrom": "chat", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_user_id_user_id_fk": { + "name": "chat_user_id_user_id_fk", + "tableFrom": "chat", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_async_tool_calls": { + "name": "copilot_async_tool_calls", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "args": { + "name": "args", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "status": { + "name": "status", + "type": "copilot_async_tool_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "claimed_by": { + "name": "claimed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_async_tool_calls_run_id_idx": { + "name": "copilot_async_tool_calls_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_checkpoint_id_idx": { + "name": "copilot_async_tool_calls_checkpoint_id_idx", + "columns": [ + { + "expression": "checkpoint_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_tool_call_id_idx": { + "name": "copilot_async_tool_calls_tool_call_id_idx", + "columns": [ + { + "expression": "tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_status_idx": { + "name": "copilot_async_tool_calls_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_run_status_idx": { + "name": "copilot_async_tool_calls_run_status_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_tool_call_id_unique": { + "name": "copilot_async_tool_calls_tool_call_id_unique", + "columns": [ + { + "expression": "tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_async_tool_calls_run_id_copilot_runs_id_fk": { + "name": "copilot_async_tool_calls_run_id_copilot_runs_id_fk", + "tableFrom": "copilot_async_tool_calls", + "tableTo": "copilot_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_async_tool_calls_checkpoint_id_copilot_run_checkpoints_id_fk": { + "name": "copilot_async_tool_calls_checkpoint_id_copilot_run_checkpoints_id_fk", + "tableFrom": "copilot_async_tool_calls", + "tableTo": "copilot_run_checkpoints", + "columnsFrom": ["checkpoint_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_chats": { + "name": "copilot_chats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "chat_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'copilot'" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'claude-3-7-sonnet-latest'" + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "preview_yaml": { + "name": "preview_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plan_artifact": { + "name": "plan_artifact", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "resources": { + "name": "resources", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "pinned": { + "name": "pinned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_chats_user_id_idx": { + "name": "copilot_chats_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_workflow_id_idx": { + "name": "copilot_chats_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workflow_idx": { + "name": "copilot_chats_user_workflow_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workspace_idx": { + "name": "copilot_chats_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_created_at_idx": { + "name": "copilot_chats_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_updated_at_idx": { + "name": "copilot_chats_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_workspace_created_at_id_idx": { + "name": "copilot_chats_workspace_created_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"created_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_chats_user_id_user_id_fk": { + "name": "copilot_chats_user_id_user_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_workflow_id_workflow_id_fk": { + "name": "copilot_chats_workflow_id_workflow_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_workspace_id_workspace_id_fk": { + "name": "copilot_chats_workspace_id_workspace_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_feedback": { + "name": "copilot_feedback", + "schema": "", + "columns": { + "feedback_id": { + "name": "feedback_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_query": { + "name": "user_query", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_response": { + "name": "agent_response", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_positive": { + "name": "is_positive", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "feedback": { + "name": "feedback", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_yaml": { + "name": "workflow_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_feedback_user_id_idx": { + "name": "copilot_feedback_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_chat_id_idx": { + "name": "copilot_feedback_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_user_chat_idx": { + "name": "copilot_feedback_user_chat_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_is_positive_idx": { + "name": "copilot_feedback_is_positive_idx", + "columns": [ + { + "expression": "is_positive", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_created_at_idx": { + "name": "copilot_feedback_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_feedback_user_id_user_id_fk": { + "name": "copilot_feedback_user_id_user_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_feedback_chat_id_copilot_chats_id_fk": { + "name": "copilot_feedback_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_messages": { + "name": "copilot_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parent_message_id": { + "name": "parent_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokens_in": { + "name": "tokens_in", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tokens_out": { + "name": "tokens_out", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_messages_chat_message_unique": { + "name": "copilot_messages_chat_message_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_created_at_idx": { + "name": "copilot_messages_chat_created_at_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_seq_idx": { + "name": "copilot_messages_chat_seq_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_stream_idx": { + "name": "copilot_messages_chat_stream_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"stream_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_messages_chat_id_copilot_chats_id_fk": { + "name": "copilot_messages_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_messages", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_run_checkpoints": { + "name": "copilot_run_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "pending_tool_call_id": { + "name": "pending_tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_snapshot": { + "name": "conversation_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "agent_state": { + "name": "agent_state", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "provider_request": { + "name": "provider_request", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_run_checkpoints_run_id_idx": { + "name": "copilot_run_checkpoints_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_run_checkpoints_pending_tool_call_id_idx": { + "name": "copilot_run_checkpoints_pending_tool_call_id_idx", + "columns": [ + { + "expression": "pending_tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_run_checkpoints_run_pending_tool_unique": { + "name": "copilot_run_checkpoints_run_pending_tool_unique", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pending_tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_run_checkpoints_run_id_copilot_runs_id_fk": { + "name": "copilot_run_checkpoints_run_id_copilot_runs_id_fk", + "tableFrom": "copilot_run_checkpoints", + "tableTo": "copilot_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_runs": { + "name": "copilot_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_run_id": { + "name": "parent_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent": { + "name": "agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "copilot_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "request_context": { + "name": "request_context", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "copilot_runs_execution_id_idx": { + "name": "copilot_runs_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_parent_run_id_idx": { + "name": "copilot_runs_parent_run_id_idx", + "columns": [ + { + "expression": "parent_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_chat_id_idx": { + "name": "copilot_runs_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_user_id_idx": { + "name": "copilot_runs_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workflow_id_idx": { + "name": "copilot_runs_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workspace_id_idx": { + "name": "copilot_runs_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_status_idx": { + "name": "copilot_runs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_chat_execution_idx": { + "name": "copilot_runs_chat_execution_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_execution_started_at_idx": { + "name": "copilot_runs_execution_started_at_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workspace_completed_at_id_idx": { + "name": "copilot_runs_workspace_completed_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"completed_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_stream_id_unique": { + "name": "copilot_runs_stream_id_unique", + "columns": [ + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_runs_chat_id_copilot_chats_id_fk": { + "name": "copilot_runs_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_user_id_user_id_fk": { + "name": "copilot_runs_user_id_user_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_workflow_id_workflow_id_fk": { + "name": "copilot_runs_workflow_id_workflow_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_workspace_id_workspace_id_fk": { + "name": "copilot_runs_workspace_id_workspace_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_workflow_read_hashes": { + "name": "copilot_workflow_read_hashes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_workflow_read_hashes_chat_id_idx": { + "name": "copilot_workflow_read_hashes_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_workflow_read_hashes_workflow_id_idx": { + "name": "copilot_workflow_read_hashes_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_workflow_read_hashes_chat_workflow_unique": { + "name": "copilot_workflow_read_hashes_chat_workflow_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_workflow_read_hashes_chat_id_copilot_chats_id_fk": { + "name": "copilot_workflow_read_hashes_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_workflow_read_hashes", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_workflow_read_hashes_workflow_id_workflow_id_fk": { + "name": "copilot_workflow_read_hashes_workflow_id_workflow_id_fk", + "tableFrom": "copilot_workflow_read_hashes", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential": { + "name": "credential", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "credential_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env_key": { + "name": "env_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env_owner_user_id": { + "name": "env_owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_service_account_key": { + "name": "encrypted_service_account_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_workspace_id_idx": { + "name": "credential_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_type_idx": { + "name": "credential_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_provider_id_idx": { + "name": "credential_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_account_id_idx": { + "name": "credential_account_id_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_env_owner_user_id_idx": { + "name": "credential_env_owner_user_id_idx", + "columns": [ + { + "expression": "env_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_account_unique": { + "name": "credential_workspace_account_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "account_id IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_env_unique": { + "name": "credential_workspace_env_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "type = 'env_workspace'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_personal_env_unique": { + "name": "credential_workspace_personal_env_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "type = 'env_personal'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_workspace_id_workspace_id_fk": { + "name": "credential_workspace_id_workspace_id_fk", + "tableFrom": "credential", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_account_id_account_id_fk": { + "name": "credential_account_id_account_id_fk", + "tableFrom": "credential", + "tableTo": "account", + "columnsFrom": ["account_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_env_owner_user_id_user_id_fk": { + "name": "credential_env_owner_user_id_user_id_fk", + "tableFrom": "credential", + "tableTo": "user", + "columnsFrom": ["env_owner_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_created_by_user_id_fk": { + "name": "credential_created_by_user_id_fk", + "tableFrom": "credential", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "credential_oauth_source_check": { + "name": "credential_oauth_source_check", + "value": "(type <> 'oauth') OR (account_id IS NOT NULL AND provider_id IS NOT NULL)" + }, + "credential_workspace_env_source_check": { + "name": "credential_workspace_env_source_check", + "value": "(type <> 'env_workspace') OR (env_key IS NOT NULL AND env_owner_user_id IS NULL)" + }, + "credential_personal_env_source_check": { + "name": "credential_personal_env_source_check", + "value": "(type <> 'env_personal') OR (env_key IS NOT NULL AND env_owner_user_id IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.credential_member": { + "name": "credential_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "credential_member_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "status": { + "name": "status", + "type": "credential_member_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_member_user_id_idx": { + "name": "credential_member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_role_idx": { + "name": "credential_member_role_idx", + "columns": [ + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_status_idx": { + "name": "credential_member_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_unique": { + "name": "credential_member_unique", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_member_credential_id_credential_id_fk": { + "name": "credential_member_credential_id_credential_id_fk", + "tableFrom": "credential_member", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_member_user_id_user_id_fk": { + "name": "credential_member_user_id_user_id_fk", + "tableFrom": "credential_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_member_invited_by_user_id_fk": { + "name": "credential_member_invited_by_user_id_fk", + "tableFrom": "credential_member", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_block": { + "name": "custom_block", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "outputs": { + "name": "outputs", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "custom_block_organization_id_idx": { + "name": "custom_block_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_block_workflow_id_idx": { + "name": "custom_block_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_block_organization_type_unique": { + "name": "custom_block_organization_type_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_block_organization_id_organization_id_fk": { + "name": "custom_block_organization_id_organization_id_fk", + "tableFrom": "custom_block", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_block_workflow_id_workflow_id_fk": { + "name": "custom_block_workflow_id_workflow_id_fk", + "tableFrom": "custom_block", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_block_created_by_user_id_fk": { + "name": "custom_block_created_by_user_id_fk", + "tableFrom": "custom_block", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_tools": { + "name": "custom_tools", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schema": { + "name": "schema", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "custom_tools_workspace_id_idx": { + "name": "custom_tools_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_tools_workspace_title_unique": { + "name": "custom_tools_workspace_title_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_tools_workspace_id_workspace_id_fk": { + "name": "custom_tools_workspace_id_workspace_id_fk", + "tableFrom": "custom_tools", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_tools_user_id_user_id_fk": { + "name": "custom_tools_user_id_user_id_fk", + "tableFrom": "custom_tools", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.data_drain_runs": { + "name": "data_drain_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "drain_id": { + "name": "drain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "data_drain_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "trigger": { + "name": "trigger", + "type": "data_drain_run_trigger", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "rows_exported": { + "name": "rows_exported", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "bytes_written": { + "name": "bytes_written", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cursor_before": { + "name": "cursor_before", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cursor_after": { + "name": "cursor_after", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locators": { + "name": "locators", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + } + }, + "indexes": { + "data_drain_runs_drain_started_idx": { + "name": "data_drain_runs_drain_started_idx", + "columns": [ + { + "expression": "drain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "data_drain_runs_drain_id_data_drains_id_fk": { + "name": "data_drain_runs_drain_id_data_drains_id_fk", + "tableFrom": "data_drain_runs", + "tableTo": "data_drains", + "columnsFrom": ["drain_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.data_drains": { + "name": "data_drains", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "data_drain_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "destination_type": { + "name": "destination_type", + "type": "data_drain_destination", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "destination_config": { + "name": "destination_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "destination_credentials": { + "name": "destination_credentials", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schedule_cadence": { + "name": "schedule_cadence", + "type": "data_drain_cadence", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "cursor": { + "name": "cursor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_success_at": { + "name": "last_success_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "data_drains_org_idx": { + "name": "data_drains_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "data_drains_due_idx": { + "name": "data_drains_due_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "data_drains_org_name_unique": { + "name": "data_drains_org_name_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "data_drains_organization_id_organization_id_fk": { + "name": "data_drains_organization_id_organization_id_fk", + "tableFrom": "data_drains", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "data_drains_created_by_user_id_fk": { + "name": "data_drains_created_by_user_id_fk", + "tableFrom": "data_drains", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.docs_embeddings": { + "name": "docs_embeddings", + "schema": "", + "columns": { + "chunk_id": { + "name": "chunk_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chunk_text": { + "name": "chunk_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_document": { + "name": "source_document", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_link": { + "name": "source_link", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_text": { + "name": "header_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_level": { + "name": "header_level", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": true + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "chunk_text_tsv": { + "name": "chunk_text_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"docs_embeddings\".\"chunk_text\")", + "type": "stored" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "docs_emb_source_document_idx": { + "name": "docs_emb_source_document_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_header_level_idx": { + "name": "docs_emb_header_level_idx", + "columns": [ + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_source_header_idx": { + "name": "docs_emb_source_header_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_model_idx": { + "name": "docs_emb_model_idx", + "columns": [ + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_created_at_idx": { + "name": "docs_emb_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_embedding_vector_hnsw_idx": { + "name": "docs_embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "docs_emb_metadata_gin_idx": { + "name": "docs_emb_metadata_gin_idx", + "columns": [ + { + "expression": "metadata", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "docs_emb_chunk_text_fts_idx": { + "name": "docs_emb_chunk_text_fts_idx", + "columns": [ + { + "expression": "chunk_text_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "docs_embedding_not_null_check": { + "name": "docs_embedding_not_null_check", + "value": "\"embedding\" IS NOT NULL" + }, + "docs_header_level_check": { + "name": "docs_header_level_check", + "value": "\"header_level\" >= 1 AND \"header_level\" <= 6" + } + }, + "isRLSEnabled": false + }, + "public.document": { + "name": "document", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_url": { + "name": "file_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_size": { + "name": "file_size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_count": { + "name": "chunk_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "character_count": { + "name": "character_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "processing_status": { + "name": "processing_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_completed_at": { + "name": "processing_completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_error": { + "name": "processing_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "user_excluded": { + "name": "user_excluded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "tag1": { + "name": "tag1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag2": { + "name": "tag2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag3": { + "name": "tag3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag4": { + "name": "tag4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag5": { + "name": "tag5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag6": { + "name": "tag6", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag7": { + "name": "tag7", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "number1": { + "name": "number1", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number2": { + "name": "number2", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number3": { + "name": "number3", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number4": { + "name": "number4", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number5": { + "name": "number5", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "date1": { + "name": "date1", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "date2": { + "name": "date2", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "boolean1": { + "name": "boolean1", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean2": { + "name": "boolean2", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean3": { + "name": "boolean3", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "doc_kb_id_idx": { + "name": "doc_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_filename_idx": { + "name": "doc_filename_idx", + "columns": [ + { + "expression": "filename", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_processing_status_idx": { + "name": "doc_processing_status_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "processing_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_external_id_idx": { + "name": "doc_connector_external_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"document\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_id_idx": { + "name": "doc_connector_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_storage_key_idx": { + "name": "doc_storage_key_idx", + "columns": [ + { + "expression": "storage_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"storage_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_archived_at_partial_idx": { + "name": "doc_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_deleted_at_partial_idx": { + "name": "doc_deleted_at_partial_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag1_idx": { + "name": "doc_tag1_idx", + "columns": [ + { + "expression": "tag1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag2_idx": { + "name": "doc_tag2_idx", + "columns": [ + { + "expression": "tag2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag3_idx": { + "name": "doc_tag3_idx", + "columns": [ + { + "expression": "tag3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag4_idx": { + "name": "doc_tag4_idx", + "columns": [ + { + "expression": "tag4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag5_idx": { + "name": "doc_tag5_idx", + "columns": [ + { + "expression": "tag5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag6_idx": { + "name": "doc_tag6_idx", + "columns": [ + { + "expression": "tag6", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag7_idx": { + "name": "doc_tag7_idx", + "columns": [ + { + "expression": "tag7", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number1_idx": { + "name": "doc_number1_idx", + "columns": [ + { + "expression": "number1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number2_idx": { + "name": "doc_number2_idx", + "columns": [ + { + "expression": "number2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number3_idx": { + "name": "doc_number3_idx", + "columns": [ + { + "expression": "number3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number4_idx": { + "name": "doc_number4_idx", + "columns": [ + { + "expression": "number4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number5_idx": { + "name": "doc_number5_idx", + "columns": [ + { + "expression": "number5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_date1_idx": { + "name": "doc_date1_idx", + "columns": [ + { + "expression": "date1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_date2_idx": { + "name": "doc_date2_idx", + "columns": [ + { + "expression": "date2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean1_idx": { + "name": "doc_boolean1_idx", + "columns": [ + { + "expression": "boolean1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean2_idx": { + "name": "doc_boolean2_idx", + "columns": [ + { + "expression": "boolean2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean3_idx": { + "name": "doc_boolean3_idx", + "columns": [ + { + "expression": "boolean3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "document_knowledge_base_id_knowledge_base_id_fk": { + "name": "document_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "document", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "document_connector_id_knowledge_connector_id_fk": { + "name": "document_connector_id_knowledge_connector_id_fk", + "tableFrom": "document", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "document_uploaded_by_user_id_fk": { + "name": "document_uploaded_by_user_id_fk", + "tableFrom": "document", + "tableTo": "user", + "columnsFrom": ["uploaded_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.embedding": { + "name": "embedding", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_index": { + "name": "chunk_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "chunk_hash": { + "name": "chunk_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_length": { + "name": "content_length", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "start_offset": { + "name": "start_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "end_offset": { + "name": "end_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "tag1": { + "name": "tag1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag2": { + "name": "tag2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag3": { + "name": "tag3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag4": { + "name": "tag4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag5": { + "name": "tag5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag6": { + "name": "tag6", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag7": { + "name": "tag7", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "number1": { + "name": "number1", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number2": { + "name": "number2", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number3": { + "name": "number3", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number4": { + "name": "number4", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number5": { + "name": "number5", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "date1": { + "name": "date1", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "date2": { + "name": "date2", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "boolean1": { + "name": "boolean1", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean2": { + "name": "boolean2", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean3": { + "name": "boolean3", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "content_tsv": { + "name": "content_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"embedding\".\"content\")", + "type": "stored" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "emb_kb_id_idx": { + "name": "emb_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_id_idx": { + "name": "emb_doc_id_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_chunk_idx": { + "name": "emb_doc_chunk_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chunk_index", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_model_idx": { + "name": "emb_kb_model_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_enabled_idx": { + "name": "emb_kb_enabled_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_enabled_idx": { + "name": "emb_doc_enabled_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "embedding_vector_hnsw_idx": { + "name": "embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "emb_tag1_idx": { + "name": "emb_tag1_idx", + "columns": [ + { + "expression": "tag1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag2_idx": { + "name": "emb_tag2_idx", + "columns": [ + { + "expression": "tag2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag3_idx": { + "name": "emb_tag3_idx", + "columns": [ + { + "expression": "tag3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag4_idx": { + "name": "emb_tag4_idx", + "columns": [ + { + "expression": "tag4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag5_idx": { + "name": "emb_tag5_idx", + "columns": [ + { + "expression": "tag5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag6_idx": { + "name": "emb_tag6_idx", + "columns": [ + { + "expression": "tag6", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag7_idx": { + "name": "emb_tag7_idx", + "columns": [ + { + "expression": "tag7", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number1_idx": { + "name": "emb_number1_idx", + "columns": [ + { + "expression": "number1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number2_idx": { + "name": "emb_number2_idx", + "columns": [ + { + "expression": "number2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number3_idx": { + "name": "emb_number3_idx", + "columns": [ + { + "expression": "number3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number4_idx": { + "name": "emb_number4_idx", + "columns": [ + { + "expression": "number4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number5_idx": { + "name": "emb_number5_idx", + "columns": [ + { + "expression": "number5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_date1_idx": { + "name": "emb_date1_idx", + "columns": [ + { + "expression": "date1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_date2_idx": { + "name": "emb_date2_idx", + "columns": [ + { + "expression": "date2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean1_idx": { + "name": "emb_boolean1_idx", + "columns": [ + { + "expression": "boolean1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean2_idx": { + "name": "emb_boolean2_idx", + "columns": [ + { + "expression": "boolean2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean3_idx": { + "name": "emb_boolean3_idx", + "columns": [ + { + "expression": "boolean3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_content_fts_idx": { + "name": "emb_content_fts_idx", + "columns": [ + { + "expression": "content_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "embedding_knowledge_base_id_knowledge_base_id_fk": { + "name": "embedding_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "embedding", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "embedding_document_id_document_id_fk": { + "name": "embedding_document_id_document_id_fk", + "tableFrom": "embedding", + "tableTo": "document", + "columnsFrom": ["document_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "embedding_not_null_check": { + "name": "embedding_not_null_check", + "value": "\"embedding\" IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.environment": { + "name": "environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "environment_user_id_user_id_fk": { + "name": "environment_user_id_user_id_fk", + "tableFrom": "environment", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "environment_user_id_unique": { + "name": "environment_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_value_dependencies": { + "name": "execution_large_value_dependencies", + "schema": "", + "columns": { + "parent_key": { + "name": "parent_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_key": { + "name": "child_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "execution_large_value_dependencies_workspace_parent_key_idx": { + "name": "execution_large_value_dependencies_workspace_parent_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_value_dependencies_workspace_child_key_idx": { + "name": "execution_large_value_dependencies_workspace_child_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_value_dependencies_workspace_id_workspace_id_fk": { + "name": "execution_large_value_dependencies_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_value_dependencies", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "execution_large_value_dependencies_parent_key_child_key_pk": { + "name": "execution_large_value_dependencies_parent_key_child_key_pk", + "columns": ["parent_key", "child_key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_value_references": { + "name": "execution_large_value_references", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "execution_large_value_reference_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "execution_large_value_references_workspace_execution_source_idx": { + "name": "execution_large_value_references_workspace_execution_source_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_value_references_workspace_id_workspace_id_fk": { + "name": "execution_large_value_references_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_value_references", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "execution_large_value_references_workflow_id_workflow_id_fk": { + "name": "execution_large_value_references_workflow_id_workflow_id_fk", + "tableFrom": "execution_large_value_references", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "execution_large_value_references_key_execution_id_source_pk": { + "name": "execution_large_value_references_key_execution_id_source_pk", + "columns": ["key", "execution_id", "source"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_values": { + "name": "execution_large_values", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_execution_id": { + "name": "owner_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "execution_large_values_owner_execution_id_idx": { + "name": "execution_large_values_owner_execution_id_idx", + "columns": [ + { + "expression": "owner_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_values_cleanup_idx": { + "name": "execution_large_values_cleanup_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"execution_large_values\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_values_tombstone_cleanup_idx": { + "name": "execution_large_values_tombstone_cleanup_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"execution_large_values\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_values_workspace_id_workspace_id_fk": { + "name": "execution_large_values_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_values", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "execution_large_values_workflow_id_workflow_id_fk": { + "name": "execution_large_values_workflow_id_workflow_id_fk", + "tableFrom": "execution_large_values", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.idempotency_key": { + "name": "idempotency_key", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "result": { + "name": "result", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idempotency_key_created_at_idx": { + "name": "idempotency_key_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation": { + "name": "invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "invitation_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'organization'" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "membership_intent": { + "name": "membership_intent", + "type": "invitation_membership_intent", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'internal'" + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "invitation_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invitation_email_idx": { + "name": "invitation_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_organization_id_idx": { + "name": "invitation_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_status_idx": { + "name": "invitation_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_pending_email_org_unique": { + "name": "invitation_pending_email_org_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"invitation\".\"status\" = 'pending' AND \"invitation\".\"organization_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": ["inviter_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "invitation_token_unique": { + "name": "invitation_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation_workspace_grant": { + "name": "invitation_workspace_grant", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "invitation_id": { + "name": "invitation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission": { + "name": "permission", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invitation_workspace_grant_unique": { + "name": "invitation_workspace_grant_unique", + "columns": [ + { + "expression": "invitation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_workspace_grant_workspace_id_idx": { + "name": "invitation_workspace_grant_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_workspace_grant_invitation_id_invitation_id_fk": { + "name": "invitation_workspace_grant_invitation_id_invitation_id_fk", + "tableFrom": "invitation_workspace_grant", + "tableTo": "invitation", + "columnsFrom": ["invitation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_workspace_grant_workspace_id_workspace_id_fk": { + "name": "invitation_workspace_grant_workspace_id_workspace_id_fk", + "tableFrom": "invitation_workspace_grant", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.job_execution_logs": { + "name": "job_execution_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "schedule_id": { + "name": "schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "execution_data": { + "name": "execution_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "cost": { + "name": "cost", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "job_execution_logs_schedule_id_idx": { + "name": "job_execution_logs_schedule_id_idx", + "columns": [ + { + "expression": "schedule_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_workspace_started_at_idx": { + "name": "job_execution_logs_workspace_started_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_workspace_ended_at_id_idx": { + "name": "job_execution_logs_workspace_ended_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"ended_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_execution_id_unique": { + "name": "job_execution_logs_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_trigger_idx": { + "name": "job_execution_logs_trigger_idx", + "columns": [ + { + "expression": "trigger", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "job_execution_logs_schedule_id_workflow_schedule_id_fk": { + "name": "job_execution_logs_schedule_id_workflow_schedule_id_fk", + "tableFrom": "job_execution_logs", + "tableTo": "workflow_schedule", + "columnsFrom": ["schedule_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "job_execution_logs_workspace_id_workspace_id_fk": { + "name": "job_execution_logs_workspace_id_workspace_id_fk", + "tableFrom": "job_execution_logs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_base": { + "name": "knowledge_base", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "embedding_dimension": { + "name": "embedding_dimension", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1536 + }, + "chunking_config": { + "name": "chunking_config", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{\"maxSize\": 1024, \"minSize\": 1, \"overlap\": 200}'" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kb_user_id_idx": { + "name": "kb_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_id_idx": { + "name": "kb_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_user_workspace_idx": { + "name": "kb_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_deleted_at_idx": { + "name": "kb_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_deleted_partial_idx": { + "name": "kb_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_base\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_name_active_unique": { + "name": "kb_workspace_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"knowledge_base\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_base_user_id_user_id_fk": { + "name": "knowledge_base_user_id_user_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_base_workspace_id_workspace_id_fk": { + "name": "knowledge_base_workspace_id_workspace_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_base_tag_definitions": { + "name": "knowledge_base_tag_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tag_slot": { + "name": "tag_slot", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "field_type": { + "name": "field_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kb_tag_definitions_kb_slot_idx": { + "name": "kb_tag_definitions_kb_slot_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tag_slot", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_tag_definitions_kb_display_name_idx": { + "name": "kb_tag_definitions_kb_display_name_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_tag_definitions_kb_id_idx": { + "name": "kb_tag_definitions_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk": { + "name": "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "knowledge_base_tag_definitions", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_connector": { + "name": "knowledge_connector", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connector_type": { + "name": "connector_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_config": { + "name": "source_config", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "sync_mode": { + "name": "sync_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'full'" + }, + "sync_interval_minutes": { + "name": "sync_interval_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1440 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_sync_error": { + "name": "last_sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_sync_doc_count": { + "name": "last_sync_doc_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "next_sync_at": { + "name": "next_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "consecutive_failures": { + "name": "consecutive_failures", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kc_knowledge_base_id_idx": { + "name": "kc_knowledge_base_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_status_next_sync_idx": { + "name": "kc_status_next_sync_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_sync_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_archived_at_partial_idx": { + "name": "kc_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_deleted_at_partial_idx": { + "name": "kc_deleted_at_partial_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_knowledge_base_id_knowledge_base_id_fk": { + "name": "knowledge_connector_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "knowledge_connector", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_connector_sync_log": { + "name": "knowledge_connector_sync_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "docs_added": { + "name": "docs_added", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_updated": { + "name": "docs_updated", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_deleted": { + "name": "docs_deleted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_unchanged": { + "name": "docs_unchanged", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_failed": { + "name": "docs_failed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kcsl_connector_id_idx": { + "name": "kcsl_connector_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_sync_log_connector_id_knowledge_connector_id_fk": { + "name": "knowledge_connector_sync_log_connector_id_knowledge_connector_id_fk", + "tableFrom": "knowledge_connector_sync_log", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_server_oauth": { + "name": "mcp_server_oauth", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mcp_server_id": { + "name": "mcp_server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_information": { + "name": "client_information", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokens": { + "name": "tokens", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code_verifier": { + "name": "code_verifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_created_at": { + "name": "state_created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_refreshed_at": { + "name": "last_refreshed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_server_oauth_server_unique": { + "name": "mcp_server_oauth_server_unique", + "columns": [ + { + "expression": "mcp_server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_server_oauth_state_idx": { + "name": "mcp_server_oauth_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_server_oauth_mcp_server_id_mcp_servers_id_fk": { + "name": "mcp_server_oauth_mcp_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "mcp_servers", + "columnsFrom": ["mcp_server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_server_oauth_user_id_user_id_fk": { + "name": "mcp_server_oauth_user_id_user_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "mcp_server_oauth_workspace_id_workspace_id_fk": { + "name": "mcp_server_oauth_workspace_id_workspace_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_servers": { + "name": "mcp_servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'headers'" + }, + "oauth_client_id": { + "name": "oauth_client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_client_secret": { + "name": "oauth_client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "headers": { + "name": "headers", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "timeout": { + "name": "timeout", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 30000 + }, + "retries": { + "name": "retries", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 3 + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_connected": { + "name": "last_connected", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "connection_status": { + "name": "connection_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'disconnected'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_config": { + "name": "status_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "tool_count": { + "name": "tool_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_tools_refresh": { + "name": "last_tools_refresh", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_requests": { + "name": "total_requests", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_servers_workspace_enabled_idx": { + "name": "mcp_servers_workspace_enabled_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_servers_workspace_deleted_partial_idx": { + "name": "mcp_servers_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"mcp_servers\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_servers_workspace_id_workspace_id_fk": { + "name": "mcp_servers_workspace_id_workspace_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_servers_created_by_user_id_fk": { + "name": "mcp_servers_created_by_user_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.member": { + "name": "member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "member_user_id_unique": { + "name": "member_user_id_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "member_organization_id_idx": { + "name": "member_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memory": { + "name": "memory", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "memory_key_idx": { + "name": "memory_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_idx": { + "name": "memory_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_key_idx": { + "name": "memory_workspace_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_deleted_partial_idx": { + "name": "memory_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"memory\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memory_workspace_id_workspace_id_fk": { + "name": "memory_workspace_id_workspace_id_fk", + "tableFrom": "memory", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_allowed_sender": { + "name": "mothership_inbox_allowed_sender", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "added_by": { + "name": "added_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "inbox_sender_ws_email_idx": { + "name": "inbox_sender_ws_email_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_inbox_allowed_sender_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_allowed_sender_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_allowed_sender", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mothership_inbox_allowed_sender_added_by_user_id_fk": { + "name": "mothership_inbox_allowed_sender_added_by_user_id_fk", + "tableFrom": "mothership_inbox_allowed_sender", + "tableTo": "user", + "columnsFrom": ["added_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_task": { + "name": "mothership_inbox_task", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_email": { + "name": "from_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_name": { + "name": "from_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body_preview": { + "name": "body_preview", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body_text": { + "name": "body_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body_html": { + "name": "body_html", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_message_id": { + "name": "email_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "in_reply_to": { + "name": "in_reply_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "response_message_id": { + "name": "response_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agentmail_message_id": { + "name": "agentmail_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'received'" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "trigger_job_id": { + "name": "trigger_job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result_summary": { + "name": "result_summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rejection_reason": { + "name": "rejection_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "has_attachments": { + "name": "has_attachments", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cc_recipients": { + "name": "cc_recipients", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "inbox_task_ws_created_at_idx": { + "name": "inbox_task_ws_created_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_ws_status_idx": { + "name": "inbox_task_ws_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_response_msg_id_idx": { + "name": "inbox_task_response_msg_id_idx", + "columns": [ + { + "expression": "response_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_email_msg_id_idx": { + "name": "inbox_task_email_msg_id_idx", + "columns": [ + { + "expression": "email_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_inbox_task_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_task_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_task", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mothership_inbox_task_chat_id_copilot_chats_id_fk": { + "name": "mothership_inbox_task_chat_id_copilot_chats_id_fk", + "tableFrom": "mothership_inbox_task", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_webhook": { + "name": "mothership_inbox_webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "webhook_id": { + "name": "webhook_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mothership_inbox_webhook_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_webhook_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_webhook", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mothership_inbox_webhook_workspace_id_unique": { + "name": "mothership_inbox_webhook_workspace_id_unique", + "nullsNotDistinct": false, + "columns": ["workspace_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_settings": { + "name": "mothership_settings", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mcp_tool_refs": { + "name": "mcp_tool_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "custom_tool_refs": { + "name": "custom_tool_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "skill_refs": { + "name": "skill_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mothership_settings_workspace_id_idx": { + "name": "mothership_settings_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_settings_workspace_id_workspace_id_fk": { + "name": "mothership_settings_workspace_id_workspace_id_fk", + "tableFrom": "mothership_settings", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization": { + "name": "organization", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "whitelabel_settings": { + "name": "whitelabel_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "data_retention_settings": { + "name": "data_retention_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "org_usage_limit": { + "name": "org_usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "limit_notifications": { + "name": "limit_notifications", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "departed_member_usage": { + "name": "departed_member_usage", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "credit_balance": { + "name": "credit_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_member_usage_limit": { + "name": "organization_member_usage_limit", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "usage_limit": { + "name": "usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "set_by": { + "name": "set_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "org_member_usage_limit_org_user_unique": { + "name": "org_member_usage_limit_org_user_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "org_member_usage_limit_organization_id_idx": { + "name": "org_member_usage_limit_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_member_usage_limit_organization_id_organization_id_fk": { + "name": "organization_member_usage_limit_organization_id_organization_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_member_usage_limit_user_id_user_id_fk": { + "name": "organization_member_usage_limit_user_id_user_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_member_usage_limit_set_by_user_id_fk": { + "name": "organization_member_usage_limit_set_by_user_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "user", + "columnsFrom": ["set_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.outbox_event": { + "name": "outbox_event", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "available_at": { + "name": "available_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "locked_at": { + "name": "locked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "outbox_event_status_available_idx": { + "name": "outbox_event_status_available_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "available_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "outbox_event_locked_at_idx": { + "name": "outbox_event_locked_at_idx", + "columns": [ + { + "expression": "locked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.paused_executions": { + "name": "paused_executions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_snapshot": { + "name": "execution_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "pause_points": { + "name": "pause_points", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "total_pause_count": { + "name": "total_pause_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "resumed_count": { + "name": "resumed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'paused'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "paused_at": { + "name": "paused_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "next_resume_at": { + "name": "next_resume_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "paused_executions_workflow_id_idx": { + "name": "paused_executions_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_status_idx": { + "name": "paused_executions_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_execution_id_unique": { + "name": "paused_executions_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_next_resume_at_idx": { + "name": "paused_executions_next_resume_at_idx", + "columns": [ + { + "expression": "next_resume_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'paused' AND next_resume_at IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "paused_executions_workflow_id_workflow_id_fk": { + "name": "paused_executions_workflow_id_workflow_id_fk", + "tableFrom": "paused_executions", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pending_credential_draft": { + "name": "pending_credential_draft", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pending_draft_user_provider_ws": { + "name": "pending_draft_user_provider_ws", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pending_credential_draft_user_id_user_id_fk": { + "name": "pending_credential_draft_user_id_user_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_credential_draft_workspace_id_workspace_id_fk": { + "name": "pending_credential_draft_workspace_id_workspace_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_credential_draft_credential_id_credential_id_fk": { + "name": "pending_credential_draft_credential_id_credential_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group": { + "name": "permission_group", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "permission_group_created_by_idx": { + "name": "permission_group_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_organization_name_unique": { + "name": "permission_group_organization_name_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_organization_default_unique": { + "name": "permission_group_organization_default_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "is_default = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_organization_id_organization_id_fk": { + "name": "permission_group_organization_id_organization_id_fk", + "tableFrom": "permission_group", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_created_by_user_id_fk": { + "name": "permission_group_created_by_user_id_fk", + "tableFrom": "permission_group", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group_member": { + "name": "permission_group_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "permission_group_id": { + "name": "permission_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assigned_by": { + "name": "assigned_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assigned_at": { + "name": "assigned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permission_group_member_group_id_idx": { + "name": "permission_group_member_group_id_idx", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_member_group_user_unique": { + "name": "permission_group_member_group_user_unique", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_member_organization_user_idx": { + "name": "permission_group_member_organization_user_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_member_permission_group_id_permission_group_id_fk": { + "name": "permission_group_member_permission_group_id_permission_group_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "permission_group", + "columnsFrom": ["permission_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_organization_id_organization_id_fk": { + "name": "permission_group_member_organization_id_organization_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_user_id_user_id_fk": { + "name": "permission_group_member_user_id_user_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_assigned_by_user_id_fk": { + "name": "permission_group_member_assigned_by_user_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "user", + "columnsFrom": ["assigned_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group_workspace": { + "name": "permission_group_workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "permission_group_id": { + "name": "permission_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permission_group_workspace_workspace_id_idx": { + "name": "permission_group_workspace_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_workspace_group_workspace_unique": { + "name": "permission_group_workspace_group_workspace_unique", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_workspace_permission_group_id_permission_group_id_fk": { + "name": "permission_group_workspace_permission_group_id_permission_group_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "permission_group", + "columnsFrom": ["permission_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_workspace_workspace_id_workspace_id_fk": { + "name": "permission_group_workspace_workspace_id_workspace_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_workspace_organization_id_organization_id_fk": { + "name": "permission_group_workspace_organization_id_organization_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permissions": { + "name": "permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_type": { + "name": "permission_type", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permissions_user_id_idx": { + "name": "permissions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_entity_idx": { + "name": "permissions_entity_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_type_idx": { + "name": "permissions_user_entity_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_permission_idx": { + "name": "permissions_user_entity_permission_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_idx": { + "name": "permissions_user_entity_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_unique_constraint": { + "name": "permissions_unique_constraint", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permissions_user_id_user_id_fk": { + "name": "permissions_user_id_user_id_fk", + "tableFrom": "permissions", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.public_share": { + "name": "public_share", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_emails": { + "name": "allowed_emails", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "public_share_token_unique": { + "name": "public_share_token_unique", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_resource_unique": { + "name": "public_share_resource_unique", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_resource_id_idx": { + "name": "public_share_resource_id_idx", + "columns": [ + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_workspace_id_idx": { + "name": "public_share_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "public_share_workspace_id_workspace_id_fk": { + "name": "public_share_workspace_id_workspace_id_fk", + "tableFrom": "public_share", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "public_share_created_by_user_id_fk": { + "name": "public_share_created_by_user_id_fk", + "tableFrom": "public_share", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rate_limit_bucket": { + "name": "rate_limit_bucket", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tokens": { + "name": "tokens", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.resume_queue": { + "name": "resume_queue", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "paused_execution_id": { + "name": "paused_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_execution_id": { + "name": "parent_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "new_execution_id": { + "name": "new_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "context_id": { + "name": "context_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resume_input": { + "name": "resume_input", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "queued_at": { + "name": "queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "resume_queue_parent_status_idx": { + "name": "resume_queue_parent_status_idx", + "columns": [ + { + "expression": "parent_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "resume_queue_new_execution_idx": { + "name": "resume_queue_new_execution_idx", + "columns": [ + { + "expression": "new_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "resume_queue_paused_execution_id_paused_executions_id_fk": { + "name": "resume_queue_paused_execution_id_paused_executions_id_fk", + "tableFrom": "resume_queue", + "tableTo": "paused_executions", + "columnsFrom": ["paused_execution_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_user_id_idx": { + "name": "session_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_token_idx": { + "name": "session_token_idx", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_active_organization_id_organization_id_fk": { + "name": "session_active_organization_id_organization_id_fk", + "tableFrom": "session", + "tableTo": "organization", + "columnsFrom": ["active_organization_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'system'" + }, + "auto_connect": { + "name": "auto_connect", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "telemetry_enabled": { + "name": "telemetry_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "email_preferences": { + "name": "email_preferences", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "billing_usage_notifications_enabled": { + "name": "billing_usage_notifications_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "show_training_controls": { + "name": "show_training_controls", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "super_user_mode_enabled": { + "name": "super_user_mode_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "mothership_environment": { + "name": "mothership_environment", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "error_notifications_enabled": { + "name": "error_notifications_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "snap_to_grid_size": { + "name": "snap_to_grid_size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "show_action_bar": { + "name": "show_action_bar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "copilot_enabled_models": { + "name": "copilot_enabled_models", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "copilot_auto_allowed_tools": { + "name": "copilot_auto_allowed_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "last_active_workspace_id": { + "name": "last_active_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "settings_user_id_user_id_fk": { + "name": "settings_user_id_user_id_fk", + "tableFrom": "settings", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "settings_user_id_unique": { + "name": "settings_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sim_trigger_state": { + "name": "sim_trigger_state", + "schema": "", + "columns": { + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_key": { + "name": "scope_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "last_fired_at": { + "name": "last_fired_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sim_trigger_state_workflow_id_workflow_id_fk": { + "name": "sim_trigger_state_workflow_id_workflow_id_fk", + "tableFrom": "sim_trigger_state", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sim_trigger_state_workflow_id_block_id_scope_key_pk": { + "name": "sim_trigger_state_workflow_id_block_id_scope_key_pk", + "columns": ["workflow_id", "block_id", "scope_key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill": { + "name": "skill", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skill_workspace_name_unique": { + "name": "skill_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_workspace_id_workspace_id_fk": { + "name": "skill_workspace_id_workspace_id_fk", + "tableFrom": "skill", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_user_id_user_id_fk": { + "name": "skill_user_id_user_id_fk", + "tableFrom": "skill", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_provider": { + "name": "sso_provider", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_config": { + "name": "oidc_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "saml_config": { + "name": "saml_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sso_provider_provider_id_idx": { + "name": "sso_provider_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_domain_idx": { + "name": "sso_provider_domain_idx", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_user_id_idx": { + "name": "sso_provider_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_organization_id_idx": { + "name": "sso_provider_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sso_provider_user_id_user_id_fk": { + "name": "sso_provider_user_id_user_id_fk", + "tableFrom": "sso_provider", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sso_provider_organization_id_organization_id_fk": { + "name": "sso_provider_organization_id_organization_id_fk", + "tableFrom": "sso_provider", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscription": { + "name": "subscription", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "period_start": { + "name": "period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "period_end": { + "name": "period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "cancel_at": { + "name": "cancel_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "canceled_at": { + "name": "canceled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "seats": { + "name": "seats", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "trial_start": { + "name": "trial_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trial_end": { + "name": "trial_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "billing_interval": { + "name": "billing_interval", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_schedule_id": { + "name": "stripe_schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "subscription_reference_status_idx": { + "name": "subscription_reference_status_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "check_enterprise_metadata": { + "name": "check_enterprise_metadata", + "value": "plan != 'enterprise' OR metadata IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.table_jobs": { + "name": "table_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "rows_processed": { + "name": "rows_processed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "table_jobs_one_active_per_table": { + "name": "table_jobs_one_active_per_table", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"table_jobs\".\"status\" = 'running' AND \"table_jobs\".\"type\" <> 'export'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_jobs_watchdog_idx": { + "name": "table_jobs_watchdog_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_jobs_table_started_idx": { + "name": "table_jobs_table_started_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_jobs_table_id_user_table_definitions_id_fk": { + "name": "table_jobs_table_id_user_table_definitions_id_fk", + "tableFrom": "table_jobs", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_jobs_workspace_id_workspace_id_fk": { + "name": "table_jobs_workspace_id_workspace_id_fk", + "tableFrom": "table_jobs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_row_executions": { + "name": "table_row_executions", + "schema": "", + "columns": { + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "job_id": { + "name": "job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "running_block_ids": { + "name": "running_block_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "block_errors": { + "name": "block_errors", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "enrichment_details": { + "name": "enrichment_details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "table_row_executions_table_status_idx": { + "name": "table_row_executions_table_status_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"table_row_executions\".\"status\" IN ('queued', 'running', 'pending')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_row_executions_execution_id_idx": { + "name": "table_row_executions_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"table_row_executions\".\"execution_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_row_executions_table_group_idx": { + "name": "table_row_executions_table_group_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_row_executions_table_id_user_table_definitions_id_fk": { + "name": "table_row_executions_table_id_user_table_definitions_id_fk", + "tableFrom": "table_row_executions", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_row_executions_row_id_user_table_rows_id_fk": { + "name": "table_row_executions_row_id_user_table_rows_id_fk", + "tableFrom": "table_row_executions", + "tableTo": "user_table_rows", + "columnsFrom": ["row_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "table_row_executions_row_id_group_id_pk": { + "name": "table_row_executions_row_id_group_id_pk", + "columns": ["row_id", "group_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_run_dispatches": { + "name": "table_run_dispatches", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "cursor": { + "name": "cursor", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "limit": { + "name": "limit", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "processed_count": { + "name": "processed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_manual_run": { + "name": "is_manual_run", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "triggered_by_user_id": { + "name": "triggered_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "requested_at": { + "name": "requested_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "table_run_dispatches_active_idx": { + "name": "table_run_dispatches_active_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_run_dispatches_watchdog_idx": { + "name": "table_run_dispatches_watchdog_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "requested_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_run_dispatches_table_id_user_table_definitions_id_fk": { + "name": "table_run_dispatches_table_id_user_table_definitions_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_run_dispatches_workspace_id_workspace_id_fk": { + "name": "table_run_dispatches_workspace_id_workspace_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_run_dispatches_triggered_by_user_id_user_id_fk": { + "name": "table_run_dispatches_triggered_by_user_id_user_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "user", + "columnsFrom": ["triggered_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.usage_log": { + "name": "usage_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "usage_log_category", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "usage_log_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cost": { + "name": "cost", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "event_key": { + "name": "event_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_entity_type": { + "name": "billing_entity_type", + "type": "billing_entity_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "billing_entity_id": { + "name": "billing_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_period_start": { + "name": "billing_period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "billing_period_end": { + "name": "billing_period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "usage_log_user_created_at_idx": { + "name": "usage_log_user_created_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_source_idx": { + "name": "usage_log_source_idx", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workspace_id_idx": { + "name": "usage_log_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workflow_id_idx": { + "name": "usage_log_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_event_key_unique": { + "name": "usage_log_event_key_unique", + "columns": [ + { + "expression": "event_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"usage_log\".\"event_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_billing_entity_period_idx": { + "name": "usage_log_billing_entity_period_idx", + "columns": [ + { + "expression": "billing_entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_start", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_end", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_log\".\"billing_entity_type\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workspace_created_at_idx": { + "name": "usage_log_workspace_created_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_execution_id_idx": { + "name": "usage_log_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "usage_log_user_id_user_id_fk": { + "name": "usage_log_user_id_user_id_fk", + "tableFrom": "usage_log", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "usage_log_workspace_id_workspace_id_fk": { + "name": "usage_log_workspace_id_workspace_id_fk", + "tableFrom": "usage_log", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "usage_log_workflow_id_workflow_id_fk": { + "name": "usage_log_workflow_id_workflow_id_fk", + "tableFrom": "usage_log", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "usage_log_billing_scope_all_or_none": { + "name": "usage_log_billing_scope_all_or_none", + "value": "(\n (\"usage_log\".\"billing_entity_type\" IS NULL AND \"usage_log\".\"billing_entity_id\" IS NULL AND \"usage_log\".\"billing_period_start\" IS NULL AND \"usage_log\".\"billing_period_end\" IS NULL)\n OR\n (\"usage_log\".\"billing_entity_type\" IS NOT NULL AND \"usage_log\".\"billing_entity_id\" IS NOT NULL AND \"usage_log\".\"billing_period_start\" IS NOT NULL AND \"usage_log\".\"billing_period_end\" IS NOT NULL AND \"usage_log\".\"billing_period_start\" < \"usage_log\".\"billing_period_end\")\n )" + } + }, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "normalized_email": { + "name": "normalized_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'user'" + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + }, + "user_normalized_email_unique": { + "name": "user_normalized_email_unique", + "nullsNotDistinct": false, + "columns": ["normalized_email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_stats": { + "name": "user_stats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "total_manual_executions": { + "name": "total_manual_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_api_calls": { + "name": "total_api_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_webhook_triggers": { + "name": "total_webhook_triggers", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_scheduled_executions": { + "name": "total_scheduled_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_chat_executions": { + "name": "total_chat_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_executions": { + "name": "total_mcp_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_tokens_used": { + "name": "total_tokens_used", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_cost": { + "name": "total_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_usage_limit": { + "name": "current_usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'5'" + }, + "usage_limit_updated_at": { + "name": "usage_limit_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "current_period_cost": { + "name": "current_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_cost": { + "name": "last_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "billed_overage_this_period": { + "name": "billed_overage_this_period", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "pro_period_cost_snapshot": { + "name": "pro_period_cost_snapshot", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "pro_period_cost_snapshot_at": { + "name": "pro_period_cost_snapshot_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "credit_balance": { + "name": "credit_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "total_copilot_cost": { + "name": "total_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_period_copilot_cost": { + "name": "current_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_copilot_cost": { + "name": "last_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "total_copilot_tokens": { + "name": "total_copilot_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_copilot_calls": { + "name": "total_copilot_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_copilot_calls": { + "name": "total_mcp_copilot_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_copilot_cost": { + "name": "total_mcp_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_period_mcp_copilot_cost": { + "name": "current_period_mcp_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_active": { + "name": "last_active", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "billing_blocked": { + "name": "billing_blocked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "billing_blocked_reason": { + "name": "billing_blocked_reason", + "type": "billing_blocked_reason", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "limit_notifications": { + "name": "limit_notifications", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": {}, + "foreignKeys": { + "user_stats_user_id_user_id_fk": { + "name": "user_stats_user_id_user_id_fk", + "tableFrom": "user_stats", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_stats_user_id_unique": { + "name": "user_stats_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_table_definitions": { + "name": "user_table_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "schema": { + "name": "schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "max_rows": { + "name": "max_rows", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10000 + }, + "row_count": { + "name": "row_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "rows_version": { + "name": "rows_version", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_table_def_workspace_id_idx": { + "name": "user_table_def_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_workspace_name_unique": { + "name": "user_table_def_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"user_table_definitions\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_archived_at_idx": { + "name": "user_table_def_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_workspace_archived_partial_idx": { + "name": "user_table_def_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"user_table_definitions\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_table_definitions_workspace_id_workspace_id_fk": { + "name": "user_table_definitions_workspace_id_workspace_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_definitions_created_by_user_id_fk": { + "name": "user_table_definitions_created_by_user_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_table_rows": { + "name": "user_table_rows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "order_key": { + "name": "order_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_table_rows_tenant_data_gin_idx": { + "name": "user_table_rows_tenant_data_gin_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"data\" jsonb_path_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "user_table_rows_workspace_table_idx": { + "name": "user_table_rows_workspace_table_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_position_idx": { + "name": "user_table_rows_table_position_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_order_key_idx": { + "name": "user_table_rows_table_order_key_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "order_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_id_id_idx": { + "name": "user_table_rows_table_id_id_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_table_rows_table_id_user_table_definitions_id_fk": { + "name": "user_table_rows_table_id_user_table_definitions_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_rows_workspace_id_workspace_id_fk": { + "name": "user_table_rows_workspace_id_workspace_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_rows_created_by_user_id_fk": { + "name": "user_table_rows_created_by_user_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "verification_expires_at_idx": { + "name": "verification_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.waitlist": { + "name": "waitlist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "waitlist_email_unique": { + "name": "waitlist_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook": { + "name": "webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_config": { + "name": "provider_config", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "path_deployment_unique": { + "name": "path_deployment_unique", + "columns": [ + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_workflow_deployment_idx": { + "name": "webhook_workflow_deployment_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_archived_at_partial_idx": { + "name": "webhook_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_webhook_on_provider_is_active_workflow_id_deploym_bdeed5468": { + "name": "idx_webhook_on_provider_is_active_workflow_id_deploym_bdeed5468", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_webhook_on_workflow_id_block_id_updated_at_desc": { + "name": "idx_webhook_on_workflow_id_block_id_updated_at_desc", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_workflow_id_workflow_id_fk": { + "name": "webhook_workflow_id_workflow_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "webhook_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "webhook_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow": { + "name": "workflow", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_synced": { + "name": "last_synced", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "is_deployed": { + "name": "is_deployed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deployed_at": { + "name": "deployed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "is_public_api": { + "name": "is_public_api", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_user_id_idx": { + "name": "workflow_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_id_idx": { + "name": "workflow_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_user_workspace_idx": { + "name": "workflow_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_folder_name_active_unique": { + "name": "workflow_workspace_folder_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"folder_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_sort_idx": { + "name": "workflow_folder_sort_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_archived_at_idx": { + "name": "workflow_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_archived_partial_idx": { + "name": "workflow_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_user_id_user_id_fk": { + "name": "workflow_user_id_user_id_fk", + "tableFrom": "workflow", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_workspace_id_workspace_id_fk": { + "name": "workflow_workspace_id_workspace_id_fk", + "tableFrom": "workflow", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_folder_id_workflow_folder_id_fk": { + "name": "workflow_folder_id_workflow_folder_id_fk", + "tableFrom": "workflow", + "tableTo": "workflow_folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_blocks": { + "name": "workflow_blocks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position_x": { + "name": "position_x", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "position_y": { + "name": "position_y", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "horizontal_handles": { + "name": "horizontal_handles", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_wide": { + "name": "is_wide", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "advanced_mode": { + "name": "advanced_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "trigger_mode": { + "name": "trigger_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "height": { + "name": "height", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "sub_blocks": { + "name": "sub_blocks", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "outputs": { + "name": "outputs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_blocks_workflow_id_idx": { + "name": "workflow_blocks_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_blocks_type_idx": { + "name": "workflow_blocks_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_blocks_workflow_id_workflow_id_fk": { + "name": "workflow_blocks_workflow_id_workflow_id_fk", + "tableFrom": "workflow_blocks", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_checkpoints": { + "name": "workflow_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_state": { + "name": "workflow_state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_checkpoints_user_id_idx": { + "name": "workflow_checkpoints_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_workflow_id_idx": { + "name": "workflow_checkpoints_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_chat_id_idx": { + "name": "workflow_checkpoints_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_message_id_idx": { + "name": "workflow_checkpoints_message_id_idx", + "columns": [ + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_user_workflow_idx": { + "name": "workflow_checkpoints_user_workflow_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_workflow_chat_idx": { + "name": "workflow_checkpoints_workflow_chat_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_created_at_idx": { + "name": "workflow_checkpoints_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_chat_created_at_idx": { + "name": "workflow_checkpoints_chat_created_at_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_checkpoints_user_id_user_id_fk": { + "name": "workflow_checkpoints_user_id_user_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_checkpoints_workflow_id_workflow_id_fk": { + "name": "workflow_checkpoints_workflow_id_workflow_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_checkpoints_chat_id_copilot_chats_id_fk": { + "name": "workflow_checkpoints_chat_id_copilot_chats_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_deployment_version": { + "name": "workflow_deployment_version", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_deployment_version_workflow_version_unique": { + "name": "workflow_deployment_version_workflow_version_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_version_workflow_active_idx": { + "name": "workflow_deployment_version_workflow_active_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_version_created_at_idx": { + "name": "workflow_deployment_version_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_deployment_version_workflow_id_workflow_id_fk": { + "name": "workflow_deployment_version_workflow_id_workflow_id_fk", + "tableFrom": "workflow_deployment_version", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_edges": { + "name": "workflow_edges", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_block_id": { + "name": "source_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_block_id": { + "name": "target_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_handle": { + "name": "source_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_handle": { + "name": "target_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_edges_workflow_id_idx": { + "name": "workflow_edges_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_source_idx": { + "name": "workflow_edges_workflow_source_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_target_idx": { + "name": "workflow_edges_workflow_target_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_edges_workflow_id_workflow_id_fk": { + "name": "workflow_edges_workflow_id_workflow_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_source_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_source_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": ["source_block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_target_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_target_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": ["target_block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_logs": { + "name": "workflow_execution_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_snapshot_id": { + "name": "state_snapshot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "execution_data": { + "name": "execution_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "cost": { + "name": "cost", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cost_total": { + "name": "cost_total", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "models_used": { + "name": "models_used", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "files": { + "name": "files", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_execution_logs_workflow_id_idx": { + "name": "workflow_execution_logs_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_state_snapshot_id_idx": { + "name": "workflow_execution_logs_state_snapshot_id_idx", + "columns": [ + { + "expression": "state_snapshot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_deployment_version_id_idx": { + "name": "workflow_execution_logs_deployment_version_id_idx", + "columns": [ + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_trigger_idx": { + "name": "workflow_execution_logs_trigger_idx", + "columns": [ + { + "expression": "trigger", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_level_idx": { + "name": "workflow_execution_logs_level_idx", + "columns": [ + { + "expression": "level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_started_at_idx": { + "name": "workflow_execution_logs_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_execution_id_unique": { + "name": "workflow_execution_logs_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workflow_started_at_idx": { + "name": "workflow_execution_logs_workflow_started_at_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_started_at_idx": { + "name": "workflow_execution_logs_workspace_started_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_started_at_id_desc_idx": { + "name": "workflow_execution_logs_workspace_started_at_id_desc_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"started_at\" DESC NULLS LAST", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_cost_total_idx": { + "name": "workflow_execution_logs_workspace_cost_total_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_total", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_models_used_idx": { + "name": "workflow_execution_logs_models_used_idx", + "columns": [ + { + "expression": "models_used", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "workflow_execution_logs_workspace_ended_at_id_idx": { + "name": "workflow_execution_logs_workspace_ended_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"ended_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_running_started_at_idx": { + "name": "workflow_execution_logs_running_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'running'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_execution_logs_workflow_id_workflow_id_fk": { + "name": "workflow_execution_logs_workflow_id_workflow_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workflow_execution_logs_workspace_id_workspace_id_fk": { + "name": "workflow_execution_logs_workspace_id_workspace_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_execution_logs_state_snapshot_id_workflow_execution_snapshots_id_fk": { + "name": "workflow_execution_logs_state_snapshot_id_workflow_execution_snapshots_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow_execution_snapshots", + "columnsFrom": ["state_snapshot_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workflow_execution_logs_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_execution_logs_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_snapshots": { + "name": "workflow_execution_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_hash": { + "name": "state_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_data": { + "name": "state_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_snapshots_workflow_id_idx": { + "name": "workflow_snapshots_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_hash_idx": { + "name": "workflow_snapshots_hash_idx", + "columns": [ + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_workflow_hash_idx": { + "name": "workflow_snapshots_workflow_hash_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_created_at_idx": { + "name": "workflow_snapshots_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_execution_snapshots_workflow_id_workflow_id_fk": { + "name": "workflow_execution_snapshots_workflow_id_workflow_id_fk", + "tableFrom": "workflow_execution_snapshots", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_folder": { + "name": "workflow_folder", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'#6B7280'" + }, + "is_expanded": { + "name": "is_expanded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_folder_user_idx": { + "name": "workflow_folder_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_workspace_parent_idx": { + "name": "workflow_folder_workspace_parent_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_parent_sort_idx": { + "name": "workflow_folder_parent_sort_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_archived_at_idx": { + "name": "workflow_folder_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_workspace_archived_partial_idx": { + "name": "workflow_folder_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_folder\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_folder_user_id_user_id_fk": { + "name": "workflow_folder_user_id_user_id_fk", + "tableFrom": "workflow_folder", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_folder_workspace_id_workspace_id_fk": { + "name": "workflow_folder_workspace_id_workspace_id_fk", + "tableFrom": "workflow_folder", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_mcp_server": { + "name": "workflow_mcp_server", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_mcp_server_workspace_id_idx": { + "name": "workflow_mcp_server_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_created_by_idx": { + "name": "workflow_mcp_server_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_deleted_at_idx": { + "name": "workflow_mcp_server_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_workspace_deleted_partial_idx": { + "name": "workflow_mcp_server_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_mcp_server\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_mcp_server_workspace_id_workspace_id_fk": { + "name": "workflow_mcp_server_workspace_id_workspace_id_fk", + "tableFrom": "workflow_mcp_server", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_mcp_server_created_by_user_id_fk": { + "name": "workflow_mcp_server_created_by_user_id_fk", + "tableFrom": "workflow_mcp_server", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_mcp_tool": { + "name": "workflow_mcp_tool", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_description": { + "name": "tool_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parameter_schema": { + "name": "parameter_schema", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "parameter_description_overrides": { + "name": "parameter_description_overrides", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'::json" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_mcp_tool_server_id_idx": { + "name": "workflow_mcp_tool_server_id_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_workflow_id_idx": { + "name": "workflow_mcp_tool_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_server_workflow_unique": { + "name": "workflow_mcp_tool_server_workflow_unique", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_mcp_tool\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_archived_at_partial_idx": { + "name": "workflow_mcp_tool_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_mcp_tool\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_mcp_tool_server_id_workflow_mcp_server_id_fk": { + "name": "workflow_mcp_tool_server_id_workflow_mcp_server_id_fk", + "tableFrom": "workflow_mcp_tool", + "tableTo": "workflow_mcp_server", + "columnsFrom": ["server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_mcp_tool_workflow_id_workflow_id_fk": { + "name": "workflow_mcp_tool_workflow_id_workflow_id_fk", + "tableFrom": "workflow_mcp_tool", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_schedule": { + "name": "workflow_schedule", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cron_expression": { + "name": "cron_expression", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_ran_at": { + "name": "last_ran_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_queued_at": { + "name": "last_queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trigger_type": { + "name": "trigger_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "infra_retry_count": { + "name": "infra_retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'workflow'" + }, + "job_title": { + "name": "job_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lifecycle": { + "name": "lifecycle", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'persistent'" + }, + "success_condition": { + "name": "success_condition", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "max_runs": { + "name": "max_runs", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "source_chat_id": { + "name": "source_chat_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_task_name": { + "name": "source_task_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_user_id": { + "name": "source_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_workspace_id": { + "name": "source_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "job_history": { + "name": "job_history", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "contexts": { + "name": "contexts", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "excluded_dates": { + "name": "excluded_dates", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "ends_at": { + "name": "ends_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_schedule_workflow_block_deployment_unique": { + "name": "workflow_schedule_workflow_block_deployment_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_workflow_deployment_idx": { + "name": "workflow_schedule_workflow_deployment_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_archived_at_partial_idx": { + "name": "workflow_schedule_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workflow_schedule_on_source_workspace_id_source_t_c07f3bba6": { + "name": "idx_workflow_schedule_on_source_workspace_id_source_t_c07f3bba6", + "columns": [ + { + "expression": "source_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_due_workflow_idx": { + "name": "workflow_schedule_due_workflow_idx", + "columns": [ + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL AND \"workflow_schedule\".\"status\" NOT IN ('disabled', 'completed') AND (\"workflow_schedule\".\"source_type\" = 'workflow' OR \"workflow_schedule\".\"source_type\" IS NULL)", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_due_job_idx": { + "name": "workflow_schedule_due_job_idx", + "columns": [ + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL AND \"workflow_schedule\".\"status\" NOT IN ('disabled', 'completed') AND \"workflow_schedule\".\"source_type\" = 'job'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_schedule_workflow_id_workflow_id_fk": { + "name": "workflow_schedule_workflow_id_workflow_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_schedule_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_source_user_id_user_id_fk": { + "name": "workflow_schedule_source_user_id_user_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "user", + "columnsFrom": ["source_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_source_workspace_id_workspace_id_fk": { + "name": "workflow_schedule_source_workspace_id_workspace_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workspace", + "columnsFrom": ["source_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_subflows": { + "name": "workflow_subflows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_subflows_workflow_id_idx": { + "name": "workflow_subflows_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_subflows_workflow_type_idx": { + "name": "workflow_subflows_workflow_type_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_subflows_workflow_id_workflow_id_fk": { + "name": "workflow_subflows_workflow_id_workflow_id_fk", + "tableFrom": "workflow_subflows", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace": { + "name": "workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#33C482'" + }, + "logo_url": { + "name": "logo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_mode": { + "name": "workspace_mode", + "type": "workspace_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'grandfathered_shared'" + }, + "billed_account_user_id": { + "name": "billed_account_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "allow_personal_api_keys": { + "name": "allow_personal_api_keys", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "inbox_enabled": { + "name": "inbox_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "inbox_address": { + "name": "inbox_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inbox_provider_id": { + "name": "inbox_provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "forked_from_workspace_id": { + "name": "forked_from_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_owner_id_idx": { + "name": "workspace_owner_id_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_organization_id_idx": { + "name": "workspace_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_mode_idx": { + "name": "workspace_mode_idx", + "columns": [ + { + "expression": "workspace_mode", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_forked_from_workspace_id_idx": { + "name": "workspace_forked_from_workspace_id_idx", + "columns": [ + { + "expression": "forked_from_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_owner_id_user_id_fk": { + "name": "workspace_owner_id_user_id_fk", + "tableFrom": "workspace", + "tableTo": "user", + "columnsFrom": ["owner_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_organization_id_organization_id_fk": { + "name": "workspace_organization_id_organization_id_fk", + "tableFrom": "workspace", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_billed_account_user_id_user_id_fk": { + "name": "workspace_billed_account_user_id_user_id_fk", + "tableFrom": "workspace", + "tableTo": "user", + "columnsFrom": ["billed_account_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workspace_forked_from_workspace_id_workspace_id_fk": { + "name": "workspace_forked_from_workspace_id_workspace_id_fk", + "tableFrom": "workspace", + "tableTo": "workspace", + "columnsFrom": ["forked_from_workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_byok_keys": { + "name": "workspace_byok_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_byok_workspace_provider_idx": { + "name": "workspace_byok_workspace_provider_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_byok_keys_workspace_id_workspace_id_fk": { + "name": "workspace_byok_keys_workspace_id_workspace_id_fk", + "tableFrom": "workspace_byok_keys", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_byok_keys_created_by_user_id_fk": { + "name": "workspace_byok_keys_created_by_user_id_fk", + "tableFrom": "workspace_byok_keys", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_environment": { + "name": "workspace_environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_environment_workspace_unique": { + "name": "workspace_environment_workspace_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_environment_workspace_id_workspace_id_fk": { + "name": "workspace_environment_workspace_id_workspace_id_fk", + "tableFrom": "workspace_environment", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file": { + "name": "workspace_file", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_file_workspace_id_idx": { + "name": "workspace_file_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_key_idx": { + "name": "workspace_file_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_deleted_at_idx": { + "name": "workspace_file_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_workspace_deleted_partial_idx": { + "name": "workspace_file_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_workspace_id_workspace_id_fk": { + "name": "workspace_file_workspace_id_workspace_id_fk", + "tableFrom": "workspace_file", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_uploaded_by_user_id_fk": { + "name": "workspace_file_uploaded_by_user_id_fk", + "tableFrom": "workspace_file", + "tableTo": "user", + "columnsFrom": ["uploaded_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_file_key_unique": { + "name": "workspace_file_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_folders": { + "name": "workspace_file_folders", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_file_folders_workspace_parent_idx": { + "name": "workspace_file_folders_workspace_parent_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_folders_parent_sort_idx": { + "name": "workspace_file_folders_parent_sort_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_folders_deleted_at_idx": { + "name": "workspace_file_folders_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_folders_workspace_deleted_partial_idx": { + "name": "workspace_file_folders_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file_folders\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_folders_workspace_parent_name_active_unique": { + "name": "workspace_file_folders_workspace_parent_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"parent_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_file_folders\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_folders_user_id_user_id_fk": { + "name": "workspace_file_folders_user_id_user_id_fk", + "tableFrom": "workspace_file_folders", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_folders_workspace_id_workspace_id_fk": { + "name": "workspace_file_folders_workspace_id_workspace_id_fk", + "tableFrom": "workspace_file_folders", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_folders_parent_id_workspace_file_folders_id_fk": { + "name": "workspace_file_folders_parent_id_workspace_file_folders_id_fk", + "tableFrom": "workspace_file_folders", + "tableTo": "workspace_file_folders", + "columnsFrom": ["parent_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_files": { + "name": "workspace_files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "context": { + "name": "context", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "original_name": { + "name": "original_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_files_key_active_unique": { + "name": "workspace_files_key_active_unique", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_folder_name_active_unique": { + "name": "workspace_files_workspace_folder_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"folder_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "original_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"deleted_at\" IS NULL AND \"workspace_files\".\"context\" = 'workspace' AND \"workspace_files\".\"workspace_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_chat_display_name_unique": { + "name": "workspace_files_chat_display_name_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"context\" = 'mothership' AND \"workspace_files\".\"chat_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_key_idx": { + "name": "workspace_files_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_user_id_idx": { + "name": "workspace_files_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_id_idx": { + "name": "workspace_files_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_folder_id_idx": { + "name": "workspace_files_folder_id_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_context_idx": { + "name": "workspace_files_context_idx", + "columns": [ + { + "expression": "context", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_chat_id_idx": { + "name": "workspace_files_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_deleted_at_idx": { + "name": "workspace_files_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_deleted_partial_idx": { + "name": "workspace_files_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_files\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_files_user_id_user_id_fk": { + "name": "workspace_files_user_id_user_id_fk", + "tableFrom": "workspace_files", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_files_workspace_id_workspace_id_fk": { + "name": "workspace_files_workspace_id_workspace_id_fk", + "tableFrom": "workspace_files", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_files_folder_id_workspace_file_folders_id_fk": { + "name": "workspace_files_folder_id_workspace_file_folders_id_fk", + "tableFrom": "workspace_files", + "tableTo": "workspace_file_folders", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_files_chat_id_copilot_chats_id_fk": { + "name": "workspace_files_chat_id_copilot_chats_id_fk", + "tableFrom": "workspace_files", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_block_map": { + "name": "workspace_fork_block_map", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_workflow_id": { + "name": "parent_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_block_id": { + "name": "parent_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_workflow_id": { + "name": "child_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_block_id": { + "name": "child_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_block_map_child_ws_parent_unique": { + "name": "workspace_fork_block_map_child_ws_parent_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_child_unique": { + "name": "workspace_fork_block_map_child_ws_child_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_parent_wf_idx": { + "name": "workspace_fork_block_map_child_ws_parent_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_child_wf_idx": { + "name": "workspace_fork_block_map_child_ws_child_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_block_map_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_block_map_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_block_map", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_dependent_value": { + "name": "workspace_fork_dependent_value", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_workflow_id": { + "name": "target_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_block_id": { + "name": "target_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sub_block_key": { + "name": "sub_block_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_dependent_value_child_ws_wf_idx": { + "name": "workspace_fork_dependent_value_child_ws_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_dependent_value_field_unique": { + "name": "workspace_fork_dependent_value_field_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sub_block_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_dependent_value_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_dependent_value_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_dependent_value", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_promote_run": { + "name": "workspace_fork_promote_run", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_workspace_id": { + "name": "source_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_workspace_id": { + "name": "target_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "direction": { + "name": "direction", + "type": "workspace_fork_promote_direction", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "snapshot": { + "name": "snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_promote_run_child_ws_target_unique": { + "name": "workspace_fork_promote_run_child_ws_target_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_promote_run_target_ws_idx": { + "name": "workspace_fork_promote_run_target_ws_idx", + "columns": [ + { + "expression": "target_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_promote_run_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_promote_run_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_promote_run", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_fork_promote_run_created_by_user_id_fk": { + "name": "workspace_fork_promote_run_created_by_user_id_fk", + "tableFrom": "workspace_fork_promote_run", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_resource_map": { + "name": "workspace_fork_resource_map", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "workspace_fork_resource_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "parent_resource_id": { + "name": "parent_resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_resource_id": { + "name": "child_resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_resource_map_child_ws_idx": { + "name": "workspace_fork_resource_map_child_ws_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_resource_map_child_ws_type_idx": { + "name": "workspace_fork_resource_map_child_ws_type_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_resource_map_child_type_parent_unique": { + "name": "workspace_fork_resource_map_child_type_parent_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_resource_map_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_resource_map_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_resource_map", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_fork_resource_map_created_by_user_id_fk": { + "name": "workspace_fork_resource_map_created_by_user_id_fk", + "tableFrom": "workspace_fork_resource_map", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.academy_cert_status": { + "name": "academy_cert_status", + "schema": "public", + "values": ["active", "revoked", "expired"] + }, + "public.background_work_kind": { + "name": "background_work_kind", + "schema": "public", + "values": ["deployment_side_effects", "fork_content_copy", "fork_sync", "fork_rollback"] + }, + "public.background_work_status_value": { + "name": "background_work_status_value", + "schema": "public", + "values": ["pending", "processing", "completed", "completed_with_warnings", "failed"] + }, + "public.billing_blocked_reason": { + "name": "billing_blocked_reason", + "schema": "public", + "values": ["payment_failed", "dispute"] + }, + "public.billing_entity_type": { + "name": "billing_entity_type", + "schema": "public", + "values": ["user", "organization"] + }, + "public.chat_type": { + "name": "chat_type", + "schema": "public", + "values": ["mothership", "copilot"] + }, + "public.copilot_async_tool_status": { + "name": "copilot_async_tool_status", + "schema": "public", + "values": ["pending", "running", "completed", "failed", "cancelled", "delivered"] + }, + "public.copilot_run_status": { + "name": "copilot_run_status", + "schema": "public", + "values": ["active", "paused_waiting_for_tool", "resuming", "complete", "error", "cancelled"] + }, + "public.credential_member_role": { + "name": "credential_member_role", + "schema": "public", + "values": ["admin", "member"] + }, + "public.credential_member_status": { + "name": "credential_member_status", + "schema": "public", + "values": ["active", "pending", "revoked"] + }, + "public.credential_type": { + "name": "credential_type", + "schema": "public", + "values": ["oauth", "env_workspace", "env_personal", "service_account"] + }, + "public.data_drain_cadence": { + "name": "data_drain_cadence", + "schema": "public", + "values": ["hourly", "daily"] + }, + "public.data_drain_destination": { + "name": "data_drain_destination", + "schema": "public", + "values": ["s3", "gcs", "azure_blob", "datadog", "bigquery", "snowflake", "webhook"] + }, + "public.data_drain_run_status": { + "name": "data_drain_run_status", + "schema": "public", + "values": ["running", "success", "failed"] + }, + "public.data_drain_run_trigger": { + "name": "data_drain_run_trigger", + "schema": "public", + "values": ["cron", "manual"] + }, + "public.data_drain_source": { + "name": "data_drain_source", + "schema": "public", + "values": ["workflow_logs", "job_logs", "audit_logs", "copilot_chats", "copilot_runs"] + }, + "public.execution_large_value_reference_source": { + "name": "execution_large_value_reference_source", + "schema": "public", + "values": ["execution_log", "paused_snapshot"] + }, + "public.invitation_kind": { + "name": "invitation_kind", + "schema": "public", + "values": ["organization", "workspace"] + }, + "public.invitation_membership_intent": { + "name": "invitation_membership_intent", + "schema": "public", + "values": ["internal", "external"] + }, + "public.invitation_status": { + "name": "invitation_status", + "schema": "public", + "values": ["pending", "accepted", "rejected", "cancelled", "expired"] + }, + "public.permission_type": { + "name": "permission_type", + "schema": "public", + "values": ["admin", "write", "read"] + }, + "public.usage_log_category": { + "name": "usage_log_category", + "schema": "public", + "values": ["model", "fixed", "tool"] + }, + "public.usage_log_source": { + "name": "usage_log_source", + "schema": "public", + "values": [ + "workflow", + "wand", + "copilot", + "workspace-chat", + "mcp_copilot", + "mothership_block", + "knowledge-base", + "voice-input", + "enrichment" + ] + }, + "public.workspace_fork_promote_direction": { + "name": "workspace_fork_promote_direction", + "schema": "public", + "values": ["push", "pull"] + }, + "public.workspace_fork_resource_type": { + "name": "workspace_fork_resource_type", + "schema": "public", + "values": [ + "workflow", + "oauth_credential", + "service_account_credential", + "env_var", + "table", + "knowledge_base", + "knowledge_document", + "file", + "mcp_server", + "custom_tool", + "skill" + ] + }, + "public.workspace_mode": { + "name": "workspace_mode", + "schema": "public", + "values": ["personal", "organization", "grandfathered_shared"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index 6138ec0120b..f9320ed41b7 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -1779,6 +1779,13 @@ "when": 1782957781005, "tag": "0254_custom_block", "breakpoints": true + }, + { + "idx": 255, + "version": "7", + "when": 1783382202081, + "tag": "0255_remove_credential_sets", + "breakpoints": true } ] } diff --git a/packages/db/schema.ts b/packages/db/schema.ts index bd6f978daa0..122b987e336 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -744,9 +744,6 @@ export const webhook = pgTable( isActive: boolean('is_active').notNull().default(true), failedCount: integer('failed_count').default(0), // Track consecutive failures lastFailedAt: timestamp('last_failed_at'), // When the webhook last failed - credentialSetId: text('credential_set_id').references(() => credentialSet.id, { - onDelete: 'set null', - }), // For credential set webhooks - enables efficient queries archivedAt: timestamp('archived_at'), createdAt: timestamp('created_at').notNull().defaultNow(), updatedAt: timestamp('updated_at').notNull().defaultNow(), @@ -761,8 +758,6 @@ export const webhook = pgTable( table.workflowId, table.deploymentVersionId ), - // Optimize queries for credential set webhooks - credentialSetIdIdx: index('webhook_credential_set_id_idx').on(table.credentialSetId), archivedAtPartialIdx: index('webhook_archived_at_partial_idx') .on(table.archivedAt) .where(sql`${table.archivedAt} IS NOT NULL`), @@ -3107,99 +3102,6 @@ export const pendingCredentialDraft = pgTable( }) ) -export const credentialSet = pgTable( - 'credential_set', - { - id: text('id').primaryKey(), - organizationId: text('organization_id') - .notNull() - .references(() => organization.id, { onDelete: 'cascade' }), - name: text('name').notNull(), - description: text('description'), - providerId: text('provider_id').notNull(), - createdBy: text('created_by') - .notNull() - .references(() => user.id, { onDelete: 'cascade' }), - createdAt: timestamp('created_at').notNull().defaultNow(), - updatedAt: timestamp('updated_at').notNull().defaultNow(), - }, - (table) => ({ - createdByIdx: index('credential_set_created_by_idx').on(table.createdBy), - orgNameUnique: uniqueIndex('credential_set_org_name_unique').on( - table.organizationId, - table.name - ), - providerIdIdx: index('credential_set_provider_id_idx').on(table.providerId), - }) -) - -export const credentialSetMemberStatusEnum = pgEnum('credential_set_member_status', [ - 'active', - 'pending', - 'revoked', -]) - -export const credentialSetMember = pgTable( - 'credential_set_member', - { - id: text('id').primaryKey(), - credentialSetId: text('credential_set_id') - .notNull() - .references(() => credentialSet.id, { onDelete: 'cascade' }), - userId: text('user_id') - .notNull() - .references(() => user.id, { onDelete: 'cascade' }), - status: credentialSetMemberStatusEnum('status').notNull().default('pending'), - joinedAt: timestamp('joined_at'), - invitedBy: text('invited_by').references(() => user.id, { onDelete: 'set null' }), - createdAt: timestamp('created_at').notNull().defaultNow(), - updatedAt: timestamp('updated_at').notNull().defaultNow(), - }, - (table) => ({ - userIdIdx: index('credential_set_member_user_id_idx').on(table.userId), - uniqueMembership: uniqueIndex('credential_set_member_unique').on( - table.credentialSetId, - table.userId - ), - statusIdx: index('credential_set_member_status_idx').on(table.status), - }) -) - -export const credentialSetInvitationStatusEnum = pgEnum('credential_set_invitation_status', [ - 'pending', - 'accepted', - 'expired', - 'cancelled', -]) - -export const credentialSetInvitation = pgTable( - 'credential_set_invitation', - { - id: text('id').primaryKey(), - credentialSetId: text('credential_set_id') - .notNull() - .references(() => credentialSet.id, { onDelete: 'cascade' }), - email: text('email'), - token: text('token').notNull().unique(), - invitedBy: text('invited_by') - .notNull() - .references(() => user.id, { onDelete: 'cascade' }), - status: credentialSetInvitationStatusEnum('status').notNull().default('pending'), - expiresAt: timestamp('expires_at').notNull(), - acceptedAt: timestamp('accepted_at'), - acceptedByUserId: text('accepted_by_user_id').references(() => user.id, { - onDelete: 'set null', - }), - createdAt: timestamp('created_at').notNull().defaultNow(), - }, - (table) => ({ - credentialSetIdIdx: index('credential_set_invitation_set_id_idx').on(table.credentialSetId), - tokenIdx: index('credential_set_invitation_token_idx').on(table.token), - statusIdx: index('credential_set_invitation_status_idx').on(table.status), - expiresAtIdx: index('credential_set_invitation_expires_at_idx').on(table.expiresAt), - }) -) - /** * A named set of access-control restrictions (`config`) governing users within * an organization. diff --git a/packages/testing/src/mocks/audit.mock.ts b/packages/testing/src/mocks/audit.mock.ts index d7a16b82364..82a59734b8f 100644 --- a/packages/testing/src/mocks/audit.mock.ts +++ b/packages/testing/src/mocks/audit.mock.ts @@ -47,15 +47,6 @@ export const auditMock = { CREDENTIAL_MEMBER_REMOVED: 'credential_member.removed', CREDENTIAL_MEMBER_ROLE_CHANGED: 'credential_member.role_changed', CREDIT_PURCHASED: 'credit.purchased', - CREDENTIAL_SET_CREATED: 'credential_set.created', - CREDENTIAL_SET_UPDATED: 'credential_set.updated', - CREDENTIAL_SET_DELETED: 'credential_set.deleted', - CREDENTIAL_SET_MEMBER_REMOVED: 'credential_set_member.removed', - CREDENTIAL_SET_MEMBER_LEFT: 'credential_set_member.left', - CREDENTIAL_SET_INVITATION_CREATED: 'credential_set_invitation.created', - CREDENTIAL_SET_INVITATION_ACCEPTED: 'credential_set_invitation.accepted', - CREDENTIAL_SET_INVITATION_RESENT: 'credential_set_invitation.resent', - CREDENTIAL_SET_INVITATION_REVOKED: 'credential_set_invitation.revoked', CUSTOM_TOOL_CREATED: 'custom_tool.created', CUSTOM_TOOL_UPDATED: 'custom_tool.updated', CUSTOM_TOOL_DELETED: 'custom_tool.deleted', @@ -165,7 +156,6 @@ export const auditMock = { CHAT: 'chat', CONNECTOR: 'connector', CREDENTIAL: 'credential', - CREDENTIAL_SET: 'credential_set', CUSTOM_TOOL: 'custom_tool', DATA_DRAIN: 'data_drain', DOCUMENT: 'document', diff --git a/packages/testing/src/mocks/auth-oauth-utils.mock.ts b/packages/testing/src/mocks/auth-oauth-utils.mock.ts index ad0d6395ac1..a11162377c6 100644 --- a/packages/testing/src/mocks/auth-oauth-utils.mock.ts +++ b/packages/testing/src/mocks/auth-oauth-utils.mock.ts @@ -35,7 +35,6 @@ export const authOAuthUtilsMockFns = { mockGetOAuthToken: vi.fn(), mockRefreshAccessTokenIfNeeded: vi.fn(), mockRefreshTokenIfNeeded: vi.fn(), - mockGetCredentialsForCredentialSet: vi.fn(), } /** @@ -55,5 +54,4 @@ export const authOAuthUtilsMock = { getOAuthToken: authOAuthUtilsMockFns.mockGetOAuthToken, refreshAccessTokenIfNeeded: authOAuthUtilsMockFns.mockRefreshAccessTokenIfNeeded, refreshTokenIfNeeded: authOAuthUtilsMockFns.mockRefreshTokenIfNeeded, - getCredentialsForCredentialSet: authOAuthUtilsMockFns.mockGetCredentialsForCredentialSet, } diff --git a/packages/testing/src/mocks/env-flags.mock.ts b/packages/testing/src/mocks/env-flags.mock.ts index 597d572090a..480ae0d62f0 100644 --- a/packages/testing/src/mocks/env-flags.mock.ts +++ b/packages/testing/src/mocks/env-flags.mock.ts @@ -23,7 +23,6 @@ export const envFlagsMock = { isTriggerDevEnabled: false, isTablesFractionalOrderingEnabled: false, isSsoEnabled: false, - isCredentialSetsEnabled: false, isAccessControlEnabled: false, isOrganizationsEnabled: false, isInboxEnabled: false, diff --git a/packages/testing/src/mocks/schema.mock.ts b/packages/testing/src/mocks/schema.mock.ts index aa28b59fe36..4d52742a3a5 100644 --- a/packages/testing/src/mocks/schema.mock.ts +++ b/packages/testing/src/mocks/schema.mock.ts @@ -302,7 +302,6 @@ export const schemaMock = { isActive: 'isActive', failedCount: 'failedCount', lastFailedAt: 'lastFailedAt', - credentialSetId: 'credentialSetId', archivedAt: 'archivedAt', createdAt: 'createdAt', updatedAt: 'updatedAt', @@ -939,40 +938,6 @@ export const schemaMock = { expiresAt: 'expiresAt', createdAt: 'createdAt', }, - credentialSet: { - id: 'id', - organizationId: 'organizationId', - name: 'name', - description: 'description', - providerId: 'providerId', - createdBy: 'createdBy', - createdAt: 'createdAt', - updatedAt: 'updatedAt', - }, - credentialSetMemberStatusEnum: 'credentialSetMemberStatusEnum', - credentialSetMember: { - id: 'id', - credentialSetId: 'credentialSetId', - userId: 'userId', - status: 'status', - joinedAt: 'joinedAt', - invitedBy: 'invitedBy', - createdAt: 'createdAt', - updatedAt: 'updatedAt', - }, - credentialSetInvitationStatusEnum: 'credentialSetInvitationStatusEnum', - credentialSetInvitation: { - id: 'id', - credentialSetId: 'credentialSetId', - email: 'email', - token: 'token', - invitedBy: 'invitedBy', - status: 'status', - expiresAt: 'expiresAt', - acceptedAt: 'acceptedAt', - acceptedByUserId: 'acceptedByUserId', - createdAt: 'createdAt', - }, permissionGroup: { id: 'id', organizationId: 'organizationId', diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index 677077f158e..e398d7c1dfa 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -45,7 +45,6 @@ const INDIRECT_ZOD_ROUTES = new Set([ 'apps/sim/app/api/auth/oauth/connections/route.ts', 'apps/sim/app/api/auth/providers/route.ts', 'apps/sim/app/api/auth/socket-token/route.ts', - 'apps/sim/app/api/credential-sets/invitations/route.ts', 'apps/sim/app/api/workspaces/invitations/route.ts', // Internal cron entry point that authenticates via `Authorization: Bearer // CRON_SECRET` and ignores query/body. The boundary contract is "no From e9a922d346013aee691614f9c5bd56206ff66771 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Mon, 6 Jul 2026 18:51:29 -0700 Subject: [PATCH 30/35] fix(tables): enrichment sidebar column-id display + filter-scoped run menu (#5459) * fix(tables): resolve column ids to display names in enrichment edit sidebar * feat(tables): scope group-header run menu to the active filter --- .../enrichments-sidebar/enrichment-config.tsx | 35 +++++++++++++----- .../headers/workflow-group-meta-cell.tsx | 37 ++++++++++++++++--- .../components/table-grid/table-grid.tsx | 6 ++- 3 files changed, 61 insertions(+), 17 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/enrichments-sidebar/enrichment-config.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/enrichments-sidebar/enrichment-config.tsx index d7ace26b0ef..23820321337 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/enrichments-sidebar/enrichment-config.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/enrichments-sidebar/enrichment-config.tsx @@ -17,6 +17,7 @@ import { toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import type { AddWorkflowGroupBodyInput } from '@/lib/api/contracts/tables' import type { ColumnDefinition, WorkflowGroup, WorkflowGroupOutput } from '@/lib/table' +import { columnMatchesRef, getColumnId } from '@/lib/table/column-keys' import { deriveOutputColumnName } from '@/lib/table/column-naming' import { FieldError } from '@/app/workspace/[workspaceId]/tables/[tableId]/components/sidebar-fields' import type { EnrichmentConfig as EnrichmentDef } from '@/enrichments/types' @@ -49,7 +50,7 @@ function defaultColumnFor( c.name.toLowerCase() === input.id.toLowerCase() || c.name.toLowerCase() === input.name.toLowerCase() ) - return match?.name ?? '' + return match ? getColumnId(match) : '' } /** @@ -71,14 +72,23 @@ export function EnrichmentConfig({ const updateColumn = useUpdateColumn({ workspaceId, tableId }) const isEditing = Boolean(existingGroup) - /** Output column's persisted name (edit mode), used to detect renames. */ - const originalOutputName = (outputId: string): string | undefined => - existingGroup?.outputs.find((o) => o.outputId === outputId)?.columnName + /** Output's column (edit mode). Persisted `columnName` refs hold a stable + * column id (name for legacy groups), so resolve id-or-name to the column. */ + const outputColumn = (outputId: string): ColumnDefinition | undefined => { + const ref = existingGroup?.outputs.find((o) => o.outputId === outputId)?.columnName + return ref === undefined ? undefined : allColumns.find((c) => columnMatchesRef(c, ref)) + } + + /** Output column's persisted display name (edit mode), used to detect renames. */ + const originalOutputName = (outputId: string): string | undefined => outputColumn(outputId)?.name const [inputMappings, setInputMappings] = useState>(() => { if (existingGroup) { const seed: Record = {} - for (const m of existingGroup.inputMappings ?? []) seed[m.inputName] = m.columnName + for (const m of existingGroup.inputMappings ?? []) { + const col = allColumns.find((c) => columnMatchesRef(c, m.columnName)) + seed[m.inputName] = col ? getColumnId(col) : m.columnName + } return seed } const seed: Record = {} @@ -94,7 +104,9 @@ export function EnrichmentConfig({ const seed: Record = {} if (existingGroup) { for (const o of existingGroup.outputs) { - if (o.outputId) seed[o.outputId] = o.columnName + if (!o.outputId) continue + const col = allColumns.find((c) => columnMatchesRef(c, o.columnName)) + seed[o.outputId] = col?.name ?? o.columnName } return seed } @@ -111,7 +123,7 @@ export function EnrichmentConfig({ const [deps, setDeps] = useState(() => existingGroup?.dependencies?.columns ?? []) const [showValidation, setShowValidation] = useState(false) - const columnOptions = allColumns.map((c) => ({ label: c.name, value: c.name })) + const columnOptions = allColumns.map((c) => ({ label: c.name, value: getColumnId(c) })) const missingRequired = enrichment.inputs.some((i) => i.required && !inputMappings[i.id]) const depsValid = !autoRun || deps.length > 0 @@ -162,10 +174,13 @@ export function EnrichmentConfig({ autoRun, }) for (const o of enrichment.outputs) { - const original = originalOutputName(o.id) + const col = outputColumn(o.id) const next = (outputNames[o.id] ?? '').trim() - if (original && next && next !== original) { - await updateColumn.mutateAsync({ columnName: original, updates: { name: next } }) + if (col && next && next !== col.name) { + await updateColumn.mutateAsync({ + columnName: getColumnId(col), + updates: { name: next }, + }) } } toast.success(`Updated "${enrichment.name}"`) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/workflow-group-meta-cell.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/workflow-group-meta-cell.tsx index 0f760fff8e1..031a9c8a6f2 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/workflow-group-meta-cell.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/workflow-group-meta-cell.tsx @@ -37,6 +37,18 @@ import type { DisplayColumn } from '../types' * surfaces stay in sync. */ const LIMITED_RUN_PRESETS = [10, 1000] as const +/** Labels for the table-scoped run items. With an active filter the run is + * scoped to matching rows, so the labels say "filtered rows" to make the + * narrowed target visible. Shared by both menu surfaces. */ +function runMenuLabels(hasActiveFilter: boolean) { + const rows = hasActiveFilter ? 'filtered rows' : 'rows' + return { + all: `Run all ${rows}`, + incomplete: `Run empty ${rows}`, + limited: (max: number) => `Run ${max.toLocaleString()} empty ${rows}`, + } +} + interface ColumnOptionsMenuProps { open: boolean onOpenChange: (open: boolean) => void @@ -65,6 +77,9 @@ interface ColumnOptionsMenuProps { /** When set, surfaces a "Run N selected rows" item above Run all. */ onRunColumnSelected?: () => void selectedRowCount?: number + /** Table-scoped run items honor the active filter; when true the labels say + * "filtered rows" so the narrowed scope is visible. */ + hasActiveFilter?: boolean /** When set, the menu surfaces a "View workflow" item that opens a popup * preview of the configured workflow. */ onViewWorkflow?: () => void @@ -97,12 +112,14 @@ export function ColumnOptionsMenu({ onRunColumnLimited, onRunColumnSelected, selectedRowCount = 0, + hasActiveFilter = false, onViewWorkflow, isPinned, onPinToggle, }: ColumnOptionsMenuProps) { const showRunActions = Boolean(onRunColumnAll && onRunColumnIncomplete) const showRunSelected = Boolean(onRunColumnSelected) && selectedRowCount > 0 + const runLabels = runMenuLabels(hasActiveFilter) return ( @@ -140,15 +157,15 @@ export function ColumnOptionsMenu({ )} onRunColumnAll?.()}> - Run all rows + {runLabels.all} onRunColumnIncomplete?.()}> - Run empty rows + {runLabels.incomplete} {onRunColumnLimited && LIMITED_RUN_PRESETS.map((max) => ( onRunColumnLimited(max)}> - {`Run ${max.toLocaleString()} empty rows`} + {runLabels.limited(max)} ))} @@ -221,6 +238,9 @@ interface WorkflowGroupMetaCellProps { /** Row ids in the user's current multi-row selection; when non-empty the * run menu adds a "Run N selected rows" option. */ selectedRowIds?: string[] | null + /** True when the grid has an active filter — table-scoped run items apply + * only to matching rows and are labeled "filtered rows". */ + hasActiveFilter?: boolean /** Opens a popup preview of the underlying workflow. */ onViewWorkflow?: (workflowId: string) => void /** When set, the meta cell becomes draggable and forwards events through @@ -267,6 +287,7 @@ export function WorkflowGroupMetaCell({ onDeleteColumn, onDeleteGroup, selectedRowIds, + hasActiveFilter = false, onViewWorkflow, onDragStart, onDragOver, @@ -292,6 +313,7 @@ export function WorkflowGroupMetaCell({ const didDragRef = useRef(false) const selectedCount = selectedRowIds?.length ?? 0 + const runLabels = runMenuLabels(hasActiveFilter) function handleRunAll() { if (groupId) onRunColumn?.(groupId, 'all') @@ -443,11 +465,13 @@ export function WorkflowGroupMetaCell({ {`Run ${selectedCount} selected ${selectedCount === 1 ? 'row' : 'rows'}`} )} - Run all rows - Run empty rows + {runLabels.all} + + {runLabels.incomplete} + {LIMITED_RUN_PRESETS.map((max) => ( handleRunLimited(max)}> - {`Run ${max.toLocaleString()} empty rows`} + {runLabels.limited(max)} ))} @@ -470,6 +494,7 @@ export function WorkflowGroupMetaCell({ onRunColumnLimited={onRunColumn ? handleRunLimited : undefined} onRunColumnSelected={onRunColumn && selectedCount > 0 ? handleRunSelected : undefined} selectedRowCount={selectedCount} + hasActiveFilter={hasActiveFilter} onViewWorkflow={onViewWorkflow ? () => onViewWorkflow(workflowId) : undefined} isPinned={isPinned} onPinToggle={onPinToggle} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx index 7521df83ecb..125418b5f45 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx @@ -503,7 +503,10 @@ export function TableGrid({ rowIds?: string[], limit?: RunLimit ) { - onRunColumn(groupId, runMode, rowIds, limit) + // Table-scoped runs (Run all / Run empty / Run N empty) honor the active + // filter; an explicit rowIds scope (Run selected) already names its rows. + const filter = rowIds ? undefined : (queryOptions.filter ?? undefined) + onRunColumn(groupId, runMode, rowIds, limit, filter) } const handleViewWorkflow = useCallback( @@ -3643,6 +3646,7 @@ export function TableGrid({ onSelectGroup={handleGroupSelect} onOpenConfig={() => handleConfigureWorkflowGroup(g.groupId)} onRunColumn={userPermissions.canEdit ? handleRunColumn : undefined} + hasActiveFilter={Boolean(queryOptions.filter)} selectedRowIds={selectedRowIds} onInsertLeft={ userPermissions.canEdit ? handleInsertColumnLeft : undefined From 11be2a31679151c380f48f6abe4976d986f04ab6 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan <33737564+Sg312@users.noreply.github.com> Date: Mon, 6 Jul 2026 18:58:26 -0700 Subject: [PATCH 31/35] fix(images): fix stale images (#5462) * fix(images): fix stale images * Fix comment --- .../files/components/file-viewer/image-preview.tsx | 6 +++++- apps/sim/next.config.ts | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/image-preview.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/image-preview.tsx index 0bac2180bed..e4ba8033261 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/image-preview.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/image-preview.tsx @@ -7,7 +7,11 @@ import { ZoomablePreview } from './zoomable-preview' export const ImagePreview = memo(function ImagePreview({ file }: { file: WorkspaceFileRecord }) { const source = useFileContentSource() - const serveUrl = source.buildUrl(file.key) + // Version the URL on updatedAt: overwrites keep the same storage key, so an unversioned + // URL would resolve to a previously cached copy instead of the rewritten bytes. + const serveUrl = source.buildUrl(file.key, { + version: Number(new Date(file.updatedAt)) || file.size, + }) return ( diff --git a/apps/sim/next.config.ts b/apps/sim/next.config.ts index 1d42e1c7c22..5f760789c4f 100644 --- a/apps/sim/next.config.ts +++ b/apps/sim/next.config.ts @@ -177,7 +177,7 @@ const nextConfig: NextConfig = { async headers() { return [ { - source: '/:all*(svg|jpg|jpeg|png|gif|ico|webp|avif|woff|woff2|ttf|eot)', + source: '/((?!api/).*\\.(?:svg|jpg|jpeg|png|gif|ico|webp|avif|woff|woff2|ttf|eot))', headers: [ { key: 'Cache-Control', From 017e8ca68fabde10fbbf454c6452f2f1cb8b18e9 Mon Sep 17 00:00:00 2001 From: Waleed Date: Mon, 6 Jul 2026 19:03:11 -0700 Subject: [PATCH 32/35] fix(ses): reject malformed startDate/endDate in list_suppressed_destinations (#5461) startDate/endDate were passed through new Date(...) with no validity check, so a malformed non-empty string became an Invalid Date and was still forwarded to AWS, surfacing as a generic 500. The contract now rejects unparseable date strings with a clear 400. --- .../tools/aws/ses-list-suppressed-destinations.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/apps/sim/lib/api/contracts/tools/aws/ses-list-suppressed-destinations.ts b/apps/sim/lib/api/contracts/tools/aws/ses-list-suppressed-destinations.ts index db8f90d40d6..017130f15e4 100644 --- a/apps/sim/lib/api/contracts/tools/aws/ses-list-suppressed-destinations.ts +++ b/apps/sim/lib/api/contracts/tools/aws/ses-list-suppressed-destinations.ts @@ -17,8 +17,18 @@ const ListSuppressedDestinationsSchema = z.object({ accessKeyId: z.string().min(1, 'AWS access key ID is required'), secretAccessKey: z.string().min(1, 'AWS secret access key is required'), reasons: z.string().nullish(), - startDate: z.string().nullish(), - endDate: z.string().nullish(), + startDate: z + .string() + .nullish() + .refine((v) => !v || !Number.isNaN(new Date(v).getTime()), { + message: 'startDate must be a valid date string', + }), + endDate: z + .string() + .nullish() + .refine((v) => !v || !Number.isNaN(new Date(v).getTime()), { + message: 'endDate must be a valid date string', + }), pageSize: z.number().int().min(1).max(1000).nullish(), nextToken: z.string().nullish(), }) From b686111082d50a9cdb6155c8a09a4ec3983a67c4 Mon Sep 17 00:00:00 2001 From: Waleed Date: Mon, 6 Jul 2026 19:42:36 -0700 Subject: [PATCH 33/35] feat(textract): migrate to AWS SDK, add AnalyzeExpense and AnalyzeID (#5456) * feat(textract): migrate to AWS SDK, add AnalyzeExpense and AnalyzeID - Replace hand-rolled AWS SigV4 signing with @aws-sdk/client-textract, matching sibling AWS integrations (secrets_manager, s3, sts) - Add Analyze Expense operation (invoice/receipt structured extraction) via AnalyzeExpense/StartExpenseAnalysis+GetExpenseAnalysis - Add Analyze Identity Document operation (AnalyzeID) with optional back-of-ID page - Add an operation selector to the textract_v2 block; defaults to the existing Analyze Document behavior for backward compatibility - Add tests for tool body/response mapping and route-level AWS response normalization * fix(textract): forward URL documents and fix ambiguous error status - textract_analyze_expense/textract_analyze_id now fall back to filePath/filePathBack when the document input is a URL string rather than an uploaded file object, so advanced "File reference" URL inputs actually reach the API (Cursor Bugbot) - mapTextractSdkError defaults to 500 (not 400) when the AWS SDK error has no HTTP status, since that implies a server-side/network failure rather than a bad request (Greptile) * fix(textract): stop stale processingMode from hiding ID document fields - Front-document fields (fileUpload/fileReference) are shared across all 3 operations; gate them with a values-aware condition so switching to Analyze Identity Document keeps them visible even if a stale processingMode='async' is left over from a previous operation - S3 URI field now also requires operation !== 'analyze_id', since that operation never supports S3 input * fix(textract): preserve first-page metadata across async pagination pollTextractJob's merge callbacks spread only the latest page, dropping any field (DocumentMetadata, model version) the first page had but a follow-up NextToken page omits. Merge accumulated first so later pages only override fields they actually return. * fix(textract): pass through the real upstream status for filePath fetch failures fetchDocumentBytes hardcoded 400 for any non-OK response from a document URL, masking transient 5xx failures from the document host as client errors and blocking tool-execution retries. Use the actual response status instead. * chore(textract): drop redundant inline comments --- .../textract/analyze-expense/route.test.ts | 81 +++ .../tools/textract/analyze-expense/route.ts | 218 ++++++ .../tools/textract/analyze-id/route.test.ts | 63 ++ .../api/tools/textract/analyze-id/route.ts | 150 +++++ .../sim/app/api/tools/textract/parse/route.ts | 635 +++--------------- .../sim/app/api/tools/textract/shared.test.ts | 165 +++++ apps/sim/app/api/tools/textract/shared.ts | 305 +++++++++ apps/sim/blocks/blocks/textract.ts | 305 +++++++-- .../contracts/tools/media/document-parse.ts | 73 ++ apps/sim/package.json | 1 + apps/sim/tools/registry.ts | 9 +- apps/sim/tools/textract/analyze-expense.ts | 226 +++++++ apps/sim/tools/textract/analyze-id.ts | 169 +++++ apps/sim/tools/textract/index.ts | 2 + apps/sim/tools/textract/textract.test.ts | 239 +++++++ apps/sim/tools/textract/types.ts | 127 +++- bun.lock | 3 + scripts/check-api-validation-contracts.ts | 4 +- 18 files changed, 2168 insertions(+), 607 deletions(-) create mode 100644 apps/sim/app/api/tools/textract/analyze-expense/route.test.ts create mode 100644 apps/sim/app/api/tools/textract/analyze-expense/route.ts create mode 100644 apps/sim/app/api/tools/textract/analyze-id/route.test.ts create mode 100644 apps/sim/app/api/tools/textract/analyze-id/route.ts create mode 100644 apps/sim/app/api/tools/textract/shared.test.ts create mode 100644 apps/sim/app/api/tools/textract/shared.ts create mode 100644 apps/sim/tools/textract/analyze-expense.ts create mode 100644 apps/sim/tools/textract/analyze-id.ts create mode 100644 apps/sim/tools/textract/textract.test.ts diff --git a/apps/sim/app/api/tools/textract/analyze-expense/route.test.ts b/apps/sim/app/api/tools/textract/analyze-expense/route.test.ts new file mode 100644 index 00000000000..76cb459128f --- /dev/null +++ b/apps/sim/app/api/tools/textract/analyze-expense/route.test.ts @@ -0,0 +1,81 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { normalizeExpenseDocuments } from '@/app/api/tools/textract/analyze-expense/route' + +describe('normalizeExpenseDocuments', () => { + it('maps a documented AWS AnalyzeExpense response shape', () => { + const result = normalizeExpenseDocuments([ + { + ExpenseIndex: 1, + SummaryFields: [ + { + Type: { Text: 'VENDOR_NAME', Confidence: 98.1 }, + ValueDetection: { Text: 'Acme Corp', Confidence: 97.5 }, + LabelDetection: { Text: 'Vendor', Confidence: 90 }, + PageNumber: 1, + Currency: { Code: 'USD', Confidence: 95 }, + GroupProperties: [{ Id: 'g1', Types: ['VENDOR'] }], + }, + ], + LineItemGroups: [ + { + LineItemGroupIndex: 1, + LineItems: [ + { + LineItemExpenseFields: [ + { + Type: { Text: 'ITEM', Confidence: 91 }, + ValueDetection: { Text: 'Widget', Confidence: 93 }, + }, + ], + }, + ], + }, + ], + }, + ]) + + expect(result).toEqual([ + { + expenseIndex: 1, + summaryFields: [ + { + type: { text: 'VENDOR_NAME', confidence: 98.1 }, + valueDetection: { text: 'Acme Corp', confidence: 97.5 }, + labelDetection: { text: 'Vendor', confidence: 90 }, + pageNumber: 1, + currency: { code: 'USD', confidence: 95 }, + groupProperties: [{ id: 'g1', types: ['VENDOR'] }], + }, + ], + lineItemGroups: [ + { + lineItemGroupIndex: 1, + lineItems: [ + { + lineItemExpenseFields: [ + { + type: { text: 'ITEM', confidence: 91 }, + valueDetection: { text: 'Widget', confidence: 93 }, + labelDetection: undefined, + pageNumber: undefined, + currency: undefined, + groupProperties: undefined, + }, + ], + }, + ], + }, + ], + }, + ]) + }) + + it('defaults missing arrays to empty arrays', () => { + expect(normalizeExpenseDocuments([{ ExpenseIndex: 0 }])).toEqual([ + { expenseIndex: 0, summaryFields: [], lineItemGroups: [] }, + ]) + }) +}) diff --git a/apps/sim/app/api/tools/textract/analyze-expense/route.ts b/apps/sim/app/api/tools/textract/analyze-expense/route.ts new file mode 100644 index 00000000000..c9009f89c2f --- /dev/null +++ b/apps/sim/app/api/tools/textract/analyze-expense/route.ts @@ -0,0 +1,218 @@ +import { + AnalyzeExpenseCommand, + type ExpenseDocument, + GetExpenseAnalysisCommand, + StartExpenseAnalysisCommand, + TextractClient, +} from '@aws-sdk/client-textract' +import { createLogger } from '@sim/logger' +import type { NextRequest } from 'next/server' +import { NextResponse } from 'next/server' +import { textractAnalyzeExpenseContract } from '@/lib/api/contracts/tools/media/document-parse' +import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + mapTextractSdkError, + parseS3Uri, + pollTextractJob, + resolveDocumentInput, + textractErrorResponse, +} from '@/app/api/tools/textract/shared' + +export const dynamic = 'force-dynamic' +/** Mirrors maxDuration in ../parse/route.ts — see that file's TSDoc for details. */ +export const maxDuration = 5400 + +const logger = createLogger('TextractAnalyzeExpenseAPI') + +/** Response shape shared by AnalyzeExpense and its async Get* counterpart. */ +interface TextractExpenseResult { + JobStatus?: string + StatusMessage?: string + NextToken?: string + ExpenseDocuments?: ExpenseDocument[] + DocumentMetadata?: { Pages?: number } + AnalyzeExpenseModelVersion?: string +} + +export function normalizeExpenseField(field: { + Type?: { Text?: string; Confidence?: number } + ValueDetection?: { Text?: string; Confidence?: number } + LabelDetection?: { Text?: string; Confidence?: number } + PageNumber?: number + Currency?: { Code?: string; Confidence?: number } + GroupProperties?: { Id?: string; Types?: string[] }[] +}) { + return { + type: { text: field.Type?.Text, confidence: field.Type?.Confidence }, + valueDetection: { + text: field.ValueDetection?.Text, + confidence: field.ValueDetection?.Confidence, + }, + labelDetection: field.LabelDetection + ? { text: field.LabelDetection.Text, confidence: field.LabelDetection.Confidence } + : undefined, + pageNumber: field.PageNumber, + currency: field.Currency + ? { code: field.Currency.Code, confidence: field.Currency.Confidence } + : undefined, + groupProperties: field.GroupProperties?.map((group) => ({ + id: group.Id ?? '', + types: group.Types ?? [], + })), + } +} + +export function normalizeExpenseDocuments(documents: ExpenseDocument[]) { + return documents.map((doc) => ({ + expenseIndex: doc.ExpenseIndex, + summaryFields: (doc.SummaryFields ?? []).map(normalizeExpenseField), + lineItemGroups: (doc.LineItemGroups ?? []).map((group) => ({ + lineItemGroupIndex: group.LineItemGroupIndex, + lineItems: (group.LineItems ?? []).map((item) => ({ + lineItemExpenseFields: (item.LineItemExpenseFields ?? []).map(normalizeExpenseField), + })), + })), + })) +} + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success || !authResult.userId) { + logger.warn(`[${requestId}] Unauthorized Textract analyze-expense attempt`, { + error: authResult.error || 'Missing userId', + }) + return NextResponse.json( + { success: false, error: authResult.error || 'Unauthorized' }, + { status: 401 } + ) + } + const userId = authResult.userId + + const parsed = await parseRequest( + textractAnalyzeExpenseContract, + request, + {}, + { + validationErrorResponse: (error) => { + logger.warn(`[${requestId}] Invalid request data`, { errors: error.issues }) + return NextResponse.json( + { + success: false, + error: getValidationErrorMessage(error, 'Invalid request data'), + details: error.issues, + }, + { status: 400 } + ) + }, + } + ) + if (!parsed.success) return parsed.response + + const validatedData = parsed.data.body + const processingMode = validatedData.processingMode || 'sync' + + logger.info(`[${requestId}] Textract analyze-expense request`, { + processingMode, + hasFile: Boolean(validatedData.file), + hasS3Uri: Boolean(validatedData.s3Uri), + userId, + }) + + const client = new TextractClient({ + region: validatedData.region, + credentials: { + accessKeyId: validatedData.accessKeyId, + secretAccessKey: validatedData.secretAccessKey, + }, + }) + + if (processingMode === 'async') { + if (!validatedData.s3Uri) { + return NextResponse.json( + { + success: false, + error: 'S3 URI is required for multi-page processing (s3://bucket/key)', + }, + { status: 400 } + ) + } + + const { bucket, key } = parseS3Uri(validatedData.s3Uri) + logger.info(`[${requestId}] Starting async Textract expense analysis job`, { + s3Bucket: bucket, + s3Key: key, + }) + + const { JobId: jobId } = await client.send( + new StartExpenseAnalysisCommand({ + DocumentLocation: { S3Object: { Bucket: bucket, Name: key } }, + }) + ) + if (!jobId) { + throw new Error('Failed to start Textract expense analysis job: No JobId returned') + } + logger.info(`[${requestId}] Async expense analysis job started`, { jobId }) + + const result = await pollTextractJob( + requestId, + logger, + (nextToken) => + client.send(new GetExpenseAnalysisCommand({ JobId: jobId, NextToken: nextToken })), + (accumulated, page) => ({ + ...accumulated, + ...page, + ExpenseDocuments: [ + ...(accumulated.ExpenseDocuments ?? []), + ...(page.ExpenseDocuments ?? []), + ], + }) + ) + + return NextResponse.json({ + success: true, + output: { + expenseDocuments: normalizeExpenseDocuments(result.ExpenseDocuments ?? []), + documentMetadata: { pages: result.DocumentMetadata?.Pages ?? 0 }, + modelVersion: result.AnalyzeExpenseModelVersion, + }, + }) + } + + const resolved = await resolveDocumentInput( + { file: validatedData.file, filePath: validatedData.filePath }, + userId, + requestId, + logger + ) + if (!resolved.ok) return resolved.response + const { bytes, isPdf } = resolved.document + + let result: TextractExpenseResult + try { + result = await client.send(new AnalyzeExpenseCommand({ Document: { Bytes: bytes } })) + } catch (error) { + throw mapTextractSdkError(error, isPdf) + } + + logger.info(`[${requestId}] Textract analyze-expense successful`, { + pageCount: result.DocumentMetadata?.Pages ?? 0, + expenseDocumentCount: result.ExpenseDocuments?.length ?? 0, + }) + + return NextResponse.json({ + success: true, + output: { + expenseDocuments: normalizeExpenseDocuments(result.ExpenseDocuments ?? []), + documentMetadata: { pages: result.DocumentMetadata?.Pages ?? 0 }, + }, + }) + } catch (error) { + return textractErrorResponse(error, requestId, logger) + } +}) diff --git a/apps/sim/app/api/tools/textract/analyze-id/route.test.ts b/apps/sim/app/api/tools/textract/analyze-id/route.test.ts new file mode 100644 index 00000000000..5798f2441fd --- /dev/null +++ b/apps/sim/app/api/tools/textract/analyze-id/route.test.ts @@ -0,0 +1,63 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { normalizeIdentityDocuments } from '@/app/api/tools/textract/analyze-id/route' + +describe('normalizeIdentityDocuments', () => { + it('maps a documented AWS AnalyzeID response shape', () => { + const result = normalizeIdentityDocuments([ + { + DocumentIndex: 1, + IdentityDocumentFields: [ + { + Type: { Text: 'FIRST_NAME', Confidence: 99 }, + ValueDetection: { Text: 'Jane', Confidence: 98 }, + }, + { + Type: { + Text: 'DATE_OF_BIRTH', + Confidence: 97, + NormalizedValue: { Value: '1990-01-01', ValueType: 'Date' }, + }, + ValueDetection: { + Text: '01/01/1990', + Confidence: 96, + NormalizedValue: { Value: '1990-01-01T00:00:00', ValueType: 'Date' }, + }, + }, + ], + }, + ]) + + expect(result).toEqual([ + { + documentIndex: 1, + identityDocumentFields: [ + { + type: { text: 'FIRST_NAME', confidence: 99, normalizedValue: undefined }, + valueDetection: { text: 'Jane', confidence: 98, normalizedValue: undefined }, + }, + { + type: { + text: 'DATE_OF_BIRTH', + confidence: 97, + normalizedValue: { value: '1990-01-01', valueType: 'Date' }, + }, + valueDetection: { + text: '01/01/1990', + confidence: 96, + normalizedValue: { value: '1990-01-01T00:00:00', valueType: 'Date' }, + }, + }, + ], + }, + ]) + }) + + it('defaults missing fields to an empty array', () => { + expect(normalizeIdentityDocuments([{ DocumentIndex: 0 }])).toEqual([ + { documentIndex: 0, identityDocumentFields: [] }, + ]) + }) +}) diff --git a/apps/sim/app/api/tools/textract/analyze-id/route.ts b/apps/sim/app/api/tools/textract/analyze-id/route.ts new file mode 100644 index 00000000000..9edf2ce97ed --- /dev/null +++ b/apps/sim/app/api/tools/textract/analyze-id/route.ts @@ -0,0 +1,150 @@ +import { AnalyzeIDCommand, type IdentityDocument, TextractClient } from '@aws-sdk/client-textract' +import { createLogger } from '@sim/logger' +import type { NextRequest } from 'next/server' +import { NextResponse } from 'next/server' +import { textractAnalyzeIdContract } from '@/lib/api/contracts/tools/media/document-parse' +import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + mapTextractSdkError, + resolveDocumentInput, + textractErrorResponse, +} from '@/app/api/tools/textract/shared' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('TextractAnalyzeIdAPI') + +export function normalizeIdentityDocuments(documents: IdentityDocument[]) { + return documents.map((doc) => ({ + documentIndex: doc.DocumentIndex, + identityDocumentFields: (doc.IdentityDocumentFields ?? []).map((field) => ({ + type: { + text: field.Type?.Text, + confidence: field.Type?.Confidence, + normalizedValue: field.Type?.NormalizedValue + ? { + value: field.Type.NormalizedValue.Value, + valueType: field.Type.NormalizedValue.ValueType, + } + : undefined, + }, + valueDetection: { + text: field.ValueDetection?.Text, + confidence: field.ValueDetection?.Confidence, + normalizedValue: field.ValueDetection?.NormalizedValue + ? { + value: field.ValueDetection.NormalizedValue.Value, + valueType: field.ValueDetection.NormalizedValue.ValueType, + } + : undefined, + }, + })), + })) +} + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success || !authResult.userId) { + logger.warn(`[${requestId}] Unauthorized Textract analyze-id attempt`, { + error: authResult.error || 'Missing userId', + }) + return NextResponse.json( + { success: false, error: authResult.error || 'Unauthorized' }, + { status: 401 } + ) + } + const userId = authResult.userId + + const parsed = await parseRequest( + textractAnalyzeIdContract, + request, + {}, + { + validationErrorResponse: (error) => { + logger.warn(`[${requestId}] Invalid request data`, { errors: error.issues }) + return NextResponse.json( + { + success: false, + error: getValidationErrorMessage(error, 'Invalid request data'), + details: error.issues, + }, + { status: 400 } + ) + }, + } + ) + if (!parsed.success) return parsed.response + + const validatedData = parsed.data.body + + logger.info(`[${requestId}] Textract analyze-id request`, { + hasFile: Boolean(validatedData.file), + hasBackFile: Boolean(validatedData.fileBack || validatedData.filePathBack), + userId, + }) + + const front = await resolveDocumentInput( + { file: validatedData.file, filePath: validatedData.filePath }, + userId, + requestId, + logger + ) + if (!front.ok) return front.response + + const documentPages = [{ Bytes: front.document.bytes }] + let isPdf = front.document.isPdf + + if (validatedData.fileBack || validatedData.filePathBack) { + const back = await resolveDocumentInput( + { file: validatedData.fileBack, filePath: validatedData.filePathBack }, + userId, + requestId, + logger + ) + if (!back.ok) return back.response + documentPages.push({ Bytes: back.document.bytes }) + isPdf = isPdf || back.document.isPdf + } + + const client = new TextractClient({ + region: validatedData.region, + credentials: { + accessKeyId: validatedData.accessKeyId, + secretAccessKey: validatedData.secretAccessKey, + }, + }) + + let result: { + AnalyzeIDModelVersion?: string + DocumentMetadata?: { Pages?: number } + IdentityDocuments?: IdentityDocument[] + } + try { + result = await client.send(new AnalyzeIDCommand({ DocumentPages: documentPages })) + } catch (error) { + throw mapTextractSdkError(error, isPdf, { hasAsyncMode: false }) + } + + logger.info(`[${requestId}] Textract analyze-id successful`, { + pageCount: result.DocumentMetadata?.Pages ?? 0, + documentCount: result.IdentityDocuments?.length ?? 0, + }) + + return NextResponse.json({ + success: true, + output: { + identityDocuments: normalizeIdentityDocuments(result.IdentityDocuments ?? []), + documentMetadata: { pages: result.DocumentMetadata?.Pages ?? 0 }, + modelVersion: result.AnalyzeIDModelVersion, + }, + }) + } catch (error) { + return textractErrorResponse(error, requestId, logger) + } +}) diff --git a/apps/sim/app/api/tools/textract/parse/route.ts b/apps/sim/app/api/tools/textract/parse/route.ts index 82eb65ff830..ec3a6d8e35f 100644 --- a/apps/sim/app/api/tools/textract/parse/route.ts +++ b/apps/sim/app/api/tools/textract/parse/route.ts @@ -1,26 +1,28 @@ -import crypto from 'crypto' +import { + AnalyzeDocumentCommand, + DetectDocumentTextCommand, + type FeatureType, + GetDocumentAnalysisCommand, + GetDocumentTextDetectionCommand, + StartDocumentAnalysisCommand, + StartDocumentTextDetectionCommand, + TextractClient, +} from '@aws-sdk/client-textract' import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import { sleep } from '@sim/utils/helpers' -import { type NextRequest, NextResponse } from 'next/server' +import type { NextRequest } from 'next/server' +import { NextResponse } from 'next/server' import { textractParseContract } from '@/lib/api/contracts/tools/media/document-parse' import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' -import { getMaxExecutionTimeout } from '@/lib/core/execution-limits' -import { validateS3BucketName } from '@/lib/core/security/input-validation' -import { - secureFetchWithPinnedIP, - validateUrlWithDNS, -} from '@/lib/core/security/input-validation.server' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { isInternalFileUrl, processSingleFileToUserFile } from '@/lib/uploads/utils/file-utils' import { - downloadServableFileFromStorage, - resolveInternalFileUrl, -} from '@/lib/uploads/utils/file-utils.server' -import { docNotReadyResponse } from '@/lib/uploads/utils/servable-file-response' -import { assertToolFileAccess } from '@/app/api/files/authorization' + mapTextractSdkError, + parseS3Uri, + pollTextractJob, + resolveDocumentInput, + textractErrorResponse, +} from '@/app/api/tools/textract/shared' export const dynamic = 'force-dynamic' /** @@ -32,244 +34,15 @@ export const maxDuration = 5400 const logger = createLogger('TextractParseAPI') -function getSignatureKey( - key: string, - dateStamp: string, - regionName: string, - serviceName: string -): Buffer { - const kDate = crypto.createHmac('sha256', `AWS4${key}`).update(dateStamp).digest() - const kRegion = crypto.createHmac('sha256', kDate).update(regionName).digest() - const kService = crypto.createHmac('sha256', kRegion).update(serviceName).digest() - const kSigning = crypto.createHmac('sha256', kService).update('aws4_request').digest() - return kSigning -} - -function signAwsRequest( - method: string, - host: string, - uri: string, - body: string, - accessKeyId: string, - secretAccessKey: string, - region: string, - service: string, - amzTarget: string -): Record { - const date = new Date() - const amzDate = date.toISOString().replace(/[:-]|\.\d{3}/g, '') - const dateStamp = amzDate.slice(0, 8) - - const payloadHash = crypto.createHash('sha256').update(body).digest('hex') - - const canonicalHeaders = - `content-type:application/x-amz-json-1.1\n` + - `host:${host}\n` + - `x-amz-date:${amzDate}\n` + - `x-amz-target:${amzTarget}\n` - - const signedHeaders = 'content-type;host;x-amz-date;x-amz-target' - - const canonicalRequest = `${method}\n${uri}\n\n${canonicalHeaders}\n${signedHeaders}\n${payloadHash}` - - const algorithm = 'AWS4-HMAC-SHA256' - const credentialScope = `${dateStamp}/${region}/${service}/aws4_request` - const stringToSign = `${algorithm}\n${amzDate}\n${credentialScope}\n${crypto.createHash('sha256').update(canonicalRequest).digest('hex')}` - - const signingKey = getSignatureKey(secretAccessKey, dateStamp, region, service) - const signature = crypto.createHmac('sha256', signingKey).update(stringToSign).digest('hex') - - const authorizationHeader = `${algorithm} Credential=${accessKeyId}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}` - - return { - 'Content-Type': 'application/x-amz-json-1.1', - Host: host, - 'X-Amz-Date': amzDate, - 'X-Amz-Target': amzTarget, - Authorization: authorizationHeader, - } -} - -async function fetchDocumentBytes(url: string): Promise<{ bytes: string; contentType: string }> { - const urlValidation = await validateUrlWithDNS(url, 'Document URL') - if (!urlValidation.isValid) { - throw new Error(urlValidation.error || 'Invalid document URL') - } - - const response = await secureFetchWithPinnedIP(url, urlValidation.resolvedIP!, { - method: 'GET', - }) - if (!response.ok) { - await response.text().catch(() => {}) - throw new Error(`Failed to fetch document: ${response.statusText}`) - } - - const arrayBuffer = await response.arrayBuffer() - const bytes = Buffer.from(arrayBuffer).toString('base64') - const contentType = response.headers.get('content-type') || 'application/octet-stream' - - return { bytes, contentType } -} - -function parseS3Uri(s3Uri: string): { bucket: string; key: string } { - const match = s3Uri.match(/^s3:\/\/([^/]+)\/(.+)$/) - if (!match) { - throw new Error( - `Invalid S3 URI format: ${s3Uri}. Expected format: s3://bucket-name/path/to/object` - ) - } - - const bucket = match[1] - const key = match[2] - - const bucketValidation = validateS3BucketName(bucket, 'S3 bucket name') - if (!bucketValidation.isValid) { - throw new Error(bucketValidation.error) - } - - if (key.includes('..') || key.startsWith('/')) { - throw new Error('S3 key contains invalid path traversal sequences') - } - - return { bucket, key } -} - -async function callTextractAsync( - host: string, - amzTarget: string, - body: Record, - accessKeyId: string, - secretAccessKey: string, - region: string -): Promise> { - const bodyString = JSON.stringify(body) - const headers = signAwsRequest( - 'POST', - host, - '/', - bodyString, - accessKeyId, - secretAccessKey, - region, - 'textract', - amzTarget - ) - - const response = await fetch(`https://${host}/`, { - method: 'POST', - headers, - body: bodyString, - }) - - if (!response.ok) { - const errorText = await response.text() - let errorMessage = `Textract API error: ${response.statusText}` - try { - const errorJson = JSON.parse(errorText) - if (errorJson.Message) { - errorMessage = errorJson.Message - } else if (errorJson.__type) { - errorMessage = `${errorJson.__type}: ${errorJson.message || errorText}` - } - } catch { - // Use default error message - } - throw new Error(errorMessage) - } - - return response.json() -} - -async function pollForJobCompletion( - host: string, - jobId: string, - accessKeyId: string, - secretAccessKey: string, - region: string, - useAnalyzeDocument: boolean, - requestId: string -): Promise> { - const pollIntervalMs = 5000 - const maxPollTimeMs = getMaxExecutionTimeout() - const maxAttempts = Math.ceil(maxPollTimeMs / pollIntervalMs) - - const getTarget = useAnalyzeDocument - ? 'Textract.GetDocumentAnalysis' - : 'Textract.GetDocumentTextDetection' - - for (let attempt = 0; attempt < maxAttempts; attempt++) { - const result = await callTextractAsync( - host, - getTarget, - { JobId: jobId }, - accessKeyId, - secretAccessKey, - region - ) - - const jobStatus = result.JobStatus as string - - if (jobStatus === 'SUCCEEDED') { - logger.info(`[${requestId}] Async job completed successfully after ${attempt + 1} polls`) - - let allBlocks = (result.Blocks as unknown[]) || [] - let nextToken = result.NextToken as string | undefined - - while (nextToken) { - const nextResult = await callTextractAsync( - host, - getTarget, - { JobId: jobId, NextToken: nextToken }, - accessKeyId, - secretAccessKey, - region - ) - allBlocks = allBlocks.concat((nextResult.Blocks as unknown[]) || []) - nextToken = nextResult.NextToken as string | undefined - } - - return { - ...result, - Blocks: allBlocks, - } - } - - if (jobStatus === 'FAILED') { - throw new Error(`Textract job failed: ${result.StatusMessage || 'Unknown error'}`) - } - - if (jobStatus === 'PARTIAL_SUCCESS') { - logger.warn(`[${requestId}] Job completed with partial success: ${result.StatusMessage}`) - - let allBlocks = (result.Blocks as unknown[]) || [] - let nextToken = result.NextToken as string | undefined - - while (nextToken) { - const nextResult = await callTextractAsync( - host, - getTarget, - { JobId: jobId, NextToken: nextToken }, - accessKeyId, - secretAccessKey, - region - ) - allBlocks = allBlocks.concat((nextResult.Blocks as unknown[]) || []) - nextToken = nextResult.NextToken as string | undefined - } - - return { - ...result, - Blocks: allBlocks, - } - } - - logger.info(`[${requestId}] Job status: ${jobStatus}, attempt ${attempt + 1}/${maxAttempts}`) - await sleep(pollIntervalMs) - } - - throw new Error( - `Timeout waiting for Textract job to complete (max ${maxPollTimeMs / 1000} seconds)` - ) +/** Response shape shared by AnalyzeDocument/DetectDocumentText and their async Get* counterparts. */ +interface TextractDocumentResult { + JobStatus?: string + StatusMessage?: string + NextToken?: string + Blocks?: unknown[] + DocumentMetadata?: { Pages?: number } + AnalyzeDocumentModelVersion?: string + DetectDocumentTextModelVersion?: string } export const POST = withRouteHandler(async (request: NextRequest) => { @@ -277,20 +50,15 @@ export const POST = withRouteHandler(async (request: NextRequest) => { try { const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success || !authResult.userId) { logger.warn(`[${requestId}] Unauthorized Textract parse attempt`, { error: authResult.error || 'Missing userId', }) return NextResponse.json( - { - success: false, - error: authResult.error || 'Unauthorized', - }, + { success: false, error: authResult.error || 'Unauthorized' }, { status: 401 } ) } - const userId = authResult.userId const parsed = await parseRequest( @@ -314,11 +82,19 @@ export const POST = withRouteHandler(async (request: NextRequest) => { if (!parsed.success) return parsed.response const validatedData = parsed.data.body - const processingMode = validatedData.processingMode || 'sync' - const featureTypes = validatedData.featureTypes ?? [] + const featureTypes = (validatedData.featureTypes ?? []) as FeatureType[] const useAnalyzeDocument = featureTypes.length > 0 - const host = `textract.${validatedData.region}.amazonaws.com` + const queriesConfig = + validatedData.queries && validatedData.queries.length > 0 && featureTypes.includes('QUERIES') + ? { + Queries: validatedData.queries.map((q) => ({ + Text: q.Text, + Alias: q.Alias, + Pages: q.Pages, + })), + } + : undefined logger.info(`[${requestId}] Textract parse request`, { processingMode, @@ -328,6 +104,14 @@ export const POST = withRouteHandler(async (request: NextRequest) => { userId, }) + const client = new TextractClient({ + region: validatedData.region, + credentials: { + accessKeyId: validatedData.accessKeyId, + secretAccessKey: validatedData.secretAccessKey, + }, + }) + if (processingMode === 'async') { if (!validatedData.s3Uri) { return NextResponse.json( @@ -339,299 +123,98 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } - const { bucket: s3Bucket, key: s3Key } = parseS3Uri(validatedData.s3Uri) - - logger.info(`[${requestId}] Starting async Textract job`, { s3Bucket, s3Key }) - - const startTarget = useAnalyzeDocument - ? 'Textract.StartDocumentAnalysis' - : 'Textract.StartDocumentTextDetection' - - const startBody: Record = { - DocumentLocation: { - S3Object: { - Bucket: s3Bucket, - Name: s3Key, - }, - }, - } - - if (useAnalyzeDocument) { - startBody.FeatureTypes = featureTypes + const { bucket, key } = parseS3Uri(validatedData.s3Uri) + logger.info(`[${requestId}] Starting async Textract job`, { s3Bucket: bucket, s3Key: key }) - if ( - validatedData.queries && - validatedData.queries.length > 0 && - featureTypes.includes('QUERIES') - ) { - startBody.QueriesConfig = { - Queries: validatedData.queries.map((q) => ({ - Text: q.Text, - Alias: q.Alias, - Pages: q.Pages, - })), - } - } - } - - const startResult = await callTextractAsync( - host, - startTarget, - startBody, - validatedData.accessKeyId, - validatedData.secretAccessKey, - validatedData.region - ) - - const jobId = startResult.JobId as string + const { JobId: jobId } = useAnalyzeDocument + ? await client.send( + new StartDocumentAnalysisCommand({ + DocumentLocation: { S3Object: { Bucket: bucket, Name: key } }, + FeatureTypes: featureTypes, + QueriesConfig: queriesConfig, + }) + ) + : await client.send( + new StartDocumentTextDetectionCommand({ + DocumentLocation: { S3Object: { Bucket: bucket, Name: key } }, + }) + ) if (!jobId) { throw new Error('Failed to start Textract job: No JobId returned') } - logger.info(`[${requestId}] Async job started`, { jobId }) - const textractData = await pollForJobCompletion( - host, - jobId, - validatedData.accessKeyId, - validatedData.secretAccessKey, - validatedData.region, - useAnalyzeDocument, - requestId + const result = await pollTextractJob( + requestId, + logger, + async (nextToken) => + useAnalyzeDocument + ? await client.send( + new GetDocumentAnalysisCommand({ JobId: jobId, NextToken: nextToken }) + ) + : await client.send( + new GetDocumentTextDetectionCommand({ JobId: jobId, NextToken: nextToken }) + ), + (accumulated, page) => ({ + ...accumulated, + ...page, + Blocks: [...(accumulated.Blocks ?? []), ...(page.Blocks ?? [])], + }) ) logger.info(`[${requestId}] Textract async parse successful`, { - pageCount: (textractData.DocumentMetadata as { Pages?: number })?.Pages ?? 0, - blockCount: (textractData.Blocks as unknown[])?.length ?? 0, + pageCount: result.DocumentMetadata?.Pages ?? 0, + blockCount: result.Blocks?.length ?? 0, }) return NextResponse.json({ success: true, output: { - blocks: textractData.Blocks ?? [], - documentMetadata: { - pages: (textractData.DocumentMetadata as { Pages?: number })?.Pages ?? 0, - }, - modelVersion: (textractData.AnalyzeDocumentModelVersion ?? - textractData.DetectDocumentTextModelVersion) as string | undefined, + blocks: result.Blocks ?? [], + documentMetadata: { pages: result.DocumentMetadata?.Pages ?? 0 }, + modelVersion: result.AnalyzeDocumentModelVersion ?? result.DetectDocumentTextModelVersion, }, }) } - let bytes = '' - let contentType = 'application/octet-stream' - let isPdf = false - - if (validatedData.file) { - let userFile - try { - userFile = processSingleFileToUserFile(validatedData.file, requestId, logger) - } catch (error) { - return NextResponse.json( - { - success: false, - error: getErrorMessage(error, 'Failed to process file'), - }, - { status: 400 } - ) - } - - const denied = await assertToolFileAccess(userFile.key, userId, requestId, logger) - if (denied) return denied - const { buffer, contentType: resolvedContentType } = await downloadServableFileFromStorage( - userFile, - requestId, - logger - ) - bytes = buffer.toString('base64') - contentType = resolvedContentType || userFile.type || 'application/octet-stream' - isPdf = contentType.includes('pdf') || userFile.name?.toLowerCase().endsWith('.pdf') - } else if (validatedData.filePath) { - let fileUrl = validatedData.filePath - - const isInternalFilePath = isInternalFileUrl(fileUrl) - - if (isInternalFilePath) { - const resolution = await resolveInternalFileUrl(fileUrl, userId, requestId, logger) - if (resolution.error) { - return NextResponse.json( - { - success: false, - error: resolution.error.message, - }, - { status: resolution.error.status } - ) - } - fileUrl = resolution.fileUrl || fileUrl - } else if (fileUrl.startsWith('/')) { - logger.warn(`[${requestId}] Invalid internal path`, { - userId, - path: fileUrl.substring(0, 50), - }) - return NextResponse.json( - { - success: false, - error: 'Invalid file path. Only uploaded files are supported for internal paths.', - }, - { status: 400 } - ) - } else { - const urlValidation = await validateUrlWithDNS(fileUrl, 'Document URL') - if (!urlValidation.isValid) { - logger.warn(`[${requestId}] SSRF attempt blocked`, { - userId, - url: fileUrl.substring(0, 100), - error: urlValidation.error, - }) - return NextResponse.json( - { - success: false, - error: urlValidation.error, - }, - { status: 400 } - ) - } - } - - const fetched = await fetchDocumentBytes(fileUrl) - bytes = fetched.bytes - contentType = fetched.contentType - isPdf = contentType.includes('pdf') || fileUrl.toLowerCase().endsWith('.pdf') - } else { - return NextResponse.json( - { - success: false, - error: 'File input is required for single-page processing', - }, - { status: 400 } - ) - } - - const uri = '/' - - let textractBody: Record - let amzTarget: string - - if (useAnalyzeDocument) { - amzTarget = 'Textract.AnalyzeDocument' - textractBody = { - Document: { - Bytes: bytes, - }, - FeatureTypes: featureTypes, - } - - if ( - validatedData.queries && - validatedData.queries.length > 0 && - featureTypes.includes('QUERIES') - ) { - textractBody.QueriesConfig = { - Queries: validatedData.queries.map((q) => ({ - Text: q.Text, - Alias: q.Alias, - Pages: q.Pages, - })), - } - } - } else { - amzTarget = 'Textract.DetectDocumentText' - textractBody = { - Document: { - Bytes: bytes, - }, - } - } - - const bodyString = JSON.stringify(textractBody) - - const headers = signAwsRequest( - 'POST', - host, - uri, - bodyString, - validatedData.accessKeyId, - validatedData.secretAccessKey, - validatedData.region, - 'textract', - amzTarget + const resolved = await resolveDocumentInput( + { file: validatedData.file, filePath: validatedData.filePath }, + userId, + requestId, + logger ) + if (!resolved.ok) return resolved.response + const { bytes, isPdf } = resolved.document - const textractResponse = await fetch(`https://${host}${uri}`, { - method: 'POST', - headers, - body: bodyString, - }) - - if (!textractResponse.ok) { - const errorText = await textractResponse.text() - logger.error(`[${requestId}] Textract API error:`, errorText) - - let errorMessage = `Textract API error: ${textractResponse.statusText}` - let isUnsupportedFormat = false - try { - const errorJson = JSON.parse(errorText) - if (errorJson.Message) { - errorMessage = errorJson.Message - } else if (errorJson.__type) { - errorMessage = `${errorJson.__type}: ${errorJson.message || errorText}` - } - // Check for unsupported document format error - isUnsupportedFormat = - errorJson.__type === 'UnsupportedDocumentException' || - errorJson.Message?.toLowerCase().includes('unsupported document') || - errorText.toLowerCase().includes('unsupported document') - } catch { - isUnsupportedFormat = errorText.toLowerCase().includes('unsupported document') - } - - // Provide helpful message for unsupported format (likely multi-page PDF) - if (isUnsupportedFormat && isPdf) { - errorMessage = - 'This document format is not supported in Single Page mode. If this is a multi-page PDF, please use "Multi-Page (PDF, TIFF via S3)" mode instead, which requires uploading your document to S3 first. Single Page mode only supports JPEG, PNG, and single-page PDF files.' - } - - return NextResponse.json( - { - success: false, - error: errorMessage, - }, - { status: textractResponse.status } - ) + let result: TextractDocumentResult + try { + result = useAnalyzeDocument + ? await client.send( + new AnalyzeDocumentCommand({ + Document: { Bytes: bytes }, + FeatureTypes: featureTypes, + QueriesConfig: queriesConfig, + }) + ) + : await client.send(new DetectDocumentTextCommand({ Document: { Bytes: bytes } })) + } catch (error) { + throw mapTextractSdkError(error, isPdf) } - const textractData = await textractResponse.json() - logger.info(`[${requestId}] Textract parse successful`, { - pageCount: textractData.DocumentMetadata?.Pages ?? 0, - blockCount: textractData.Blocks?.length ?? 0, + pageCount: result.DocumentMetadata?.Pages ?? 0, + blockCount: result.Blocks?.length ?? 0, }) return NextResponse.json({ success: true, output: { - blocks: textractData.Blocks ?? [], - documentMetadata: { - pages: textractData.DocumentMetadata?.Pages ?? 0, - }, - modelVersion: - textractData.AnalyzeDocumentModelVersion ?? - textractData.DetectDocumentTextModelVersion ?? - undefined, + blocks: result.Blocks ?? [], + documentMetadata: { pages: result.DocumentMetadata?.Pages ?? 0 }, + modelVersion: result.AnalyzeDocumentModelVersion ?? result.DetectDocumentTextModelVersion, }, }) } catch (error) { - const notReady = docNotReadyResponse(error) - if (notReady) return notReady - - logger.error(`[${requestId}] Error in Textract parse:`, error) - - return NextResponse.json( - { - success: false, - error: getErrorMessage(error, 'Internal server error'), - }, - { status: 500 } - ) + return textractErrorResponse(error, requestId, logger) } }) diff --git a/apps/sim/app/api/tools/textract/shared.test.ts b/apps/sim/app/api/tools/textract/shared.test.ts new file mode 100644 index 00000000000..b95b6753453 --- /dev/null +++ b/apps/sim/app/api/tools/textract/shared.test.ts @@ -0,0 +1,165 @@ +/** + * @vitest-environment node + */ +import { createLogger } from '@sim/logger' +import { describe, expect, it } from 'vitest' +import { + mapTextractSdkError, + parseS3Uri, + pollTextractJob, + TextractRouteError, +} from '@/app/api/tools/textract/shared' + +const logger = createLogger('TextractSharedTest') + +describe('parseS3Uri', () => { + it('parses a valid s3 URI', () => { + expect(parseS3Uri('s3://my-bucket/path/to/doc.pdf')).toEqual({ + bucket: 'my-bucket', + key: 'path/to/doc.pdf', + }) + }) + + it('rejects a malformed URI', () => { + expect(() => parseS3Uri('not-an-s3-uri')).toThrow(TextractRouteError) + }) + + it('rejects path traversal in the key', () => { + expect(() => parseS3Uri('s3://my-bucket/../secrets.pdf')).toThrow('path traversal') + }) +}) + +describe('mapTextractSdkError', () => { + it('gives a friendly hint for unsupported PDFs in single-page mode', () => { + const mapped = mapTextractSdkError( + { name: 'UnsupportedDocumentException', message: 'Unsupported document' }, + true + ) + expect(mapped.status).toBe(400) + expect(mapped.message).toContain('Multi-Page (PDF, TIFF via S3)') + }) + + it('omits the multi-page hint for operations without an async mode', () => { + const mapped = mapTextractSdkError( + { name: 'UnsupportedDocumentException', message: 'Unsupported document' }, + true, + { hasAsyncMode: false } + ) + expect(mapped.message).not.toContain('Multi-Page') + expect(mapped.message).toContain('Only JPEG, PNG, and single-page PDF files are supported') + }) + + it('does not rewrite the message for non-PDF unsupported documents', () => { + const mapped = mapTextractSdkError( + { name: 'UnsupportedDocumentException', message: 'Unsupported document' }, + false + ) + expect(mapped.message).toBe('Unsupported document') + }) + + it('uses the SDK http status when under 500', () => { + const mapped = mapTextractSdkError( + { + name: 'InvalidParameterException', + message: 'Bad param', + $metadata: { httpStatusCode: 400 }, + }, + false + ) + expect(mapped.status).toBe(400) + expect(mapped.message).toBe('Bad param') + }) + + it('passes through a 5xx SDK status so tool-execution retry logic still fires', () => { + const mapped = mapTextractSdkError( + { message: 'Internal failure', $metadata: { httpStatusCode: 500 } }, + false + ) + expect(mapped.status).toBe(500) + }) + + it('defaults to 500 when the SDK gives no http status, since that implies a server-side failure', () => { + const mapped = mapTextractSdkError({ message: 'Unknown failure' }, false) + expect(mapped.status).toBe(500) + }) +}) + +describe('pollTextractJob', () => { + it('returns immediately on SUCCEEDED with no NextToken', async () => { + const result = await pollTextractJob( + 'req-1', + logger, + async () => ({ JobStatus: 'SUCCEEDED', Blocks: [{ Id: '1' }] }), + (accumulated, page) => ({ + ...page, + Blocks: [...(accumulated.Blocks ?? []), ...(page.Blocks ?? [])], + }) + ) + + expect(result.JobStatus).toBe('SUCCEEDED') + expect(result.Blocks).toHaveLength(1) + }) + + it('follows NextToken pagination and merges pages', async () => { + let calls = 0 + const result = await pollTextractJob( + 'req-2', + logger, + async (nextToken) => { + calls += 1 + if (!nextToken) return { JobStatus: 'SUCCEEDED', Blocks: [{ Id: '1' }], NextToken: 'next' } + return { JobStatus: 'SUCCEEDED', Blocks: [{ Id: '2' }] } + }, + (accumulated, page) => ({ + ...accumulated, + ...page, + Blocks: [...(accumulated.Blocks ?? []), ...(page.Blocks ?? [])], + }) + ) + + expect(calls).toBe(2) + expect(result.Blocks).toHaveLength(2) + }) + + it('preserves fields the first page has but a later page omits (e.g. DocumentMetadata)', async () => { + const result = await pollTextractJob<{ + JobStatus?: string + NextToken?: string + Blocks?: unknown[] + DocumentMetadata?: { Pages?: number } + }>( + 'req-4', + logger, + async (nextToken) => { + if (!nextToken) { + return { + JobStatus: 'SUCCEEDED', + Blocks: [{ Id: '1' }], + DocumentMetadata: { Pages: 3 }, + NextToken: 'next', + } + } + return { JobStatus: 'SUCCEEDED', Blocks: [{ Id: '2' }] } + }, + (accumulated, page) => ({ + ...accumulated, + ...page, + Blocks: [...(accumulated.Blocks ?? []), ...(page.Blocks ?? [])], + }) + ) + + expect(result.Blocks).toHaveLength(2) + expect(result.DocumentMetadata).toEqual({ Pages: 3 }) + }) + + it('throws a TextractRouteError when the job fails', async () => { + await expect( + pollTextractJob( + 'req-3', + logger, + async () => ({ JobStatus: 'FAILED', StatusMessage: 'boom' }), + (accumulated) => accumulated + ) + ).rejects.toThrow('Textract job failed: boom') + }) +}) diff --git a/apps/sim/app/api/tools/textract/shared.ts b/apps/sim/app/api/tools/textract/shared.ts new file mode 100644 index 00000000000..90297f21498 --- /dev/null +++ b/apps/sim/app/api/tools/textract/shared.ts @@ -0,0 +1,305 @@ +import type { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { sleep } from '@sim/utils/helpers' +import { NextResponse } from 'next/server' +import { getMaxExecutionTimeout } from '@/lib/core/execution-limits' +import { validateS3BucketName } from '@/lib/core/security/input-validation' +import { + secureFetchWithPinnedIP, + validateUrlWithDNS, +} from '@/lib/core/security/input-validation.server' +import type { RawFileInput } from '@/lib/uploads/utils/file-utils' +import { isInternalFileUrl, processSingleFileToUserFile } from '@/lib/uploads/utils/file-utils' +import { + downloadServableFileFromStorage, + resolveInternalFileUrl, +} from '@/lib/uploads/utils/file-utils.server' +import { docNotReadyResponse } from '@/lib/uploads/utils/servable-file-response' +import { assertToolFileAccess } from '@/app/api/files/authorization' + +type RouteLogger = ReturnType + +/** Thrown by AWS SDK call sites so route handlers can map failures to the right HTTP status. */ +export class TextractRouteError extends Error { + status: number + + constructor(message: string, status = 500) { + super(message) + this.name = 'TextractRouteError' + this.status = status + } +} + +export function textractErrorResponse( + error: unknown, + requestId: string, + logger: RouteLogger +): NextResponse { + const notReady = docNotReadyResponse(error) + if (notReady) return notReady + + logger.error(`[${requestId}] Error in Textract request:`, error) + const status = error instanceof TextractRouteError ? error.status : 500 + return NextResponse.json( + { success: false, error: getErrorMessage(error, 'Internal server error') }, + { status } + ) +} + +/** + * Maps an AWS SDK TextractClient rejection to a client-facing error, with a friendly hint for the + * common "PDF used in single-page mode" mistake. The real AWS HTTP status (including 5xx) is + * passed through so the tool-execution layer's retry logic can still treat throttling/internal + * errors as retryable, matching the pre-migration hand-rolled-signing behavior. + */ +export function mapTextractSdkError( + error: unknown, + isPdf: boolean, + options?: { hasAsyncMode?: boolean } +): TextractRouteError { + const err = error as { + name?: string + message?: string + $metadata?: { httpStatusCode?: number } + } + const hasAsyncMode = options?.hasAsyncMode ?? true + + const isUnsupportedFormat = + err.name === 'UnsupportedDocumentException' || + Boolean(err.message?.toLowerCase().includes('unsupported document')) + + if (isUnsupportedFormat && isPdf) { + const hint = hasAsyncMode + ? ' If this is a multi-page PDF, please use "Multi-Page (PDF, TIFF via S3)" mode instead, which requires uploading your document to S3 first. Single Page mode only supports JPEG, PNG, and single-page PDF files.' + : ' Only JPEG, PNG, and single-page PDF files are supported.' + return new TextractRouteError(`This document format is not supported.${hint}`, 400) + } + + const status = err.$metadata?.httpStatusCode ?? 500 + return new TextractRouteError(err.message || 'Textract API error', status) +} + +export interface ResolvedDocument { + bytes: Buffer + contentType: string + isPdf: boolean +} + +export type ResolveDocumentResult = + | { ok: true; document: ResolvedDocument } + | { ok: false; response: NextResponse } + +/** Passes through the document host's real HTTP status on failure, so tool-execution retry logic can still treat a transient 5xx as retryable. */ +async function fetchDocumentBytes(url: string): Promise<{ bytes: Buffer; contentType: string }> { + const urlValidation = await validateUrlWithDNS(url, 'Document URL') + if (!urlValidation.isValid) { + throw new TextractRouteError(urlValidation.error || 'Invalid document URL', 400) + } + + const response = await secureFetchWithPinnedIP(url, urlValidation.resolvedIP!, { + method: 'GET', + }) + if (!response.ok) { + await response.text().catch(() => {}) + throw new TextractRouteError( + `Failed to fetch document: ${response.statusText}`, + response.status + ) + } + + const arrayBuffer = await response.arrayBuffer() + const contentType = response.headers.get('content-type') || 'application/octet-stream' + + return { bytes: Buffer.from(arrayBuffer), contentType } +} + +/** Resolves a document input (uploaded file reference or URL) to raw bytes for the Textract Document.Bytes field. */ +export async function resolveDocumentInput( + input: { file?: RawFileInput; filePath?: string }, + userId: string, + requestId: string, + logger: RouteLogger +): Promise { + if (input.file) { + let userFile: ReturnType + try { + userFile = processSingleFileToUserFile(input.file, requestId, logger) + } catch (error) { + return { + ok: false, + response: NextResponse.json( + { success: false, error: getErrorMessage(error, 'Failed to process file') }, + { status: 400 } + ), + } + } + + const denied = await assertToolFileAccess(userFile.key, userId, requestId, logger) + if (denied) return { ok: false, response: denied } + + const { buffer, contentType } = await downloadServableFileFromStorage( + userFile, + requestId, + logger + ) + const resolvedContentType = contentType || userFile.type || 'application/octet-stream' + + return { + ok: true, + document: { + bytes: buffer, + contentType: resolvedContentType, + isPdf: + resolvedContentType.includes('pdf') || + Boolean(userFile.name?.toLowerCase().endsWith('.pdf')), + }, + } + } + + if (input.filePath) { + let fileUrl = input.filePath + const isInternalFilePath = isInternalFileUrl(fileUrl) + + if (isInternalFilePath) { + const resolution = await resolveInternalFileUrl(fileUrl, userId, requestId, logger) + if (resolution.error) { + return { + ok: false, + response: NextResponse.json( + { success: false, error: resolution.error.message }, + { status: resolution.error.status } + ), + } + } + fileUrl = resolution.fileUrl || fileUrl + } else if (fileUrl.startsWith('/')) { + logger.warn(`[${requestId}] Invalid internal path`, { + userId, + path: fileUrl.substring(0, 50), + }) + return { + ok: false, + response: NextResponse.json( + { + success: false, + error: 'Invalid file path. Only uploaded files are supported for internal paths.', + }, + { status: 400 } + ), + } + } else { + const urlValidation = await validateUrlWithDNS(fileUrl, 'Document URL') + if (!urlValidation.isValid) { + logger.warn(`[${requestId}] SSRF attempt blocked`, { + userId, + url: fileUrl.substring(0, 100), + error: urlValidation.error, + }) + return { + ok: false, + response: NextResponse.json( + { success: false, error: urlValidation.error }, + { status: 400 } + ), + } + } + } + + const fetched = await fetchDocumentBytes(fileUrl) + return { + ok: true, + document: { + bytes: fetched.bytes, + contentType: fetched.contentType, + isPdf: fetched.contentType.includes('pdf') || fileUrl.toLowerCase().endsWith('.pdf'), + }, + } + } + + return { + ok: false, + response: NextResponse.json( + { success: false, error: 'Document input is required' }, + { status: 400 } + ), + } +} + +export function parseS3Uri(s3Uri: string): { bucket: string; key: string } { + const match = s3Uri.match(/^s3:\/\/([^/]+)\/(.+)$/) + if (!match) { + throw new TextractRouteError( + `Invalid S3 URI format: ${s3Uri}. Expected format: s3://bucket-name/path/to/object`, + 400 + ) + } + + const bucket = match[1] + const key = match[2] + + const bucketValidation = validateS3BucketName(bucket, 'S3 bucket name') + if (!bucketValidation.isValid) { + throw new TextractRouteError(bucketValidation.error || 'Invalid S3 bucket name', 400) + } + + if (key.includes('..') || key.startsWith('/')) { + throw new TextractRouteError('S3 key contains invalid path traversal sequences', 400) + } + + return { bucket, key } +} + +interface PollableJobResult { + JobStatus?: string + StatusMessage?: string + NextToken?: string +} + +/** Polls a started async Textract job (StartDocumentAnalysis/StartDocumentTextDetection/StartExpenseAnalysis) until it completes, following NextToken pagination on success. */ +export async function pollTextractJob( + requestId: string, + logger: RouteLogger, + getPage: (nextToken?: string) => Promise, + mergePage: (accumulated: TResult, page: TResult) => TResult +): Promise { + const pollIntervalMs = 5000 + const maxPollTimeMs = getMaxExecutionTimeout() + const maxAttempts = Math.ceil(maxPollTimeMs / pollIntervalMs) + + for (let attempt = 0; attempt < maxAttempts; attempt++) { + const result = await getPage() + const jobStatus = result.JobStatus + + if (jobStatus === 'SUCCEEDED' || jobStatus === 'PARTIAL_SUCCESS') { + if (jobStatus === 'PARTIAL_SUCCESS') { + logger.warn(`[${requestId}] Job completed with partial success: ${result.StatusMessage}`) + } else { + logger.info(`[${requestId}] Async job completed successfully after ${attempt + 1} polls`) + } + + let merged = result + let nextToken = result.NextToken + while (nextToken) { + const page = await getPage(nextToken) + merged = mergePage(merged, page) + nextToken = page.NextToken + } + return merged + } + + if (jobStatus === 'FAILED') { + throw new TextractRouteError( + `Textract job failed: ${result.StatusMessage || 'Unknown error'}`, + 502 + ) + } + + logger.info(`[${requestId}] Job status: ${jobStatus}, attempt ${attempt + 1}/${maxAttempts}`) + await sleep(pollIntervalMs) + } + + throw new TextractRouteError( + `Timeout waiting for Textract job to complete (max ${maxPollTimeMs / 1000} seconds)`, + 504 + ) +} diff --git a/apps/sim/blocks/blocks/textract.ts b/apps/sim/blocks/blocks/textract.ts index 9a6baa033b8..c5a87c480ab 100644 --- a/apps/sim/blocks/blocks/textract.ts +++ b/apps/sim/blocks/blocks/textract.ts @@ -6,7 +6,7 @@ import { IntegrationType, type SubBlockType, } from '@/blocks/types' -import { createVersionedToolSelector, normalizeFileInput } from '@/blocks/utils' +import { normalizeFileInput } from '@/blocks/utils' import type { TextractParserOutput } from '@/tools/textract/types' export const TextractBlock: BlockConfig = { @@ -199,63 +199,205 @@ export const TextractBlock: BlockConfig = { }, } -const textractV2Inputs = TextractBlock.inputs ? { ...TextractBlock.inputs } : {} -const textractV2SubBlocks = (TextractBlock.subBlocks || []).flatMap((subBlock) => { - if (subBlock.id === 'filePath') { - return [] // Remove the old filePath subblock +/** + * The front-document fields (fileUpload/fileReference) are shared by all three operations. + * Analyze Identity Document is always synchronous, so they must stay visible for it regardless + * of a stale `processingMode` value left over from switching away from another operation. + */ +function documentFieldCondition(values?: Record) { + if (values?.operation === 'analyze_id') { + return { field: 'operation', value: 'analyze_id' } as const } - if (subBlock.id === 'fileUpload') { - // Insert fileReference right after fileUpload - return [ - subBlock, - { - id: 'fileReference', - title: 'Document', - type: 'short-input' as SubBlockType, - canonicalParamId: 'document', - placeholder: 'File reference', - condition: { - field: 'processingMode', - value: 'async', - not: true, - }, - mode: 'advanced' as const, - }, - ] - } - return [subBlock] -}) + return { field: 'processingMode', value: 'async', not: true } as const +} + +/** + * Resolves a canonical document input to either an uploaded file object or a plain URL string. + * `normalizeFileInput` only recognizes file objects (or JSON-serialized file references) — a raw + * URL typed into the "File reference" advanced field falls through to `filePath` instead. + */ +function resolveDocumentParam(value: unknown): { file?: object; filePath?: string } { + const file = normalizeFileInput(value, { single: true }) + if (file) return { file } + if (typeof value === 'string' && value.trim() !== '') return { filePath: value.trim() } + return {} +} + +function requireAwsCredentials(params: Record) { + const accessKeyId = typeof params.accessKeyId === 'string' ? params.accessKeyId.trim() : '' + const secretAccessKey = + typeof params.secretAccessKey === 'string' ? params.secretAccessKey.trim() : '' + const region = typeof params.region === 'string' ? params.region.trim() : '' + + if (!accessKeyId) throw new Error('AWS Access Key ID is required') + if (!secretAccessKey) throw new Error('AWS Secret Access Key is required') + if (!region) throw new Error('AWS Region is required') + + return { accessKeyId, secretAccessKey, region } +} export const TextractV2Block: BlockConfig = { ...TextractBlock, type: 'textract_v2', name: 'AWS Textract', hideFromToolbar: false, - subBlocks: textractV2SubBlocks, + subBlocks: [ + { + id: 'operation', + title: 'Operation', + type: 'dropdown' as SubBlockType, + options: [ + { id: 'analyze_document', label: 'Analyze Document (Text, Tables, Forms)' }, + { id: 'analyze_expense', label: 'Analyze Expense (Invoices & Receipts)' }, + { id: 'analyze_id', label: 'Analyze Identity Document' }, + ], + value: () => 'analyze_document', + }, + { + id: 'processingMode', + title: 'Processing Mode', + type: 'dropdown' as SubBlockType, + options: [ + { id: 'sync', label: 'Single Page (JPEG, PNG, 1-page PDF)' }, + { id: 'async', label: 'Multi-Page (PDF, TIFF via S3)' }, + ], + tooltip: + 'Single Page uses synchronous API for JPEG, PNG, or single-page PDF. Multi-Page uses async API for multi-page PDF/TIFF stored in S3.', + condition: { field: 'operation', value: 'analyze_id', not: true }, + }, + { + id: 'fileUpload', + title: 'Document', + type: 'file-upload' as SubBlockType, + canonicalParamId: 'document', + acceptedTypes: 'image/jpeg,image/png,application/pdf', + placeholder: 'Upload JPEG, PNG, or single-page PDF (max 10MB)', + condition: documentFieldCondition, + mode: 'basic', + maxSize: 10, + }, + { + id: 'fileReference', + title: 'Document', + type: 'short-input' as SubBlockType, + canonicalParamId: 'document', + placeholder: 'File reference', + condition: documentFieldCondition, + mode: 'advanced' as const, + }, + { + id: 'fileUploadBack', + title: 'Back of ID (optional)', + type: 'file-upload' as SubBlockType, + canonicalParamId: 'documentBack', + acceptedTypes: 'image/jpeg,image/png,application/pdf', + placeholder: 'Upload the back of the ID, if it carries data', + condition: { field: 'operation', value: 'analyze_id' }, + mode: 'basic', + maxSize: 10, + }, + { + id: 'fileReferenceBack', + title: 'Back of ID (optional)', + type: 'short-input' as SubBlockType, + canonicalParamId: 'documentBack', + placeholder: 'File reference', + condition: { field: 'operation', value: 'analyze_id' }, + mode: 'advanced' as const, + }, + { + id: 's3Uri', + title: 'S3 URI', + type: 'short-input' as SubBlockType, + placeholder: 's3://bucket-name/path/to/document.pdf', + condition: { + field: 'processingMode', + value: 'async', + and: { field: 'operation', value: 'analyze_id', not: true }, + }, + }, + { + id: 'region', + title: 'AWS Region', + type: 'short-input' as SubBlockType, + placeholder: 'e.g., us-east-1', + required: true, + }, + { + id: 'accessKeyId', + title: 'AWS Access Key ID', + type: 'short-input' as SubBlockType, + placeholder: 'Enter your AWS Access Key ID', + password: true, + required: true, + }, + { + id: 'secretAccessKey', + title: 'AWS Secret Access Key', + type: 'short-input' as SubBlockType, + placeholder: 'Enter your AWS Secret Access Key', + password: true, + required: true, + }, + { + id: 'extractTables', + title: 'Extract Tables', + type: 'switch' as SubBlockType, + condition: { field: 'operation', value: 'analyze_document' }, + }, + { + id: 'extractForms', + title: 'Extract Forms (Key-Value Pairs)', + type: 'switch' as SubBlockType, + condition: { field: 'operation', value: 'analyze_document' }, + }, + { + id: 'detectSignatures', + title: 'Detect Signatures', + type: 'switch' as SubBlockType, + condition: { field: 'operation', value: 'analyze_document' }, + }, + { + id: 'analyzeLayout', + title: 'Analyze Document Layout', + type: 'switch' as SubBlockType, + condition: { field: 'operation', value: 'analyze_document' }, + }, + ], tools: { - access: ['textract_parser_v2'], + access: ['textract_parser_v2', 'textract_analyze_expense', 'textract_analyze_id'], config: { - tool: createVersionedToolSelector({ - baseToolSelector: () => 'textract_parser', - suffix: '_v2', - fallbackToolId: 'textract_parser_v2', - }), + tool: (params) => { + const operation = params.operation || 'analyze_document' + if (operation === 'analyze_expense') return 'textract_analyze_expense' + if (operation === 'analyze_id') return 'textract_analyze_id' + return 'textract_parser_v2' + }, params: (params) => { - if (!params.accessKeyId || params.accessKeyId.trim() === '') { - throw new Error('AWS Access Key ID is required') - } - if (!params.secretAccessKey || params.secretAccessKey.trim() === '') { - throw new Error('AWS Secret Access Key is required') - } - if (!params.region || params.region.trim() === '') { - throw new Error('AWS Region is required') + const { accessKeyId, secretAccessKey, region } = requireAwsCredentials(params) + const operation = params.operation || 'analyze_document' + + if (operation === 'analyze_id') { + const front = resolveDocumentParam(params.document) + if (!front.file && !front.filePath) throw new Error('Identity document is required') + + const parameters: Record = { + accessKeyId, + secretAccessKey, + region, + ...front, + } + const back = resolveDocumentParam(params.documentBack) + if (back.file) parameters.fileBack = back.file + else if (back.filePath) parameters.filePathBack = back.filePath + return parameters } const processingMode = params.processingMode || 'sync' const parameters: Record = { - accessKeyId: params.accessKeyId.trim(), - secretAccessKey: params.secretAccessKey.trim(), - region: params.region.trim(), + accessKeyId, + secretAccessKey, + region, processingMode, } @@ -264,30 +406,76 @@ export const TextractV2Block: BlockConfig = { throw new Error('S3 URI is required for multi-page processing') } parameters.s3Uri = params.s3Uri.trim() + } else if (operation === 'analyze_expense') { + const resolved = resolveDocumentParam(params.document) + if (!resolved.file && !resolved.filePath) throw new Error('Document file is required') + Object.assign(parameters, resolved) } else { - // document is the canonical param for both basic (fileUpload) and advanced (fileReference) modes const file = normalizeFileInput(params.document, { single: true }) - if (!file) { - throw new Error('Document file is required') - } + if (!file) throw new Error('Document file is required') parameters.file = file } + if (operation === 'analyze_expense') return parameters + const featureTypes: string[] = [] if (params.extractTables) featureTypes.push('TABLES') if (params.extractForms) featureTypes.push('FORMS') if (params.detectSignatures) featureTypes.push('SIGNATURES') if (params.analyzeLayout) featureTypes.push('LAYOUT') - - if (featureTypes.length > 0) { - parameters.featureTypes = featureTypes - } + if (featureTypes.length > 0) parameters.featureTypes = featureTypes return parameters }, }, }, - inputs: textractV2Inputs, + inputs: { + operation: { + type: 'string', + description: 'Operation: analyze_document, analyze_expense, or analyze_id', + }, + processingMode: { type: 'string', description: 'Document type: single-page or multi-page' }, + document: { type: 'json', description: 'Document input (file upload or URL reference)' }, + documentBack: { + type: 'json', + description: 'Back-of-ID document input, for analyze_id (file upload or URL reference)', + }, + s3Uri: { type: 'string', description: 'S3 URI for multi-page processing (s3://bucket/key)' }, + extractTables: { type: 'boolean', description: 'Extract tables from document' }, + extractForms: { type: 'boolean', description: 'Extract form key-value pairs' }, + detectSignatures: { type: 'boolean', description: 'Detect signatures' }, + analyzeLayout: { type: 'boolean', description: 'Analyze document layout' }, + region: { type: 'string', description: 'AWS region' }, + accessKeyId: { type: 'string', description: 'AWS Access Key ID' }, + secretAccessKey: { type: 'string', description: 'AWS Secret Access Key' }, + }, + outputs: { + blocks: { + type: 'json', + description: 'Array of detected blocks (PAGE, LINE, WORD, TABLE, CELL, KEY_VALUE_SET, etc.)', + condition: { field: 'operation', value: 'analyze_document' }, + }, + expenseDocuments: { + type: 'json', + description: + '[{expenseIndex, summaryFields: [{type, valueDetection, currency}], lineItemGroups: [{lineItems}]}]', + condition: { field: 'operation', value: 'analyze_expense' }, + }, + identityDocuments: { + type: 'json', + description: + '[{documentIndex, identityDocumentFields: [{type: {text}, valueDetection: {text, normalizedValue}}]}]', + condition: { field: 'operation', value: 'analyze_id' }, + }, + documentMetadata: { + type: 'json', + description: 'Document metadata containing pages count', + }, + modelVersion: { + type: 'string', + description: 'Version of the Textract model used for processing', + }, + }, } export const TextractBlockMeta = { @@ -368,9 +556,9 @@ export const TextractBlockMeta = { { name: 'extract-invoice-fields', description: - 'Run an invoice or receipt through AWS Textract and return clean structured fields (vendor, date, totals, line items). Use when you need to digitize finance documents.', + 'Run an invoice or receipt through AWS Textract Analyze Expense and return clean structured fields (vendor, date, totals, line items). Use when you need to digitize finance documents.', content: - '# Extract Invoice Fields\n\nUse AWS Textract to pull structured data from an invoice or receipt image/PDF.\n\n## Steps\n1. Choose the processing mode: Single Page for JPEG, PNG, or one-page PDF; Multi-Page for multi-page PDF/TIFF staged in S3.\n2. Provide the document (upload, file reference, or S3 URI) and enable Extract Forms and Extract Tables so key-value pairs and line items are captured.\n3. Run the extraction and read the returned blocks (KEY_VALUE_SET, TABLE, CELL, LINE).\n4. Map key-value pairs to fields: vendor, invoice number, invoice date, due date, subtotal, tax, and total.\n5. Reconstruct line items from the TABLE/CELL blocks into rows with description, quantity, unit price, and amount.\n\n## Output\nReturn a clean JSON record with the header fields and a line-items array. Flag any field where Textract confidence is low or the totals do not reconcile so a human can review.', + '# Extract Invoice Fields\n\nUse AWS Textract Analyze Expense to pull structured data from an invoice or receipt image/PDF.\n\n## Steps\n1. Set Operation to "Analyze Expense". Choose Single Page for JPEG, PNG, or one-page PDF, or Multi-Page for a PDF/TIFF staged in S3.\n2. Provide the document (upload, file reference, or S3 URI) and run the extraction.\n3. Read `expenseDocuments[].summaryFields` for header data (vendor, invoice number, invoice date, due date, subtotal, tax, total) and `expenseDocuments[].lineItemGroups[].lineItems` for purchased items.\n4. Map each summary field\'s normalized `type.text` (e.g., VENDOR_NAME, TOTAL, INVOICE_DATE) to your target schema, and read `valueDetection.text` for the value.\n\n## Output\nReturn a clean JSON record with the header fields and a line-items array. Flag any field where confidence is low or the totals do not reconcile so a human can review.', }, { name: 'extract-form-key-values', @@ -386,5 +574,12 @@ export const TextractBlockMeta = { content: '# OCR Document To Text\n\nExtract readable text from a scanned or image-only document.\n\n## Steps\n1. Pick the processing mode that matches the file: Single Page for an image or one-page PDF, Multi-Page (S3) for multi-page documents.\n2. Provide the document and run the extraction. Plain OCR does not require the Forms or Tables features.\n3. Read the LINE and WORD blocks and join LINE blocks in reading order to reconstruct the text.\n4. Preserve paragraph and page breaks using the PAGE blocks.\n\n## Output\nReturn the full extracted text as clean Markdown, grouped by page. Include the page count from document metadata and surface any low-confidence lines so they can be reviewed before indexing.', }, + { + name: 'verify-identity-document', + description: + 'Run a government-issued ID through AWS Textract Analyze ID and return normalized identity fields (name, date of birth, ID number, expiration date). Use for KYC and identity-verification workflows.', + content: + '# Verify Identity Document\n\nUse AWS Textract Analyze ID to extract normalized fields from a driver\'s license, passport, or other identity document.\n\n## Steps\n1. Set Operation to "Analyze Identity Document".\n2. Upload the front of the ID. If the document carries data on both sides (e.g., a driver\'s license), also upload the back.\n3. Run the extraction and read `identityDocuments[].identityDocumentFields`, where each field has a normalized `type.text` (e.g., FIRST_NAME, LAST_NAME, DATE_OF_BIRTH, DOCUMENT_NUMBER, EXPIRATION_DATE) and a `valueDetection.text` (with `valueDetection.normalizedValue` for dates).\n4. Compare the extracted fields against the customer record you already have on file.\n\n## Output\nReturn a normalized identity record and flag any mismatch between the extracted fields and the existing customer record for human review.', + }, ], } as const satisfies BlockMeta diff --git a/apps/sim/lib/api/contracts/tools/media/document-parse.ts b/apps/sim/lib/api/contracts/tools/media/document-parse.ts index 8bafd437ef6..ecc4bd8e93c 100644 --- a/apps/sim/lib/api/contracts/tools/media/document-parse.ts +++ b/apps/sim/lib/api/contracts/tools/media/document-parse.ts @@ -46,6 +46,65 @@ export const textractParseBodySchema = z } }) +export const textractAnalyzeExpenseBodySchema = z + .object({ + accessKeyId: z.string().min(1, 'AWS Access Key ID is required'), + secretAccessKey: z.string().min(1, 'AWS Secret Access Key is required'), + region: z + .string() + .min(1, 'AWS region is required') + .regex( + AWS_REGION_PATTERN, + 'AWS region must be a valid AWS region (e.g., us-east-1, eu-west-2, us-gov-west-1)' + ), + processingMode: z.enum(['sync', 'async']).optional().default('sync'), + filePath: z.string().optional(), + file: RawFileInputSchema.optional(), + s3Uri: z.string().optional(), + }) + .superRefine((data, ctx) => { + if (data.processingMode === 'async' && !data.s3Uri) { + ctx.addIssue({ + code: 'custom', + message: 'S3 URI is required for multi-page processing (s3://bucket/key)', + path: ['s3Uri'], + }) + } + if (data.processingMode !== 'async' && !data.file && !data.filePath) { + ctx.addIssue({ + code: 'custom', + message: 'Document input is required for single-page processing', + path: ['filePath'], + }) + } + }) + +export const textractAnalyzeIdBodySchema = z + .object({ + accessKeyId: z.string().min(1, 'AWS Access Key ID is required'), + secretAccessKey: z.string().min(1, 'AWS Secret Access Key is required'), + region: z + .string() + .min(1, 'AWS region is required') + .regex( + AWS_REGION_PATTERN, + 'AWS region must be a valid AWS region (e.g., us-east-1, eu-west-2, us-gov-west-1)' + ), + filePath: z.string().optional(), + file: RawFileInputSchema.optional(), + filePathBack: z.string().optional(), + fileBack: RawFileInputSchema.optional(), + }) + .superRefine((data, ctx) => { + if (!data.file && !data.filePath) { + ctx.addIssue({ + code: 'custom', + message: 'Identity document is required', + path: ['filePath'], + }) + } + }) + export const reductoParseBodySchema = z.object({ apiKey: z.string().min(1, 'API key is required'), filePath: z.string().optional(), @@ -94,6 +153,20 @@ export const textractParseContract = defineRouteContract({ response: { mode: 'json', schema: toolJsonResponseSchema }, }) +export const textractAnalyzeExpenseContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/textract/analyze-expense', + body: textractAnalyzeExpenseBodySchema, + response: { mode: 'json', schema: toolJsonResponseSchema }, +}) + +export const textractAnalyzeIdContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/textract/analyze-id', + body: textractAnalyzeIdBodySchema, + response: { mode: 'json', schema: toolJsonResponseSchema }, +}) + export const reductoParseContract = defineRouteContract({ method: 'POST', path: '/api/tools/reducto/parse', diff --git a/apps/sim/package.json b/apps/sim/package.json index 1e824356698..31e66edbc00 100644 --- a/apps/sim/package.json +++ b/apps/sim/package.json @@ -54,6 +54,7 @@ "@aws-sdk/client-sqs": "3.1032.0", "@aws-sdk/client-sso-admin": "3.1032.0", "@aws-sdk/client-sts": "3.1032.0", + "@aws-sdk/client-textract": "3.1032.0", "@aws-sdk/lib-dynamodb": "3.1032.0", "@aws-sdk/s3-request-presigner": "3.1032.0", "@azure/communication-email": "1.0.0", diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index 20ac92282f3..f32edc49219 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -3901,7 +3901,12 @@ import { temporalUnpauseScheduleTool, temporalUpdateWorkflowTool, } from '@/tools/temporal' -import { textractParserTool, textractParserV2Tool } from '@/tools/textract' +import { + textractAnalyzeExpenseTool, + textractAnalyzeIdTool, + textractParserTool, + textractParserV2Tool, +} from '@/tools/textract' import { thinkingTool } from '@/tools/thinking' import { thriveAddAudienceManagersTool, @@ -6946,6 +6951,8 @@ export const tools: Record = { mistral_parser_v3: mistralParserV3Tool, reducto_parser: reductoParserTool, reducto_parser_v2: reductoParserV2Tool, + textract_analyze_expense: textractAnalyzeExpenseTool, + textract_analyze_id: textractAnalyzeIdTool, textract_parser: textractParserTool, textract_parser_v2: textractParserV2Tool, thinking_tool: thinkingTool, diff --git a/apps/sim/tools/textract/analyze-expense.ts b/apps/sim/tools/textract/analyze-expense.ts new file mode 100644 index 00000000000..2cc3da68ba9 --- /dev/null +++ b/apps/sim/tools/textract/analyze-expense.ts @@ -0,0 +1,226 @@ +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import type { + TextractAnalyzeExpenseOutput, + TextractAnalyzeExpenseV2Input, +} from '@/tools/textract/types' +import type { ToolConfig } from '@/tools/types' + +const logger = createLogger('TextractAnalyzeExpenseTool') + +/** Shared shape for AnalyzeExpense fields — used by both summaryFields and lineItemExpenseFields. */ +const expenseFieldOutputProperties = { + type: { + type: 'object', + description: 'Normalized field label (e.g., VENDOR_NAME, TOTAL, ITEM, QUANTITY, PRICE)', + properties: { + text: { type: 'string', description: 'Field label text' }, + confidence: { type: 'number', description: 'Confidence score (0-100)' }, + }, + }, + valueDetection: { + type: 'object', + description: 'Detected value for the field', + properties: { + text: { type: 'string', description: 'Field value text' }, + confidence: { type: 'number', description: 'Confidence score (0-100)' }, + }, + }, + labelDetection: { + type: 'object', + description: 'The printed label detected next to the value, if any', + optional: true, + properties: { + text: { type: 'string', description: 'Label text' }, + confidence: { type: 'number', description: 'Confidence score (0-100)' }, + }, + }, + pageNumber: { type: 'number', description: 'Page number the field was found on', optional: true }, + currency: { + type: 'object', + description: 'Currency of a monetary value, if detected', + optional: true, + properties: { + code: { type: 'string', description: 'ISO currency code (e.g., USD)' }, + confidence: { type: 'number', description: 'Confidence score (0-100)' }, + }, + }, + groupProperties: { + type: 'array', + description: 'Grouping metadata (e.g., distinguishes vendor vs. recipient address lines)', + optional: true, + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'Group identifier' }, + types: { type: 'array', description: 'Group type tags', items: { type: 'string' } }, + }, + }, + }, +} as const + +export const textractAnalyzeExpenseTool: ToolConfig< + TextractAnalyzeExpenseV2Input, + TextractAnalyzeExpenseOutput +> = { + id: 'textract_analyze_expense', + name: 'AWS Textract Analyze Expense', + description: 'Extract structured invoice and receipt fields using AWS Textract AnalyzeExpense', + version: '1.0.0', + + params: { + accessKeyId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS Access Key ID', + }, + secretAccessKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS Secret Access Key', + }, + region: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS region for Textract service (e.g., us-east-1)', + }, + processingMode: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Document type: single-page or multi-page. Defaults to single-page.', + }, + file: { + type: 'file', + required: false, + visibility: 'hidden', + description: 'Invoice or receipt to be processed (JPEG, PNG, or single-page PDF).', + }, + filePath: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'URL to an invoice or receipt to be processed, if not uploaded directly.', + }, + s3Uri: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'S3 URI for multi-page processing (s3://bucket/key).', + }, + }, + + request: { + url: '/api/tools/textract/analyze-expense', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + Accept: 'application/json', + }), + body: (params) => { + const processingMode = params.processingMode || 'sync' + const requestBody: Record = { + accessKeyId: params.accessKeyId?.trim(), + secretAccessKey: params.secretAccessKey?.trim(), + region: params.region?.trim(), + processingMode, + } + + if (processingMode === 'async') { + requestBody.s3Uri = params.s3Uri?.trim() + } else if (params.file && typeof params.file === 'object') { + requestBody.file = params.file + } else if (params.filePath && params.filePath.trim() !== '') { + requestBody.filePath = params.filePath.trim() + } else { + throw new Error('Document is required for single-page processing') + } + + return requestBody + }, + }, + + transformResponse: async (response) => { + try { + const apiResult = await response.json() + + if (!apiResult || typeof apiResult !== 'object') { + throw new Error('Invalid response format from Textract API') + } + if (!apiResult.success) { + throw new Error(apiResult.error || 'Request failed') + } + + const data = apiResult.output ?? apiResult + + return { + success: true, + output: { + expenseDocuments: data.expenseDocuments ?? [], + documentMetadata: { pages: data.documentMetadata?.pages ?? 0 }, + modelVersion: data.modelVersion ?? undefined, + }, + } + } catch (error) { + logger.error('Error processing Textract AnalyzeExpense result:', toError(error)) + throw error + } + }, + + outputs: { + expenseDocuments: { + type: 'array', + description: 'Detected expense documents with summary fields and line items', + items: { + type: 'object', + properties: { + expenseIndex: { type: 'number', description: 'Index of the expense document' }, + summaryFields: { + type: 'array', + description: 'Header fields such as vendor name, invoice date, and totals', + items: { type: 'object', properties: expenseFieldOutputProperties }, + }, + lineItemGroups: { + type: 'array', + description: 'Groups of line items (e.g., purchased items and their prices)', + items: { + type: 'object', + properties: { + lineItemGroupIndex: { type: 'number', description: 'Index of the line item group' }, + lineItems: { + type: 'array', + description: 'Individual line items within the group', + items: { + type: 'object', + properties: { + lineItemExpenseFields: { + type: 'array', + description: 'Fields for a single line item (description, quantity, price)', + items: { type: 'object', properties: expenseFieldOutputProperties }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + documentMetadata: { + type: 'object', + description: 'Metadata about the analyzed document', + properties: { + pages: { type: 'number', description: 'Number of pages in the document' }, + }, + }, + modelVersion: { + type: 'string', + description: 'Version of the AnalyzeExpense model used (multi-page/async only)', + optional: true, + }, + }, +} diff --git a/apps/sim/tools/textract/analyze-id.ts b/apps/sim/tools/textract/analyze-id.ts new file mode 100644 index 00000000000..775283fa6df --- /dev/null +++ b/apps/sim/tools/textract/analyze-id.ts @@ -0,0 +1,169 @@ +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import type { TextractAnalyzeIdOutput, TextractAnalyzeIdV2Input } from '@/tools/textract/types' +import type { ToolConfig } from '@/tools/types' + +const logger = createLogger('TextractAnalyzeIdTool') + +export const textractAnalyzeIdTool: ToolConfig = + { + id: 'textract_analyze_id', + name: 'AWS Textract Analyze ID', + description: 'Extract identity document fields using AWS Textract AnalyzeID', + version: '1.0.0', + + params: { + accessKeyId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS Access Key ID', + }, + secretAccessKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS Secret Access Key', + }, + region: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'AWS region for Textract service (e.g., us-east-1)', + }, + file: { + type: 'file', + required: false, + visibility: 'hidden', + description: 'Front of the identity document (JPEG, PNG, or PDF).', + }, + filePath: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'URL to the front of the identity document, if not uploaded directly.', + }, + fileBack: { + type: 'file', + required: false, + visibility: 'hidden', + description: 'Back of the identity document, if applicable (JPEG, PNG, or PDF).', + }, + filePathBack: { + type: 'string', + required: false, + visibility: 'hidden', + description: 'URL to the back of the identity document, if not uploaded directly.', + }, + }, + + request: { + url: '/api/tools/textract/analyze-id', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + Accept: 'application/json', + }), + body: (params) => { + const requestBody: Record = { + accessKeyId: params.accessKeyId?.trim(), + secretAccessKey: params.secretAccessKey?.trim(), + region: params.region?.trim(), + } + + if (params.file && typeof params.file === 'object') { + requestBody.file = params.file + } else if (params.filePath && params.filePath.trim() !== '') { + requestBody.filePath = params.filePath.trim() + } else { + throw new Error('Identity document is required') + } + + if (params.fileBack && typeof params.fileBack === 'object') { + requestBody.fileBack = params.fileBack + } else if (params.filePathBack && params.filePathBack.trim() !== '') { + requestBody.filePathBack = params.filePathBack.trim() + } + + return requestBody + }, + }, + + transformResponse: async (response) => { + try { + const apiResult = await response.json() + + if (!apiResult || typeof apiResult !== 'object') { + throw new Error('Invalid response format from Textract API') + } + if (!apiResult.success) { + throw new Error(apiResult.error || 'Request failed') + } + + const data = apiResult.output ?? apiResult + + return { + success: true, + output: { + identityDocuments: data.identityDocuments ?? [], + documentMetadata: { pages: data.documentMetadata?.pages ?? 0 }, + modelVersion: data.modelVersion ?? undefined, + }, + } + } catch (error) { + logger.error('Error processing Textract AnalyzeID result:', toError(error)) + throw error + } + }, + + outputs: { + identityDocuments: { + type: 'array', + description: 'Detected identity documents with normalized fields', + items: { + type: 'object', + properties: { + documentIndex: { type: 'number', description: 'Index of the document page set' }, + identityDocumentFields: { + type: 'array', + description: + 'Normalized fields such as FIRST_NAME, LAST_NAME, DATE_OF_BIRTH, DOCUMENT_NUMBER, EXPIRATION_DATE', + items: { + type: 'object', + properties: { + type: { + type: 'object', + description: 'Normalized field label', + properties: { + text: { type: 'string', description: 'Field label text' }, + confidence: { type: 'number', description: 'Confidence score (0-100)' }, + }, + }, + valueDetection: { + type: 'object', + description: 'Detected value for the field, with a normalized value for dates', + properties: { + text: { type: 'string', description: 'Field value text' }, + confidence: { type: 'number', description: 'Confidence score (0-100)' }, + }, + }, + }, + }, + }, + }, + }, + }, + documentMetadata: { + type: 'object', + description: 'Metadata about the analyzed document', + properties: { + pages: { type: 'number', description: 'Number of pages analyzed' }, + }, + }, + modelVersion: { + type: 'string', + description: 'Version of the AnalyzeID model used for processing', + optional: true, + }, + }, + } diff --git a/apps/sim/tools/textract/index.ts b/apps/sim/tools/textract/index.ts index c47c5cfc557..aea5ba29d9b 100644 --- a/apps/sim/tools/textract/index.ts +++ b/apps/sim/tools/textract/index.ts @@ -1,2 +1,4 @@ +export { textractAnalyzeExpenseTool } from '@/tools/textract/analyze-expense' +export { textractAnalyzeIdTool } from '@/tools/textract/analyze-id' export { textractParserTool, textractParserV2Tool } from '@/tools/textract/parser' export * from '@/tools/textract/types' diff --git a/apps/sim/tools/textract/textract.test.ts b/apps/sim/tools/textract/textract.test.ts new file mode 100644 index 00000000000..1b5823c4ffc --- /dev/null +++ b/apps/sim/tools/textract/textract.test.ts @@ -0,0 +1,239 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { textractAnalyzeExpenseTool } from '@/tools/textract/analyze-expense' +import { textractAnalyzeIdTool } from '@/tools/textract/analyze-id' +import { textractParserTool, textractParserV2Tool } from '@/tools/textract/parser' + +const respond = (body: unknown) => new Response(JSON.stringify(body)) + +describe('textract_parser', () => { + const body = textractParserTool.request.body! + + it('builds a sync body from filePath', () => { + expect( + body({ + accessKeyId: ' key ', + secretAccessKey: ' secret ', + region: ' us-east-1 ', + filePath: ' https://example.com/doc.pdf ', + featureTypes: ['TABLES'], + } as never) + ).toMatchObject({ + accessKeyId: 'key', + secretAccessKey: 'secret', + region: 'us-east-1', + processingMode: 'sync', + filePath: 'https://example.com/doc.pdf', + featureTypes: ['TABLES'], + }) + }) + + it('requires s3Uri for async mode', () => { + expect(() => + body({ + accessKeyId: 'key', + secretAccessKey: 'secret', + region: 'us-east-1', + processingMode: 'async', + } as never) + ).not.toThrow() + expect( + body({ + accessKeyId: 'key', + secretAccessKey: 'secret', + region: 'us-east-1', + processingMode: 'async', + s3Uri: 's3://bucket/key.pdf', + } as never) + ).toMatchObject({ processingMode: 'async', s3Uri: 's3://bucket/key.pdf' }) + }) + + it('throws when no document is provided for sync mode', () => { + expect(() => + body({ + accessKeyId: 'key', + secretAccessKey: 'secret', + region: 'us-east-1', + } as never) + ).toThrow('Document is required for single-page processing') + }) + + it('normalizes the documented response shape', async () => { + const result = await textractParserTool.transformResponse!( + respond({ + success: true, + output: { + blocks: [{ BlockType: 'LINE', Id: '1', Text: 'Hello' }], + documentMetadata: { pages: 2 }, + modelVersion: '1.0', + }, + }) + ) + + expect(result.success).toBe(true) + expect(result.output.blocks).toHaveLength(1) + expect(result.output.documentMetadata.pages).toBe(2) + expect(result.output.modelVersion).toBe('1.0') + }) + + it('surfaces the API error message on failure', async () => { + await expect( + textractParserTool.transformResponse!(respond({ success: false, error: 'Bad request' })) + ).rejects.toThrow('Bad request') + }) +}) + +describe('textract_parser_v2', () => { + const body = textractParserV2Tool.request.body! + + it('throws when no file is provided for sync mode', () => { + expect(() => + body({ + accessKeyId: 'key', + secretAccessKey: 'secret', + region: 'us-east-1', + } as never) + ).toThrow('Document file is required for single-page processing') + }) + + it('builds a sync body from a UserFile', () => { + const file = { key: 'file-key', name: 'doc.pdf' } as never + expect( + body({ + accessKeyId: 'key', + secretAccessKey: 'secret', + region: 'us-east-1', + file, + } as never) + ).toMatchObject({ processingMode: 'sync', file }) + }) +}) + +describe('textract_analyze_expense', () => { + const body = textractAnalyzeExpenseTool.request.body! + + it('throws when neither file nor filePath is provided for sync mode', () => { + expect(() => + body({ + accessKeyId: 'key', + secretAccessKey: 'secret', + region: 'us-east-1', + } as never) + ).toThrow('Document is required for single-page processing') + }) + + it('falls back to filePath when no file object is provided', () => { + expect( + body({ + accessKeyId: 'key', + secretAccessKey: 'secret', + region: 'us-east-1', + filePath: ' https://example.com/receipt.pdf ', + } as never) + ).toMatchObject({ processingMode: 'sync', filePath: 'https://example.com/receipt.pdf' }) + }) + + it('builds an async body requiring s3Uri', () => { + expect( + body({ + accessKeyId: 'key', + secretAccessKey: 'secret', + region: 'us-east-1', + processingMode: 'async', + s3Uri: 's3://bucket/receipt.pdf', + } as never) + ).toMatchObject({ processingMode: 'async', s3Uri: 's3://bucket/receipt.pdf' }) + }) + + it('normalizes expense documents from the response', async () => { + const result = await textractAnalyzeExpenseTool.transformResponse!( + respond({ + success: true, + output: { + expenseDocuments: [{ expenseIndex: 0, summaryFields: [], lineItemGroups: [] }], + documentMetadata: { pages: 1 }, + }, + }) + ) + + expect(result.success).toBe(true) + expect(result.output.expenseDocuments).toHaveLength(1) + expect(result.output.documentMetadata.pages).toBe(1) + }) +}) + +describe('textract_analyze_id', () => { + const body = textractAnalyzeIdTool.request.body! + + it('throws when no front-of-ID file is provided', () => { + expect(() => + body({ accessKeyId: 'key', secretAccessKey: 'secret', region: 'us-east-1' } as never) + ).toThrow('Identity document is required') + }) + + it('includes fileBack only when provided', () => { + const file = { key: 'front-key' } as never + expect( + body({ accessKeyId: 'key', secretAccessKey: 'secret', region: 'us-east-1', file } as never) + ).toEqual({ + accessKeyId: 'key', + secretAccessKey: 'secret', + region: 'us-east-1', + file, + }) + + const fileBack = { key: 'back-key' } as never + expect( + body({ + accessKeyId: 'key', + secretAccessKey: 'secret', + region: 'us-east-1', + file, + fileBack, + } as never) + ).toEqual({ + accessKeyId: 'key', + secretAccessKey: 'secret', + region: 'us-east-1', + file, + fileBack, + }) + }) + + it('falls back to filePath/filePathBack when no file objects are provided', () => { + expect( + body({ + accessKeyId: 'key', + secretAccessKey: 'secret', + region: 'us-east-1', + filePath: ' https://example.com/id-front.png ', + filePathBack: ' https://example.com/id-back.png ', + } as never) + ).toEqual({ + accessKeyId: 'key', + secretAccessKey: 'secret', + region: 'us-east-1', + filePath: 'https://example.com/id-front.png', + filePathBack: 'https://example.com/id-back.png', + }) + }) + + it('normalizes identity documents from the response', async () => { + const result = await textractAnalyzeIdTool.transformResponse!( + respond({ + success: true, + output: { + identityDocuments: [{ documentIndex: 0, identityDocumentFields: [] }], + documentMetadata: { pages: 1 }, + modelVersion: '1.0', + }, + }) + ) + + expect(result.success).toBe(true) + expect(result.output.identityDocuments).toHaveLength(1) + expect(result.output.modelVersion).toBe('1.0') + }) +}) diff --git a/apps/sim/tools/textract/types.ts b/apps/sim/tools/textract/types.ts index 813c613ad78..aea61f8b660 100644 --- a/apps/sim/tools/textract/types.ts +++ b/apps/sim/tools/textract/types.ts @@ -81,41 +81,122 @@ interface TextractBlock { } } -interface TextractDocumentMetadataRaw { - Pages: number -} - interface TextractDocumentMetadata { pages: number } -interface TextractApiResponse { - Blocks: TextractBlock[] - DocumentMetadata: TextractDocumentMetadataRaw - AnalyzeDocumentModelVersion?: string - DetectDocumentTextModelVersion?: string -} - interface TextractNormalizedOutput { blocks: TextractBlock[] documentMetadata: TextractDocumentMetadata modelVersion?: string } -interface TextractAsyncJobResponse { - JobStatus: 'IN_PROGRESS' | 'SUCCEEDED' | 'FAILED' | 'PARTIAL_SUCCESS' - StatusMessage?: string - Blocks?: TextractBlock[] - DocumentMetadata?: TextractDocumentMetadataRaw - NextToken?: string - AnalyzeDocumentModelVersion?: string - DetectDocumentTextModelVersion?: string +export interface TextractParserOutput extends ToolResponse { + output: TextractNormalizedOutput } -interface TextractStartJobResponse { - JobId: string +export interface TextractAnalyzeExpenseInput { + accessKeyId: string + secretAccessKey: string + region: string + processingMode?: TextractProcessingMode + filePath?: string + file?: RawFileInput + s3Uri?: string } -export interface TextractParserOutput extends ToolResponse { - output: TextractNormalizedOutput +export interface TextractAnalyzeExpenseV2Input { + accessKeyId: string + secretAccessKey: string + region: string + processingMode?: TextractProcessingMode + file?: UserFile + filePath?: string + s3Uri?: string +} + +interface TextractCurrency { + code?: string + confidence?: number +} + +interface TextractExpenseFieldValue { + text?: string + confidence?: number +} + +interface TextractExpenseField { + type?: TextractExpenseFieldValue + valueDetection?: TextractExpenseFieldValue + labelDetection?: TextractExpenseFieldValue + pageNumber?: number + currency?: TextractCurrency + groupProperties?: { id: string; types: string[] }[] +} + +interface TextractLineItem { + lineItemExpenseFields: TextractExpenseField[] +} + +interface TextractLineItemGroup { + lineItemGroupIndex?: number + lineItems: TextractLineItem[] +} + +interface TextractExpenseDocument { + expenseIndex?: number + summaryFields: TextractExpenseField[] + lineItemGroups: TextractLineItemGroup[] +} + +export interface TextractAnalyzeExpenseOutput extends ToolResponse { + output: { + expenseDocuments: TextractExpenseDocument[] + documentMetadata: TextractDocumentMetadata + modelVersion?: string + } +} + +export interface TextractAnalyzeIdInput { + accessKeyId: string + secretAccessKey: string + region: string + filePath?: string + file?: RawFileInput + filePathBack?: string + fileBack?: RawFileInput +} + +export interface TextractAnalyzeIdV2Input { + accessKeyId: string + secretAccessKey: string + region: string + file?: UserFile + filePath?: string + fileBack?: UserFile + filePathBack?: string +} + +interface TextractIdFieldValue { + text?: string + confidence?: number + normalizedValue?: { value?: string; valueType?: string } +} + +interface TextractIdentityDocumentField { + type?: TextractIdFieldValue + valueDetection?: TextractIdFieldValue +} + +interface TextractIdentityDocument { + documentIndex?: number + identityDocumentFields: TextractIdentityDocumentField[] +} + +export interface TextractAnalyzeIdOutput extends ToolResponse { + output: { + identityDocuments: TextractIdentityDocument[] + documentMetadata: TextractDocumentMetadata + modelVersion?: string + } } diff --git a/bun.lock b/bun.lock index ff44268a33c..b6ab6c68a15 100644 --- a/bun.lock +++ b/bun.lock @@ -122,6 +122,7 @@ "@aws-sdk/client-sqs": "3.1032.0", "@aws-sdk/client-sso-admin": "3.1032.0", "@aws-sdk/client-sts": "3.1032.0", + "@aws-sdk/client-textract": "3.1032.0", "@aws-sdk/lib-dynamodb": "3.1032.0", "@aws-sdk/s3-request-presigner": "3.1032.0", "@azure/communication-email": "1.0.0", @@ -700,6 +701,8 @@ "@aws-sdk/client-sts": ["@aws-sdk/client-sts@3.1032.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.1", "@aws-sdk/credential-provider-node": "^3.972.32", "@aws-sdk/middleware-host-header": "^3.972.10", "@aws-sdk/middleware-logger": "^3.972.10", "@aws-sdk/middleware-recursion-detection": "^3.972.11", "@aws-sdk/middleware-user-agent": "^3.972.31", "@aws-sdk/region-config-resolver": "^3.972.12", "@aws-sdk/signature-v4-multi-region": "^3.996.18", "@aws-sdk/types": "^3.973.8", "@aws-sdk/util-endpoints": "^3.996.7", "@aws-sdk/util-user-agent-browser": "^3.972.10", "@aws-sdk/util-user-agent-node": "^3.973.17", "@smithy/config-resolver": "^4.4.16", "@smithy/core": "^3.23.15", "@smithy/fetch-http-handler": "^5.3.17", "@smithy/hash-node": "^4.2.14", "@smithy/invalid-dependency": "^4.2.14", "@smithy/middleware-content-length": "^4.2.14", "@smithy/middleware-endpoint": "^4.4.30", "@smithy/middleware-retry": "^4.5.3", "@smithy/middleware-serde": "^4.2.18", "@smithy/middleware-stack": "^4.2.14", "@smithy/node-config-provider": "^4.3.14", "@smithy/node-http-handler": "^4.5.3", "@smithy/protocol-http": "^5.3.14", "@smithy/smithy-client": "^4.12.11", "@smithy/types": "^4.14.1", "@smithy/url-parser": "^4.2.14", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.47", "@smithy/util-defaults-mode-node": "^4.2.52", "@smithy/util-endpoints": "^3.4.1", "@smithy/util-middleware": "^4.2.14", "@smithy/util-retry": "^4.3.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-FCLc5VWb+yz1xb/Jv0sXFGqIIs+bHZQWBKbPQKCuypF3wU/7UFygXuSXo9uJfwISKNGVHJwp+0136f8mqmzRcA=="], + "@aws-sdk/client-textract": ["@aws-sdk/client-textract@3.1032.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.1", "@aws-sdk/credential-provider-node": "^3.972.32", "@aws-sdk/middleware-host-header": "^3.972.10", "@aws-sdk/middleware-logger": "^3.972.10", "@aws-sdk/middleware-recursion-detection": "^3.972.11", "@aws-sdk/middleware-user-agent": "^3.972.31", "@aws-sdk/region-config-resolver": "^3.972.12", "@aws-sdk/types": "^3.973.8", "@aws-sdk/util-endpoints": "^3.996.7", "@aws-sdk/util-user-agent-browser": "^3.972.10", "@aws-sdk/util-user-agent-node": "^3.973.17", "@smithy/config-resolver": "^4.4.16", "@smithy/core": "^3.23.15", "@smithy/fetch-http-handler": "^5.3.17", "@smithy/hash-node": "^4.2.14", "@smithy/invalid-dependency": "^4.2.14", "@smithy/middleware-content-length": "^4.2.14", "@smithy/middleware-endpoint": "^4.4.30", "@smithy/middleware-retry": "^4.5.3", "@smithy/middleware-serde": "^4.2.18", "@smithy/middleware-stack": "^4.2.14", "@smithy/node-config-provider": "^4.3.14", "@smithy/node-http-handler": "^4.5.3", "@smithy/protocol-http": "^5.3.14", "@smithy/smithy-client": "^4.12.11", "@smithy/types": "^4.14.1", "@smithy/url-parser": "^4.2.14", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.47", "@smithy/util-defaults-mode-node": "^4.2.52", "@smithy/util-endpoints": "^3.4.1", "@smithy/util-middleware": "^4.2.14", "@smithy/util-retry": "^4.3.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-aqOnp0aEiqCQQ/ceLTMAjtCsmIdWAeuOyjz9dIzwgcNZKqUPwf+rproTSl8XIHmorqaBb8donzPaLTcAkMr+Yw=="], + "@aws-sdk/core": ["@aws-sdk/core@3.974.21", "", { "dependencies": { "@aws-sdk/types": "^3.973.13", "@aws-sdk/xml-builder": "^3.972.30", "@aws/lambda-invoke-store": "^0.2.2", "@smithy/core": "^3.24.6", "@smithy/signature-v4": "^5.4.6", "@smithy/types": "^4.14.3", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-P5JAHvn4dTi96UsAGS67LVOqqpUNNRhnfFXqzCYtdBIGZtqBue4CXvRr9YenOO7PALj/Pn8uuyw53FBCiCYw8w=="], "@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.47", "", { "dependencies": { "@aws-sdk/core": "^3.974.21", "@aws-sdk/types": "^3.973.13", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-3YoPwJczcc+MtX2xxXaYaOOWO6xKUJr1ZIIDIFuninr51BYONVVcF/CP8K2xfVRC/PztJjqKWxNGFH7BWQAw1Q=="], diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index e398d7c1dfa..334f8ff2170 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: 904, - zodRoutes: 904, + totalRoutes: 906, + zodRoutes: 906, nonZodRoutes: 0, } as const From b3175ae97349bfe923f4af96a222c7cb5f3259e6 Mon Sep 17 00:00:00 2001 From: Waleed Date: Mon, 6 Jul 2026 20:06:11 -0700 Subject: [PATCH 34/35] fix(rich-markdown-editor): strict, provenance-aware markdown paste (#5463) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(rich-markdown-editor): strict, provenance-aware markdown paste Pasting code-like text such as `5 * width * height` was italicized by StarterKit's lenient mark paste rules. Own emphasis with the strict CommonMark parser instead: - Disable StarterKit's mark paste rules (`enablePasteRules: false`); markdown paste is handled by MarkdownPaste (marked) and rich paste by real HTML tags. Input rules (typing) are unaffected. - Split the paste gate by provenance: structural markdown always parses (faithful GFM tables and escaping), while inline-only marks parse for a plain-text paste but defer to a rich HTML sibling so a copied table isn't flattened. Emphasis (`*_ ** __ ~~ ``) renders; `5 * width`, `*args`, and `snake_case` stay literal — matching Obsidian/Linear. * test(rich-markdown-editor): assert code-like pastes are claimed but preserved byte-for-byte The gate is intentionally lenient — a precise CommonMark-emphasis matcher would risk missing real emphasis. Over-claiming is safe because the strict parser (marked) preserves non-markdown exactly; assert the handler claims the paste and returns the input unchanged, not just that no mark is added. * fix(rich-markdown-editor): keep paste literal inside inline code too The paste handler skipped a fenced code block but not the inline code mark. Now that inline marks gate the parse, pasting `*italic*` inside inline code would render rich instead of staying literal — extend the code-context guard to editor.isActive('code'). --- .../markdown-paste.test.ts | 42 ++++++++++--- .../rich-markdown-editor/markdown-paste.ts | 60 +++++++++++++------ .../rich-markdown-editor.tsx | 1 + .../rich-markdown-field.tsx | 1 + 4 files changed, 78 insertions(+), 26 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-paste.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-paste.test.ts index 789c2d8a60c..b36857c037e 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-paste.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-paste.test.ts @@ -94,6 +94,14 @@ describe('markdown paste', () => { expect(paste(editor, '[link](https://example.com)')).toBe(false) }) + it('keeps pasted markdown literal inside inline code', () => { + editor = mount() + editor.commands.setContent('a `codehere` b', { contentType: 'markdown' }) + editor.commands.setTextSelection(6) + expect(editor.isActive('code')).toBe(true) + expect(paste(editor, '*italic*')).toBe(false) + }) + it('rejects the paste entirely in a read-only editor', () => { editor = mount(false) expect(paste(editor, '# heading\n\n- one\n- two')).toBe(false) @@ -104,21 +112,19 @@ describe('markdown paste', () => { ['empty string', ''], ['whitespace only', ' \n\n '], ['a bare thematic break (ambiguous — needs another markdown signal)', '---'], - ['inline-only italic (single asterisk would false-positive on e.g. *args)', 'an *italic* word'], - ['inline-only strikethrough', 'a ~~struck~~ word'], - ['inline-only code', 'some `code` here'], ])('leaves %s to the default handler', (_label, text) => { editor = mount() expect(paste(editor, text)).toBe(false) }) - // Only structural / unambiguous constructs gate the markdown parse. Inline-only marks that - // `looksLikeMarkdown` deliberately omits to avoid false positives — single-asterisk italic - // (`*args`), `~~`, single-backtick code — are covered by the Markdown extension's own paste path, - // not MarkdownPaste, so they belong to a different test surface. it.each([ ['heading', '# Heading', 'heading'], ['bold', 'a **bold** word', 'bold'], + ['italic', 'an *italic* word', 'italic'], + ['underscore italic', 'an _italic_ word', 'italic'], + ['underscore bold', 'a __bold__ word', 'bold'], + ['strikethrough', 'a ~~struck~~ word', 'strike'], + ['inline code', 'some `code` here', 'code'], ['bullet list', '- one\n- two', 'bulletList'], ['ordered list', '1. one\n2. two', 'orderedList'], ['task list', '- [x] done\n- [ ] todo', 'taskList'], @@ -132,6 +138,28 @@ describe('markdown paste', () => { expect(JSON.stringify(editor.getJSON())).toContain(`"type":"${nodeType}"`) }) + it.each([ + ['italic', 'an *italic* word', '

an italic word

'], + ['strikethrough', 'a ~~struck~~ word', '

a struck word

'], + ['inline code', 'some `code` here', '

some code here

'], + ])('defers inline-only %s to a rich HTML sibling (keeps its structure)', (_label, text, html) => { + editor = mount() + expect(paste(editor, text, html)).toBe(false) + }) + + it.each([ + ['space-flanked asterisks', 'area = 5 * width * height'], + ['python args and kwargs', 'def foo(*args, **kwargs): pass'], + ['snake_case identifiers', 'call user_name and file_path_here'], + ])('claims %s but leaves it byte-for-byte literal (strict CommonMark)', (_label, text) => { + editor = mount() + expect(paste(editor, text)).toBe(true) + const json = JSON.stringify(editor.getJSON()) + expect(json).not.toContain('"type":"italic"') + expect(json).not.toContain('"type":"bold"') + expect(editor.getText()).toBe(text) + }) + it('parses markdown-shaped plain text even when an HTML sibling is present', () => { editor = mount() const html = '

Title

  • a
  • b
' diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-paste.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-paste.ts index 48bca04aceb..88a9debbd9a 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-paste.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-paste.ts @@ -3,11 +3,12 @@ import { Plugin } from '@tiptap/pm/state' import { parseMarkdownToDoc } from './markdown-parse' /** - * Markdown syntax hints. If pasted plain text matches any of these, it's parsed as markdown rather - * than inserted literally — so a pasted link, image, badge, list, or heading renders as rich content - * instead of showing its raw `[text](url)` / `# ` source. + * Structural markdown — strong signals the plain text is genuinely markdown (a link, image, badge, + * list, heading, blockquote, fenced block, or GFM table). Our parser round-trips these more faithfully + * than generic HTML→DOM mapping (GFM alignment, escaping, the `./raw-markdown-snippet.ts` constructs), + * so they are parsed even when the clipboard also carries an HTML sibling. */ -const MARKDOWN_HINTS: ReadonlyArray = [ +const STRUCTURAL_MARKDOWN_HINTS: ReadonlyArray = [ /^#{1,6}\s/m, /\*\*[^*]+\*\*/, /\[[^\]]*]\([^)]+\)/, @@ -18,21 +19,40 @@ const MARKDOWN_HINTS: ReadonlyArray = [ /^\|.*\|.*\|/m, ] -function looksLikeMarkdown(text: string): boolean { - return MARKDOWN_HINTS.some((hint) => hint.test(text)) +/** + * Inline marks — weaker markdown signals (`*italic*` / `_italic_`, `~~strike~~`, `` `code` ``) that a + * rich HTML sibling encodes just as well. Parsed for a plain-text-only paste (so markdown copied from a + * terminal or `.md` source renders), but deferred to an HTML sibling: its presence means the source was + * rich, and it may carry structure the plain text can't (a copied table's plain form is tab-separated, + * not a `| … |` grid, so parsing it would flatten the table). + */ +const INLINE_MARK_HINTS: ReadonlyArray = [ + /\*[^*\n]+\*/, + /_[^_\n]+_/, + /~~[^~\n]+~~/, + /`[^`\n]+`/, +] + +function hasAny(hints: ReadonlyArray, text: string): boolean { + return hints.some((hint) => hint.test(text)) } /** - * Parses pasted plain text that looks like markdown into rich content. Pastes inside a code block - * are left untouched (code is meant to stay literal). + * Parses pasted plain text that looks like markdown into rich content, via the strict CommonMark + * parser ({@link parseMarkdownToDoc}, `marked`). Pastes inside a code block or inline code are left + * untouched (code is meant to stay literal). + * + * Provenance decides plain-text-vs-HTML: a `text/html` sibling (copied from a browser, Slack, Notion, + * GitHub, or this editor) is the signal the source was rich. Structural markdown is still parsed from + * the plain-text sibling regardless — our parser is more faithful for GFM tables and escaping. But + * inline-only marks are equally expressible in HTML, so when a rich sibling is present we defer to the + * DOM path, which preserves structure the plain text can't encode. A plain-text-only clipboard (a + * terminal, a code editor, a `.md` file) always parses. * - * A clipboard entry that also carries `text/html` (copied from a browser, Slack, Notion, GitHub, - * or this editor itself) used to always defer entirely to ProseMirror's generic HTML→DOM mapping, - * even when the `text/plain` sibling was clean markdown our own parser round-trips more faithfully - * (GFM table alignment, escaping, the constructs `./raw-markdown-snippet.ts` now preserves). Only - * defer to DOM mapping when the plain-text sibling *doesn't* look like markdown — an HTML clipboard - * payload with no markdown-shaped plain-text counterpart (a genuinely rich paste from a word - * processor, a web page selection, …) still goes through the DOM path unchanged. + * The strictness of the parse matters: `marked` follows CommonMark flanking rules, so `*text*` becomes + * emphasis but a space-flanked `5 * width * height` stays literal. The editor sets `enablePasteRules: + * false` so StarterKit's lenient mark paste rules (which would mangle that expression on either path) + * never run — emphasis is owned by this parser on the plain path and by real HTML tags on the DOM path. */ export const MarkdownPaste = Extension.create({ name: 'markdownPaste', @@ -44,11 +64,13 @@ export const MarkdownPaste = Extension.create({ props: { handlePaste: (_view, event) => { if (!editor.isEditable) return false - if (editor.isActive('codeBlock')) return false + if (editor.isActive('codeBlock') || editor.isActive('code')) return false const text = event.clipboardData?.getData('text/plain') - if (!text || !looksLikeMarkdown(text)) return false - // Parse through the chunker (linear) so pasting a large markdown blob can't freeze the - // main thread the way the underlying superlinear parse would. + if (!text) return false + if (!hasAny(STRUCTURAL_MARKDOWN_HINTS, text)) { + if (!hasAny(INLINE_MARK_HINTS, text)) return false + if (event.clipboardData?.getData('text/html')) return false + } const doc = parseMarkdownToDoc(text) if (!doc.content?.length) return false return editor.commands.insertContent(doc) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx index b141d8bab64..d6fc15224a1 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx @@ -250,6 +250,7 @@ export function LoadedRichMarkdownEditor({ const editor = useEditor({ extensions: EXTENSIONS, editable: isEditable, + enablePasteRules: false, autofocus: streamingAtMountRef.current ? false : autoFocus ? 'end' : false, immediatelyRender: false, shouldRerenderOnTransaction: false, diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx index 202ed9b4651..8936c9a489b 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx @@ -97,6 +97,7 @@ function LoadedRichMarkdownField({ const editor = useEditor({ extensions, editable: !disabled && !isStreaming, + enablePasteRules: false, autofocus: autoFocus ? 'end' : false, immediatelyRender: false, shouldRerenderOnTransaction: false, From cad44b9996e5f6cc5ed024d6012af4b5771c6f9d Mon Sep 17 00:00:00 2001 From: Waleed Date: Mon, 6 Jul 2026 20:21:57 -0700 Subject: [PATCH 35/35] fix(user-input): stop @mention/skill menu from reopening after dismiss (#5464) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(user-input): stop @mention/skill menu from reopening after dismiss - Fixes a bug where the @mention (and /skill) autocomplete menu couldn't be dismissed: clicking away or pressing Escape closed it, but the very next click/selection change reopened it because the caret was still inside the unfinished @token - Adds a one-shot "dismissed" marker per token, set only on a real outside-click/Escape dismiss, cleared the instant the user types again - Shared fix in use-prompt-editor.ts covers every consumer: home chat input, scheduled-task modal, task-details modal * chore(user-input): drop redundant inline comments from the mention-dismiss fix Trims the inline // explanations added in the previous commit down to the two declaration-level doc comments that carry real information — matching the repo's no-inline-comments convention. * fix(user-input): clear slash dismissal marker on explicit toolbar trigger Cursor Bugbot review: insertSlashTrigger (the toolbar Slash button) called syncSlashState without clearing dismissedSlashStartRef, so a stale dismissal could suppress the skills menu on an explicit user action when the new token's start offset coincided with the previously dismissed one. An explicit trigger click must always open the menu, so it now clears the marker first like every other insertion path. --- .../prompt-editor/use-prompt-editor.test.tsx | 246 ++++++++++++++++++ .../prompt-editor/use-prompt-editor.ts | 46 ++++ 2 files changed, 292 insertions(+) create mode 100644 apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.test.tsx diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.test.tsx new file mode 100644 index 00000000000..7cd9a2281c0 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.test.tsx @@ -0,0 +1,246 @@ +/** + * @vitest-environment jsdom + */ +import { act, type ReactNode } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/hooks/queries/skills', () => ({ useSkills: () => ({ data: [] }) })) +vi.mock('@/hooks/queries/workflows', () => ({ useWorkflows: () => ({ data: [] }) })) +vi.mock('@/hooks/queries/tables', () => ({ useTablesList: () => ({ data: [] }) })) +vi.mock('@/hooks/queries/workspace-files', () => ({ useWorkspaceFiles: () => ({ data: [] }) })) +vi.mock('@/hooks/queries/kb/knowledge', () => ({ useKnowledgeBasesQuery: () => ({ data: [] }) })) +vi.mock('@/hooks/queries/folders', () => ({ useFolders: () => ({ data: [] }) })) +vi.mock('@/hooks/queries/workspace-file-folders', () => ({ + useWorkspaceFileFolders: () => ({ data: [] }), +})) +vi.mock('@/hooks/queries/mothership-chats', () => ({ useMothershipChats: () => ({ data: [] }) })) +vi.mock('@/hooks/queries/schedules', () => ({ useWorkspaceSchedules: () => ({ data: [] }) })) +vi.mock('@/hooks/queries/logs', () => ({ useLogsList: () => ({ data: undefined }) })) +vi.mock('@/blocks/integration-matcher', () => ({ + getIntegrationMatcher: () => ({ regex: null, byName: new Map() }), + listIntegrations: () => [], +})) + +import type { PlusMenuHandle } from '@/app/workspace/[workspaceId]/home/components/user-input/components/constants' +import { + type UsePromptEditorProps, + usePromptEditor, +} from '@/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor' +import type { SkillsMenuHandle } from '@/app/workspace/[workspaceId]/home/components/user-input/components/skills-menu-dropdown/skills-menu-dropdown' + +/** + * Mounts `usePromptEditor` in a real React 19 root under jsdom (no + * `@testing-library/react` in this repo — see `hooks/queries/unsubscribe.test.tsx` + * for the established pattern) and wires a real `