From 61a5c98717480f68770465438c4d8ad7988638a0 Mon Sep 17 00:00:00 2001 From: Waleed Date: Tue, 17 Feb 2026 16:27:20 -0800 Subject: [PATCH 01/10] fix(shortlink): use redirect instead of rewrite for Beluga tracking (#3239) --- apps/sim/next.config.ts | 21 +++++++++------------ 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/apps/sim/next.config.ts b/apps/sim/next.config.ts index 828f95a5d..9ec7ab6fe 100644 --- a/apps/sim/next.config.ts +++ b/apps/sim/next.config.ts @@ -324,20 +324,17 @@ const nextConfig: NextConfig = { ) } + // Beluga campaign short link tracking + if (isHosted) { + redirects.push({ + source: '/r/:shortCode', + destination: 'https://go.trybeluga.ai/:shortCode', + permanent: false, + }) + } + return redirects }, - async rewrites() { - return [ - ...(isHosted - ? [ - { - source: '/r/:shortCode', - destination: 'https://go.trybeluga.ai/:shortCode', - }, - ] - : []), - ] - }, } export default nextConfig From 6421b1a0cace4065eae70a55dacc4b2bd2fb764d Mon Sep 17 00:00:00 2001 From: Waleed Date: Tue, 17 Feb 2026 18:01:52 -0800 Subject: [PATCH 02/10] feat(mcp): add ALLOWED_MCP_DOMAINS env var for domain allowlist (#3240) * feat(mcp): add ALLOWED_MCP_DOMAINS env var for domain allowlist * ack PR comments * cleanup --- apps/sim/app/api/mcp/servers/[id]/route.ts | 12 ++ apps/sim/app/api/mcp/servers/route.ts | 10 ++ .../api/mcp/servers/test-connection/route.ts | 10 ++ .../api/settings/allowed-mcp-domains/route.ts | 27 +++ .../settings-modal/components/mcp/mcp.tsx | 50 +++++- apps/sim/ee/sso/components/sso-settings.tsx | 50 +++--- apps/sim/lib/core/config/env.ts | 1 + apps/sim/lib/core/config/feature-flags.ts | 29 ++++ apps/sim/lib/mcp/domain-check.test.ts | 163 ++++++++++++++++++ apps/sim/lib/mcp/domain-check.ts | 69 ++++++++ apps/sim/lib/mcp/service.ts | 33 ++-- helm/sim/values.yaml | 1 + 12 files changed, 412 insertions(+), 43 deletions(-) create mode 100644 apps/sim/app/api/settings/allowed-mcp-domains/route.ts create mode 100644 apps/sim/lib/mcp/domain-check.test.ts create mode 100644 apps/sim/lib/mcp/domain-check.ts diff --git a/apps/sim/app/api/mcp/servers/[id]/route.ts b/apps/sim/app/api/mcp/servers/[id]/route.ts index e7b2d9f1d..be7c30c6f 100644 --- a/apps/sim/app/api/mcp/servers/[id]/route.ts +++ b/apps/sim/app/api/mcp/servers/[id]/route.ts @@ -3,6 +3,7 @@ import { mcpServers } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq, isNull } from 'drizzle-orm' import type { NextRequest } from 'next/server' +import { McpDomainNotAllowedError, validateMcpDomain } from '@/lib/mcp/domain-check' import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware' import { mcpService } from '@/lib/mcp/service' import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils' @@ -29,6 +30,17 @@ export const PATCH = withMcpAuth<{ id: string }>('write')( // Remove workspaceId from body to prevent it from being updated const { workspaceId: _, ...updateData } = body + if (updateData.url) { + try { + validateMcpDomain(updateData.url) + } catch (e) { + if (e instanceof McpDomainNotAllowedError) { + return createMcpErrorResponse(e, e.message, 403) + } + throw e + } + } + // Get the current server to check if URL is changing const [currentServer] = await db .select({ url: mcpServers.url }) diff --git a/apps/sim/app/api/mcp/servers/route.ts b/apps/sim/app/api/mcp/servers/route.ts index 8f304035b..f6bd6b782 100644 --- a/apps/sim/app/api/mcp/servers/route.ts +++ b/apps/sim/app/api/mcp/servers/route.ts @@ -3,6 +3,7 @@ import { mcpServers } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq, isNull } from 'drizzle-orm' import type { NextRequest } from 'next/server' +import { McpDomainNotAllowedError, validateMcpDomain } from '@/lib/mcp/domain-check' import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware' import { mcpService } from '@/lib/mcp/service' import { @@ -72,6 +73,15 @@ export const POST = withMcpAuth('write')( ) } + try { + validateMcpDomain(body.url) + } catch (e) { + if (e instanceof McpDomainNotAllowedError) { + return createMcpErrorResponse(e, e.message, 403) + } + throw e + } + const serverId = body.url ? generateMcpServerId(workspaceId, body.url) : crypto.randomUUID() const [existingServer] = await db diff --git a/apps/sim/app/api/mcp/servers/test-connection/route.ts b/apps/sim/app/api/mcp/servers/test-connection/route.ts index 69fb034a8..5a5a3f85a 100644 --- a/apps/sim/app/api/mcp/servers/test-connection/route.ts +++ b/apps/sim/app/api/mcp/servers/test-connection/route.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import type { NextRequest } from 'next/server' import { McpClient } from '@/lib/mcp/client' +import { McpDomainNotAllowedError, validateMcpDomain } from '@/lib/mcp/domain-check' import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware' import { resolveMcpConfigEnvVars } from '@/lib/mcp/resolve-config' import type { McpTransport } from '@/lib/mcp/types' @@ -71,6 +72,15 @@ export const POST = withMcpAuth('write')( ) } + try { + validateMcpDomain(body.url) + } catch (e) { + if (e instanceof McpDomainNotAllowedError) { + return createMcpErrorResponse(e, e.message, 403) + } + throw e + } + // Build initial config for resolution const initialConfig = { id: `test-${requestId}`, diff --git a/apps/sim/app/api/settings/allowed-mcp-domains/route.ts b/apps/sim/app/api/settings/allowed-mcp-domains/route.ts new file mode 100644 index 000000000..07ec5d107 --- /dev/null +++ b/apps/sim/app/api/settings/allowed-mcp-domains/route.ts @@ -0,0 +1,27 @@ +import { NextResponse } from 'next/server' +import { getSession } from '@/lib/auth' +import { getAllowedMcpDomainsFromEnv } from '@/lib/core/config/feature-flags' +import { getBaseUrl } from '@/lib/core/utils/urls' + +export async function GET() { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const configuredDomains = getAllowedMcpDomainsFromEnv() + if (configuredDomains === null) { + return NextResponse.json({ allowedMcpDomains: null }) + } + + try { + const platformHostname = new URL(getBaseUrl()).hostname.toLowerCase() + if (!configuredDomains.includes(platformHostname)) { + return NextResponse.json({ + allowedMcpDomains: [...configuredDomains, platformHostname], + }) + } + } catch {} + + return NextResponse.json({ allowedMcpDomains: configuredDomains }) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/mcp/mcp.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/mcp/mcp.tsx index 3b1980acd..b0f2079fd 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/mcp/mcp.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/mcp/mcp.tsx @@ -106,6 +106,21 @@ interface McpServer { const logger = createLogger('McpSettings') +/** + * Checks if a URL's hostname is in the allowed domains list. + * Returns true if no allowlist is configured (null) or the domain matches. + */ +function isDomainAllowed(url: string | undefined, allowedDomains: string[] | null): boolean { + if (allowedDomains === null) return true + if (!url) return true + try { + const hostname = new URL(url).hostname.toLowerCase() + return allowedDomains.includes(hostname) + } catch { + return false + } +} + const DEFAULT_FORM_DATA: McpServerFormData = { name: '', transport: 'streamable-http', @@ -390,6 +405,15 @@ export function MCP({ initialServerId }: MCPProps) { } = useMcpServerTest() const availableEnvVars = useAvailableEnvVarKeys(workspaceId) + const [allowedMcpDomains, setAllowedMcpDomains] = useState(null) + + useEffect(() => { + fetch('/api/settings/allowed-mcp-domains') + .then((res) => res.json()) + .then((data) => setAllowedMcpDomains(data.allowedMcpDomains ?? null)) + .catch(() => setAllowedMcpDomains(null)) + }, []) + const urlInputRef = useRef(null) const [showAddForm, setShowAddForm] = useState(false) @@ -1006,10 +1030,12 @@ export function MCP({ initialServerId }: MCPProps) { const showNoResults = searchTerm.trim() && filteredServers.length === 0 && servers.length > 0 const isFormValid = formData.name.trim() && formData.url?.trim() - const isSubmitDisabled = serversLoading || isAddingServer || !isFormValid + const isAddDomainBlocked = !isDomainAllowed(formData.url, allowedMcpDomains) + const isSubmitDisabled = serversLoading || isAddingServer || !isFormValid || isAddDomainBlocked const testButtonLabel = getTestButtonLabel(testResult, isTestingConnection) const isEditFormValid = editFormData.name.trim() && editFormData.url?.trim() + const isEditDomainBlocked = !isDomainAllowed(editFormData.url, allowedMcpDomains) const editTestButtonLabel = getTestButtonLabel(editTestResult, isEditTestingConnection) const hasEditChanges = useMemo(() => { if (editFormData.name !== editOriginalData.name) return true @@ -1299,6 +1325,11 @@ export function MCP({ initialServerId }: MCPProps) { onChange={(e) => handleEditInputChange('url', e.target.value)} onScroll={setEditUrlScrollLeft} /> + {isEditDomainBlocked && ( +

+ Domain not permitted by server policy +

+ )}
@@ -1351,7 +1382,7 @@ export function MCP({ initialServerId }: MCPProps) { @@ -1361,7 +1392,9 @@ export function MCP({ initialServerId }: MCPProps) { @@ -1489,7 +1527,9 @@ export function MCP({ initialServerId }: MCPProps) { Cancel
diff --git a/apps/sim/ee/sso/components/sso-settings.tsx b/apps/sim/ee/sso/components/sso-settings.tsx index a43e15ff3..bb1fa515f 100644 --- a/apps/sim/ee/sso/components/sso-settings.tsx +++ b/apps/sim/ee/sso/components/sso-settings.tsx @@ -2,7 +2,7 @@ import { useState } from 'react' import { createLogger } from '@sim/logger' -import { Check, ChevronDown, Copy, Eye, EyeOff } from 'lucide-react' +import { Check, ChevronDown, Clipboard, Eye, EyeOff } from 'lucide-react' import { Button, Combobox, Input, Switch, Textarea } from '@/components/emcn' import { Skeleton } from '@/components/ui' import { useSession } from '@/lib/auth/auth-client' @@ -418,29 +418,29 @@ export function SSO() { {/* Callback URL */}
- - Callback URL - -
-
- - {providerCallbackUrl} - -
+
+ + Callback URL +
+
+ + {providerCallbackUrl} + +

Configure this in your identity provider

@@ -852,29 +852,29 @@ export function SSO() { {/* Callback URL display */}
- - Callback URL - -
-
- - {callbackUrl} - -
+
+ + Callback URL +
+
+ + {callbackUrl} + +

Configure this in your identity provider

diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index b154fbdbb..8d06f938c 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -93,6 +93,7 @@ export const env = createEnv({ EXA_API_KEY: z.string().min(1).optional(), // Exa AI API key for enhanced online search BLACKLISTED_PROVIDERS: z.string().optional(), // Comma-separated provider IDs to hide (e.g., "openai,anthropic") BLACKLISTED_MODELS: z.string().optional(), // Comma-separated model names/prefixes to hide (e.g., "gpt-4,claude-*") + ALLOWED_MCP_DOMAINS: z.string().optional(), // Comma-separated domains for MCP servers (e.g., "internal.company.com,mcp.example.org"). Empty = all allowed. // Azure Configuration - Shared credentials with feature-specific models AZURE_OPENAI_ENDPOINT: z.string().url().optional(), // Shared Azure OpenAI service endpoint diff --git a/apps/sim/lib/core/config/feature-flags.ts b/apps/sim/lib/core/config/feature-flags.ts index 9f746c5b1..3ff517fb8 100644 --- a/apps/sim/lib/core/config/feature-flags.ts +++ b/apps/sim/lib/core/config/feature-flags.ts @@ -123,6 +123,35 @@ export const isReactGrabEnabled = isDev && isTruthy(env.REACT_GRAB_ENABLED) */ export const isReactScanEnabled = isDev && isTruthy(env.REACT_SCAN_ENABLED) +/** + * Normalizes a domain entry from the ALLOWED_MCP_DOMAINS env var. + * Accepts bare hostnames (e.g., "mcp.company.com") or full URLs (e.g., "https://mcp.company.com"). + * Extracts the hostname in either case. + */ +function normalizeDomainEntry(entry: string): string { + const trimmed = entry.trim().toLowerCase() + if (!trimmed) return '' + if (trimmed.includes('://')) { + try { + return new URL(trimmed).hostname + } catch { + return trimmed + } + } + return trimmed +} + +/** + * Get allowed MCP server domains from the ALLOWED_MCP_DOMAINS env var. + * Returns null if not set (all domains allowed), or parsed array of lowercase hostnames. + * Accepts both bare hostnames and full URLs in the env var value. + */ +export function getAllowedMcpDomainsFromEnv(): string[] | null { + if (!env.ALLOWED_MCP_DOMAINS) return null + const parsed = env.ALLOWED_MCP_DOMAINS.split(',').map(normalizeDomainEntry).filter(Boolean) + return parsed.length > 0 ? parsed : null +} + /** * Get cost multiplier based on environment */ diff --git a/apps/sim/lib/mcp/domain-check.test.ts b/apps/sim/lib/mcp/domain-check.test.ts new file mode 100644 index 000000000..ff72f7e6f --- /dev/null +++ b/apps/sim/lib/mcp/domain-check.test.ts @@ -0,0 +1,163 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mockGetAllowedMcpDomainsFromEnv = vi.fn<() => string[] | null>() +const mockGetBaseUrl = vi.fn<() => string>() + +vi.doMock('@/lib/core/config/feature-flags', () => ({ + getAllowedMcpDomainsFromEnv: mockGetAllowedMcpDomainsFromEnv, +})) + +vi.doMock('@/lib/core/utils/urls', () => ({ + getBaseUrl: mockGetBaseUrl, +})) + +const { McpDomainNotAllowedError, isMcpDomainAllowed, validateMcpDomain } = await import( + './domain-check' +) + +describe('McpDomainNotAllowedError', () => { + it.concurrent('creates error with correct name and message', () => { + const error = new McpDomainNotAllowedError('evil.com') + + expect(error).toBeInstanceOf(Error) + expect(error).toBeInstanceOf(McpDomainNotAllowedError) + expect(error.name).toBe('McpDomainNotAllowedError') + expect(error.message).toContain('evil.com') + }) +}) + +describe('isMcpDomainAllowed', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('when no allowlist is configured', () => { + beforeEach(() => { + mockGetAllowedMcpDomainsFromEnv.mockReturnValue(null) + }) + + it('allows any URL', () => { + expect(isMcpDomainAllowed('https://any-server.com/mcp')).toBe(true) + }) + + it('allows undefined URL', () => { + expect(isMcpDomainAllowed(undefined)).toBe(true) + }) + + it('allows empty string URL', () => { + expect(isMcpDomainAllowed('')).toBe(true) + }) + }) + + describe('when allowlist is configured', () => { + beforeEach(() => { + mockGetAllowedMcpDomainsFromEnv.mockReturnValue(['allowed.com', 'internal.company.com']) + mockGetBaseUrl.mockReturnValue('https://platform.example.com') + }) + + it('allows URLs on the allowlist', () => { + expect(isMcpDomainAllowed('https://allowed.com/mcp')).toBe(true) + expect(isMcpDomainAllowed('https://internal.company.com/tools')).toBe(true) + }) + + it('rejects URLs not on the allowlist', () => { + expect(isMcpDomainAllowed('https://evil.com/mcp')).toBe(false) + }) + + it('rejects undefined URL (fail-closed)', () => { + expect(isMcpDomainAllowed(undefined)).toBe(false) + }) + + it('rejects empty string URL (fail-closed)', () => { + expect(isMcpDomainAllowed('')).toBe(false) + }) + + it('rejects malformed URLs', () => { + expect(isMcpDomainAllowed('not-a-url')).toBe(false) + }) + + it('matches case-insensitively', () => { + expect(isMcpDomainAllowed('https://ALLOWED.COM/mcp')).toBe(true) + }) + + it('always allows the platform hostname', () => { + expect(isMcpDomainAllowed('https://platform.example.com/mcp')).toBe(true) + }) + + it('allows platform hostname even when not in the allowlist', () => { + mockGetAllowedMcpDomainsFromEnv.mockReturnValue(['other.com']) + expect(isMcpDomainAllowed('https://platform.example.com/mcp')).toBe(true) + }) + }) + + describe('when getBaseUrl is not configured', () => { + beforeEach(() => { + mockGetAllowedMcpDomainsFromEnv.mockReturnValue(['allowed.com']) + mockGetBaseUrl.mockImplementation(() => { + throw new Error('Not configured') + }) + }) + + it('still allows URLs on the allowlist', () => { + expect(isMcpDomainAllowed('https://allowed.com/mcp')).toBe(true) + }) + + it('still rejects URLs not on the allowlist', () => { + expect(isMcpDomainAllowed('https://evil.com/mcp')).toBe(false) + }) + }) +}) + +describe('validateMcpDomain', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('when no allowlist is configured', () => { + beforeEach(() => { + mockGetAllowedMcpDomainsFromEnv.mockReturnValue(null) + }) + + it('does not throw for any URL', () => { + expect(() => validateMcpDomain('https://any-server.com/mcp')).not.toThrow() + }) + + it('does not throw for undefined URL', () => { + expect(() => validateMcpDomain(undefined)).not.toThrow() + }) + }) + + describe('when allowlist is configured', () => { + beforeEach(() => { + mockGetAllowedMcpDomainsFromEnv.mockReturnValue(['allowed.com']) + mockGetBaseUrl.mockReturnValue('https://platform.example.com') + }) + + it('does not throw for allowed URLs', () => { + expect(() => validateMcpDomain('https://allowed.com/mcp')).not.toThrow() + }) + + it('throws McpDomainNotAllowedError for disallowed URLs', () => { + expect(() => validateMcpDomain('https://evil.com/mcp')).toThrow(McpDomainNotAllowedError) + }) + + it('throws for undefined URL (fail-closed)', () => { + expect(() => validateMcpDomain(undefined)).toThrow(McpDomainNotAllowedError) + }) + + it('throws for malformed URLs', () => { + expect(() => validateMcpDomain('not-a-url')).toThrow(McpDomainNotAllowedError) + }) + + it('includes the rejected domain in the error message', () => { + expect(() => validateMcpDomain('https://evil.com/mcp')).toThrow(/evil\.com/) + }) + + it('does not throw for platform hostname', () => { + expect(() => validateMcpDomain('https://platform.example.com/mcp')).not.toThrow() + }) + }) +}) diff --git a/apps/sim/lib/mcp/domain-check.ts b/apps/sim/lib/mcp/domain-check.ts new file mode 100644 index 000000000..e4087031f --- /dev/null +++ b/apps/sim/lib/mcp/domain-check.ts @@ -0,0 +1,69 @@ +import { getAllowedMcpDomainsFromEnv } from '@/lib/core/config/feature-flags' +import { getBaseUrl } from '@/lib/core/utils/urls' + +export class McpDomainNotAllowedError extends Error { + constructor(domain: string) { + super(`MCP server domain "${domain}" is not allowed by the server's ALLOWED_MCP_DOMAINS policy`) + this.name = 'McpDomainNotAllowedError' + } +} + +let cachedPlatformHostname: string | null = null + +/** + * Returns the platform's own hostname (from getBaseUrl), lazy-cached. + * Always lowercase. Returns null if the base URL is not configured or invalid. + */ +function getPlatformHostname(): string | null { + if (cachedPlatformHostname !== null) return cachedPlatformHostname + try { + cachedPlatformHostname = new URL(getBaseUrl()).hostname.toLowerCase() + } catch { + return null + } + return cachedPlatformHostname +} + +/** + * Core domain check. Returns null if the URL is allowed, or the hostname/url + * string to use in the rejection error. + */ +function checkMcpDomain(url: string): string | null { + const allowedDomains = getAllowedMcpDomainsFromEnv() + if (allowedDomains === null) return null + try { + const hostname = new URL(url).hostname.toLowerCase() + if (hostname === getPlatformHostname()) return null + return allowedDomains.includes(hostname) ? null : hostname + } catch { + return url + } +} + +/** + * Returns true if the URL's domain is allowed (or no restriction is configured). + * The platform's own hostname (from getBaseUrl) is always allowed. + */ +export function isMcpDomainAllowed(url: string | undefined): boolean { + if (!url) { + return getAllowedMcpDomainsFromEnv() === null + } + return checkMcpDomain(url) === null +} + +/** + * Throws McpDomainNotAllowedError if the URL's domain is not in the allowlist. + * The platform's own hostname (from getBaseUrl) is always allowed. + */ +export function validateMcpDomain(url: string | undefined): void { + if (!url) { + if (getAllowedMcpDomainsFromEnv() !== null) { + throw new McpDomainNotAllowedError('(empty)') + } + return + } + const rejected = checkMcpDomain(url) + if (rejected !== null) { + throw new McpDomainNotAllowedError(rejected) + } +} diff --git a/apps/sim/lib/mcp/service.ts b/apps/sim/lib/mcp/service.ts index e38cfb3f0..d9bc1aa20 100644 --- a/apps/sim/lib/mcp/service.ts +++ b/apps/sim/lib/mcp/service.ts @@ -10,6 +10,7 @@ import { isTest } from '@/lib/core/config/feature-flags' import { generateRequestId } from '@/lib/core/utils/request' import { McpClient } from '@/lib/mcp/client' import { mcpConnectionManager } from '@/lib/mcp/connection-manager' +import { isMcpDomainAllowed } from '@/lib/mcp/domain-check' import { resolveMcpConfigEnvVars } from '@/lib/mcp/resolve-config' import { createMcpCacheAdapter, @@ -93,6 +94,10 @@ class McpService { return null } + if (!isMcpDomainAllowed(server.url || undefined)) { + return null + } + return { id: server.id, name: server.name, @@ -123,19 +128,21 @@ class McpService { .from(mcpServers) .where(and(...whereConditions)) - return servers.map((server) => ({ - id: server.id, - name: server.name, - description: server.description || undefined, - transport: server.transport as McpTransport, - url: server.url || undefined, - headers: (server.headers as Record) || {}, - timeout: server.timeout || 30000, - retries: server.retries || 3, - enabled: server.enabled, - createdAt: server.createdAt.toISOString(), - updatedAt: server.updatedAt.toISOString(), - })) + return servers + .map((server) => ({ + id: server.id, + name: server.name, + description: server.description || undefined, + transport: server.transport as McpTransport, + url: server.url || undefined, + headers: (server.headers as Record) || {}, + timeout: server.timeout || 30000, + retries: server.retries || 3, + enabled: server.enabled, + createdAt: server.createdAt.toISOString(), + updatedAt: server.updatedAt.toISOString(), + })) + .filter((config) => isMcpDomainAllowed(config.url)) } /** diff --git a/helm/sim/values.yaml b/helm/sim/values.yaml index 9ac47e95e..dc7b2081a 100644 --- a/helm/sim/values.yaml +++ b/helm/sim/values.yaml @@ -193,6 +193,7 @@ app: # LLM Provider/Model Restrictions (leave empty if not restricting) BLACKLISTED_PROVIDERS: "" # Comma-separated provider IDs to hide from UI (e.g., "openai,anthropic,google") BLACKLISTED_MODELS: "" # Comma-separated model names/prefixes to hide (e.g., "gpt-4,claude-*") + ALLOWED_MCP_DOMAINS: "" # Comma-separated domains for MCP servers (e.g., "internal.company.com,mcp.example.org"). Empty = all allowed. # Invitation Control DISABLE_INVITATIONS: "" # Set to "true" to disable workspace invitations globally From 0ee52df5a73aa923daa29a7a5c1404c2cb8bbea7 Mon Sep 17 00:00:00 2001 From: Emir Karabeg <78010029+emir-karabeg@users.noreply.github.com> Date: Tue, 17 Feb 2026 18:16:17 -0800 Subject: [PATCH 03/10] feat(canvas): allow locked block outbound connections (#3229) * Allow outbound connections from locked blocks to be modified - Modified isEdgeProtected to only check target block protection - Outbound connections (from locked blocks) can now be added/removed - Inbound connections (to locked blocks) remain protected - Updated notification messages and comments to reflect the change Co-authored-by: Emir Karabeg * update notif msg --------- Co-authored-by: Cursor Agent Co-authored-by: Emir Karabeg Co-authored-by: waleed --- .../w/[workflowId]/utils/block-protection-utils.ts | 7 ++++--- .../[workspaceId]/w/[workflowId]/workflow.tsx | 10 +++++----- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/block-protection-utils.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/block-protection-utils.ts index eb76077fc..d86f1b3dc 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/block-protection-utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/block-protection-utils.ts @@ -36,17 +36,18 @@ export function isBlockProtected(blockId: string, blocks: Record ): boolean { - return isBlockProtected(edge.source, blocks) || isBlockProtected(edge.target, blocks) + return isBlockProtected(edge.target, blocks) } /** diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index 66fa0ee16..5b8b66f2b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -2523,7 +2523,7 @@ const WorkflowContent = React.memo(() => { .filter((change: any) => change.type === 'remove') .map((change: any) => change.id) .filter((edgeId: string) => { - // Prevent removing edges connected to protected blocks + // Prevent removing edges targeting protected blocks const edge = edges.find((e) => e.id === edgeId) if (!edge) return true return !isEdgeProtected(edge, blocks) @@ -2595,7 +2595,7 @@ const WorkflowContent = React.memo(() => { if (!sourceNode || !targetNode) return - // Prevent connections to/from protected blocks + // Prevent connections to protected blocks (outbound from locked blocks is allowed) if (isEdgeProtected(connection, blocks)) { addNotification({ level: 'info', @@ -3357,12 +3357,12 @@ const WorkflowContent = React.memo(() => { /** Stable delete handler to avoid creating new function references per edge. */ const handleEdgeDelete = useCallback( (edgeId: string) => { - // Prevent removing edges connected to protected blocks + // Prevent removing edges targeting protected blocks const edge = edges.find((e) => e.id === edgeId) if (edge && isEdgeProtected(edge, blocks)) { addNotification({ level: 'info', - message: 'Cannot remove connections from locked blocks', + message: 'Cannot remove connections to locked blocks', workflowId: activeWorkflowId || undefined, }) return @@ -3420,7 +3420,7 @@ const WorkflowContent = React.memo(() => { // Handle edge deletion first (edges take priority if selected) if (selectedEdges.size > 0) { - // Get all selected edge IDs and filter out edges connected to protected blocks + // Get all selected edge IDs and filter out edges targeting protected blocks const edgeIds = Array.from(selectedEdges.values()).filter((edgeId) => { const edge = edges.find((e) => e.id === edgeId) if (!edge) return true From bbcef7ce5cfd8621d4e270f7a958ca5fd9957a5d Mon Sep 17 00:00:00 2001 From: Waleed Date: Tue, 17 Feb 2026 18:46:24 -0800 Subject: [PATCH 04/10] feat(access-control): add ALLOWED_INTEGRATIONS env var for self-hosted block restrictions (#3238) * feat(access-control): add ALLOWED_INTEGRATIONS env var for self-hosted block restrictions * fix(tests): add getAllowedIntegrationsFromEnv mock to agent-handler tests * fix(access-control): add auth to allowlist endpoint, fix loading state race, use accurate error message * fix(access-control): remove auth from allowed-integrations endpoint to match models endpoint pattern * fix(access-control): normalize blockType to lowercase before env allowlist check * fix(access-control): expose merged allowedIntegrations on config to prevent bypass via direct access * consolidate merging of allowed blocks so all callers have it by default * normalize to lower case * added tests * added tests, normalize to lower case * added safety incase userId is missing * fix failing tests --- .../settings/allowed-integrations/route.ts | 14 + .../components/integrations/integrations.tsx | 4 +- .../utils/permission-check.test.ts | 265 ++++++++++++++++++ .../access-control/utils/permission-check.ts | 74 +++-- .../handlers/agent/agent-handler.test.ts | 1 + apps/sim/hooks/use-permission-config.ts | 62 +++- apps/sim/lib/copilot/process-contents.ts | 17 +- .../tools/server/blocks/get-block-config.ts | 6 +- .../tools/server/blocks/get-block-options.ts | 6 +- .../server/blocks/get-blocks-and-tools.ts | 7 +- .../server/blocks/get-blocks-metadata-tool.ts | 7 +- .../tools/server/blocks/get-trigger-blocks.ts | 7 +- .../workflow/edit-workflow/validation.ts | 2 +- apps/sim/lib/core/config/env.ts | 1 + apps/sim/lib/core/config/feature-flags.ts | 12 + helm/sim/values.yaml | 3 + 16 files changed, 438 insertions(+), 50 deletions(-) create mode 100644 apps/sim/app/api/settings/allowed-integrations/route.ts create mode 100644 apps/sim/ee/access-control/utils/permission-check.test.ts diff --git a/apps/sim/app/api/settings/allowed-integrations/route.ts b/apps/sim/app/api/settings/allowed-integrations/route.ts new file mode 100644 index 000000000..d05887641 --- /dev/null +++ b/apps/sim/app/api/settings/allowed-integrations/route.ts @@ -0,0 +1,14 @@ +import { NextResponse } from 'next/server' +import { getSession } from '@/lib/auth' +import { getAllowedIntegrationsFromEnv } from '@/lib/core/config/feature-flags' + +export async function GET() { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + return NextResponse.json({ + allowedIntegrations: getAllowedIntegrationsFromEnv(), + }) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/integrations/integrations.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/integrations/integrations.tsx index dabdfc03f..6f7fb5397 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/integrations/integrations.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/integrations/integrations.tsx @@ -223,13 +223,11 @@ export function Integrations({ onOpenChange, registerCloseHandler }: Integration } } - // Group services by provider, filtering by permission config const groupedServices = services.reduce( (acc, service) => { - // Filter based on allowedIntegrations if ( permissionConfig.allowedIntegrations !== null && - !permissionConfig.allowedIntegrations.includes(service.id) + !permissionConfig.allowedIntegrations.includes(service.id.replace(/-/g, '_')) ) { return acc } diff --git a/apps/sim/ee/access-control/utils/permission-check.test.ts b/apps/sim/ee/access-control/utils/permission-check.test.ts new file mode 100644 index 000000000..995af13cf --- /dev/null +++ b/apps/sim/ee/access-control/utils/permission-check.test.ts @@ -0,0 +1,265 @@ +/** + * @vitest-environment node + */ +import { databaseMock, drizzleOrmMock, loggerMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + DEFAULT_PERMISSION_GROUP_CONFIG, + mockGetAllowedIntegrationsFromEnv, + mockIsOrganizationOnEnterprisePlan, + mockGetProviderFromModel, +} = vi.hoisted(() => ({ + DEFAULT_PERMISSION_GROUP_CONFIG: { + allowedIntegrations: null, + allowedModelProviders: null, + hideTraceSpans: false, + hideKnowledgeBaseTab: false, + hideCopilot: false, + hideApiKeysTab: false, + hideEnvironmentTab: false, + hideFilesTab: false, + disableMcpTools: false, + disableCustomTools: false, + disableSkills: false, + hideTemplates: false, + disableInvitations: false, + hideDeployApi: false, + hideDeployMcp: false, + hideDeployA2a: false, + hideDeployChatbot: false, + hideDeployTemplate: false, + }, + mockGetAllowedIntegrationsFromEnv: vi.fn<() => string[] | null>(), + mockIsOrganizationOnEnterprisePlan: vi.fn<() => Promise>(), + mockGetProviderFromModel: vi.fn<(model: string) => string>(), +})) + +vi.mock('@sim/db', () => databaseMock) +vi.mock('@sim/db/schema', () => ({})) +vi.mock('@sim/logger', () => loggerMock) +vi.mock('drizzle-orm', () => drizzleOrmMock) +vi.mock('@/lib/billing', () => ({ + isOrganizationOnEnterprisePlan: mockIsOrganizationOnEnterprisePlan, +})) +vi.mock('@/lib/core/config/feature-flags', () => ({ + getAllowedIntegrationsFromEnv: mockGetAllowedIntegrationsFromEnv, + isAccessControlEnabled: false, + isHosted: false, +})) +vi.mock('@/lib/permission-groups/types', () => ({ + DEFAULT_PERMISSION_GROUP_CONFIG, + parsePermissionGroupConfig: (config: unknown) => { + if (!config || typeof config !== 'object') return DEFAULT_PERMISSION_GROUP_CONFIG + return { ...DEFAULT_PERMISSION_GROUP_CONFIG, ...config } + }, +})) +vi.mock('@/providers/utils', () => ({ + getProviderFromModel: mockGetProviderFromModel, +})) + +import { + getUserPermissionConfig, + IntegrationNotAllowedError, + validateBlockType, +} from './permission-check' + +describe('IntegrationNotAllowedError', () => { + it.concurrent('creates error with correct name and message', () => { + const error = new IntegrationNotAllowedError('discord') + + expect(error).toBeInstanceOf(Error) + expect(error.name).toBe('IntegrationNotAllowedError') + expect(error.message).toContain('discord') + }) + + it.concurrent('includes custom reason when provided', () => { + const error = new IntegrationNotAllowedError('discord', 'blocked by server policy') + + expect(error.message).toContain('blocked by server policy') + }) +}) + +describe('getUserPermissionConfig', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('returns null when no env allowlist is configured', async () => { + mockGetAllowedIntegrationsFromEnv.mockReturnValue(null) + + const config = await getUserPermissionConfig('user-123') + + expect(config).toBeNull() + }) + + it('returns config with env allowlist when configured', async () => { + mockGetAllowedIntegrationsFromEnv.mockReturnValue(['slack', 'gmail']) + + const config = await getUserPermissionConfig('user-123') + + expect(config).not.toBeNull() + expect(config!.allowedIntegrations).toEqual(['slack', 'gmail']) + }) + + it('preserves default values for non-allowlist fields', async () => { + mockGetAllowedIntegrationsFromEnv.mockReturnValue(['slack']) + + const config = await getUserPermissionConfig('user-123') + + expect(config!.disableMcpTools).toBe(false) + expect(config!.allowedModelProviders).toBeNull() + }) +}) + +describe('env allowlist fallback when userId is absent', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('returns null allowlist when no userId and no env allowlist', async () => { + mockGetAllowedIntegrationsFromEnv.mockReturnValue(null) + + const userId: string | undefined = undefined + const permissionConfig = userId ? await getUserPermissionConfig(userId) : null + const allowedIntegrations = + permissionConfig?.allowedIntegrations ?? mockGetAllowedIntegrationsFromEnv() + + expect(allowedIntegrations).toBeNull() + }) + + it('falls back to env allowlist when no userId is provided', async () => { + mockGetAllowedIntegrationsFromEnv.mockReturnValue(['slack', 'gmail']) + + const userId: string | undefined = undefined + const permissionConfig = userId ? await getUserPermissionConfig(userId) : null + const allowedIntegrations = + permissionConfig?.allowedIntegrations ?? mockGetAllowedIntegrationsFromEnv() + + expect(allowedIntegrations).toEqual(['slack', 'gmail']) + }) + + it('env allowlist filters block types when userId is absent', async () => { + mockGetAllowedIntegrationsFromEnv.mockReturnValue(['slack', 'gmail']) + + const userId: string | undefined = undefined + const permissionConfig = userId ? await getUserPermissionConfig(userId) : null + const allowedIntegrations = + permissionConfig?.allowedIntegrations ?? mockGetAllowedIntegrationsFromEnv() + + expect(allowedIntegrations).not.toBeNull() + expect(allowedIntegrations!.includes('slack')).toBe(true) + expect(allowedIntegrations!.includes('discord')).toBe(false) + }) + + it('uses permission config when userId is present, ignoring env fallback', async () => { + mockGetAllowedIntegrationsFromEnv.mockReturnValue(['slack', 'gmail']) + + const config = await getUserPermissionConfig('user-123') + + expect(config).not.toBeNull() + expect(config!.allowedIntegrations).toEqual(['slack', 'gmail']) + }) +}) + +describe('validateBlockType', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('when no env allowlist is configured', () => { + beforeEach(() => { + mockGetAllowedIntegrationsFromEnv.mockReturnValue(null) + }) + + it('allows any block type', async () => { + await validateBlockType(undefined, 'google_drive') + }) + + it('allows multi-word block types', async () => { + await validateBlockType(undefined, 'microsoft_excel') + }) + + it('always allows start_trigger', async () => { + await validateBlockType(undefined, 'start_trigger') + }) + }) + + describe('when env allowlist is configured', () => { + beforeEach(() => { + mockGetAllowedIntegrationsFromEnv.mockReturnValue([ + 'slack', + 'google_drive', + 'microsoft_excel', + ]) + }) + + it('allows block types on the allowlist', async () => { + await validateBlockType(undefined, 'slack') + await validateBlockType(undefined, 'google_drive') + await validateBlockType(undefined, 'microsoft_excel') + }) + + it('rejects block types not on the allowlist', async () => { + await expect(validateBlockType(undefined, 'discord')).rejects.toThrow( + IntegrationNotAllowedError + ) + }) + + it('always allows start_trigger regardless of allowlist', async () => { + await validateBlockType(undefined, 'start_trigger') + }) + + it('matches case-insensitively', async () => { + await validateBlockType(undefined, 'Slack') + await validateBlockType(undefined, 'GOOGLE_DRIVE') + }) + + it('includes env reason in error when env allowlist is the source', async () => { + await expect(validateBlockType(undefined, 'discord')).rejects.toThrow(/ALLOWED_INTEGRATIONS/) + }) + + it('includes env reason even when userId is present if env is the source', async () => { + await expect(validateBlockType('user-123', 'discord')).rejects.toThrow(/ALLOWED_INTEGRATIONS/) + }) + }) +}) + +describe('service ID to block type normalization', () => { + it.concurrent('hyphenated service IDs match underscore block types after normalization', () => { + const allowedBlockTypes = [ + 'google_drive', + 'microsoft_excel', + 'microsoft_teams', + 'google_sheets', + 'google_docs', + 'google_calendar', + 'google_forms', + 'microsoft_planner', + ] + const serviceIds = [ + 'google-drive', + 'microsoft-excel', + 'microsoft-teams', + 'google-sheets', + 'google-docs', + 'google-calendar', + 'google-forms', + 'microsoft-planner', + ] + + for (const serviceId of serviceIds) { + const normalized = serviceId.replace(/-/g, '_') + expect(allowedBlockTypes).toContain(normalized) + } + }) + + it.concurrent('single-word service IDs are unaffected by normalization', () => { + const serviceIds = ['slack', 'gmail', 'notion', 'discord', 'jira', 'trello'] + + for (const serviceId of serviceIds) { + const normalized = serviceId.replace(/-/g, '_') + expect(normalized).toBe(serviceId) + } + }) +}) diff --git a/apps/sim/ee/access-control/utils/permission-check.ts b/apps/sim/ee/access-control/utils/permission-check.ts index c3a5e05e8..4e74b1af8 100644 --- a/apps/sim/ee/access-control/utils/permission-check.ts +++ b/apps/sim/ee/access-control/utils/permission-check.ts @@ -3,8 +3,13 @@ import { member, permissionGroup, permissionGroupMember } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq } from 'drizzle-orm' import { isOrganizationOnEnterprisePlan } from '@/lib/billing' -import { isAccessControlEnabled, isHosted } from '@/lib/core/config/feature-flags' import { + getAllowedIntegrationsFromEnv, + isAccessControlEnabled, + isHosted, +} from '@/lib/core/config/feature-flags' +import { + DEFAULT_PERMISSION_GROUP_CONFIG, type PermissionGroupConfig, parsePermissionGroupConfig, } from '@/lib/permission-groups/types' @@ -23,8 +28,12 @@ export class ProviderNotAllowedError extends Error { } export class IntegrationNotAllowedError extends Error { - constructor(blockType: string) { - super(`Integration "${blockType}" is not allowed based on your permission group settings`) + constructor(blockType: string, reason?: string) { + super( + reason + ? `Integration "${blockType}" is not allowed: ${reason}` + : `Integration "${blockType}" is not allowed based on your permission group settings` + ) this.name = 'IntegrationNotAllowedError' } } @@ -57,11 +66,38 @@ export class InvitationsNotAllowedError extends Error { } } +/** + * Merges the env allowlist into a permission config. + * If `config` is null and no env allowlist is set, returns null. + * If `config` is null but env allowlist is set, returns a default config with only allowedIntegrations set. + * If both are set, intersects the two allowlists. + */ +function mergeEnvAllowlist(config: PermissionGroupConfig | null): PermissionGroupConfig | null { + const envAllowlist = getAllowedIntegrationsFromEnv() + + if (envAllowlist === null) { + return config + } + + if (config === null) { + return { ...DEFAULT_PERMISSION_GROUP_CONFIG, allowedIntegrations: envAllowlist } + } + + const merged = + config.allowedIntegrations === null + ? envAllowlist + : config.allowedIntegrations + .map((i) => i.toLowerCase()) + .filter((i) => envAllowlist.includes(i)) + + return { ...config, allowedIntegrations: merged } +} + export async function getUserPermissionConfig( userId: string ): Promise { if (!isHosted && !isAccessControlEnabled) { - return null + return mergeEnvAllowlist(null) } const [membership] = await db @@ -71,12 +107,12 @@ export async function getUserPermissionConfig( .limit(1) if (!membership) { - return null + return mergeEnvAllowlist(null) } const isEnterprise = await isOrganizationOnEnterprisePlan(membership.organizationId) if (!isEnterprise) { - return null + return mergeEnvAllowlist(null) } const [groupMembership] = await db @@ -92,10 +128,10 @@ export async function getUserPermissionConfig( .limit(1) if (!groupMembership) { - return null + return mergeEnvAllowlist(null) } - return parsePermissionGroupConfig(groupMembership.config) + return mergeEnvAllowlist(parsePermissionGroupConfig(groupMembership.config)) } export async function getPermissionConfig( @@ -152,19 +188,25 @@ export async function validateBlockType( return } - if (!userId) { - return - } - - const config = await getPermissionConfig(userId, ctx) + const config = userId ? await getPermissionConfig(userId, ctx) : mergeEnvAllowlist(null) if (!config || config.allowedIntegrations === null) { return } - if (!config.allowedIntegrations.includes(blockType)) { - logger.warn('Integration blocked by permission group', { userId, blockType }) - throw new IntegrationNotAllowedError(blockType) + if (!config.allowedIntegrations.includes(blockType.toLowerCase())) { + const envAllowlist = getAllowedIntegrationsFromEnv() + const blockedByEnv = envAllowlist !== null && !envAllowlist.includes(blockType.toLowerCase()) + logger.warn( + blockedByEnv + ? 'Integration blocked by env allowlist' + : 'Integration blocked by permission group', + { userId, blockType } + ) + throw new IntegrationNotAllowedError( + blockType, + blockedByEnv ? 'blocked by server ALLOWED_INTEGRATIONS policy' : undefined + ) } } diff --git a/apps/sim/executor/handlers/agent/agent-handler.test.ts b/apps/sim/executor/handlers/agent/agent-handler.test.ts index 217971b9e..75c22e8be 100644 --- a/apps/sim/executor/handlers/agent/agent-handler.test.ts +++ b/apps/sim/executor/handlers/agent/agent-handler.test.ts @@ -17,6 +17,7 @@ vi.mock('@/lib/core/config/feature-flags', () => ({ isDev: true, isTest: false, getCostMultiplier: vi.fn().mockReturnValue(1), + getAllowedIntegrationsFromEnv: vi.fn().mockReturnValue(null), isEmailVerificationEnabled: false, isBillingEnabled: false, isOrganizationsEnabled: false, diff --git a/apps/sim/hooks/use-permission-config.ts b/apps/sim/hooks/use-permission-config.ts index 3c536caf5..32c16e227 100644 --- a/apps/sim/hooks/use-permission-config.ts +++ b/apps/sim/hooks/use-permission-config.ts @@ -1,6 +1,7 @@ 'use client' import { useMemo } from 'react' +import { useQuery } from '@tanstack/react-query' import { getEnv, isTruthy } from '@/lib/core/config/env' import { isAccessControlEnabled, isHosted } from '@/lib/core/config/feature-flags' import { @@ -21,12 +22,44 @@ export interface PermissionConfigResult { isInvitationsDisabled: boolean } +interface AllowedIntegrationsResponse { + allowedIntegrations: string[] | null +} + +function useAllowedIntegrationsFromEnv() { + return useQuery({ + queryKey: ['allowedIntegrations', 'env'], + queryFn: async () => { + const response = await fetch('/api/settings/allowed-integrations') + if (!response.ok) return { allowedIntegrations: null } + return response.json() + }, + staleTime: 5 * 60 * 1000, + }) +} + +/** + * Intersects two allowlists. If either is null (unrestricted), returns the other. + * If both are set, returns only items present in both. + */ +function intersectAllowlists(a: string[] | null, b: string[] | null): string[] | null { + if (a === null) return b + if (b === null) return a + return a.map((i) => i.toLowerCase()).filter((i) => b.includes(i)) +} + export function usePermissionConfig(): PermissionConfigResult { const accessControlDisabled = !isHosted && !isAccessControlEnabled const { data: organizationsData } = useOrganizations() const activeOrganization = organizationsData?.activeOrganization - const { data: permissionData, isLoading } = useUserPermissionConfig(activeOrganization?.id) + const { data: permissionData, isLoading: isPermissionLoading } = useUserPermissionConfig( + activeOrganization?.id + ) + const { data: envAllowlistData, isLoading: isEnvAllowlistLoading } = + useAllowedIntegrationsFromEnv() + + const isLoading = isPermissionLoading || isEnvAllowlistLoading const config = useMemo(() => { if (accessControlDisabled) { @@ -40,13 +73,18 @@ export function usePermissionConfig(): PermissionConfigResult { const isInPermissionGroup = !accessControlDisabled && !!permissionData?.permissionGroupId + const mergedAllowedIntegrations = useMemo(() => { + const envAllowlist = envAllowlistData?.allowedIntegrations ?? null + return intersectAllowlists(config.allowedIntegrations, envAllowlist) + }, [config.allowedIntegrations, envAllowlistData]) + const isBlockAllowed = useMemo(() => { return (blockType: string) => { if (blockType === 'start_trigger') return true - if (config.allowedIntegrations === null) return true - return config.allowedIntegrations.includes(blockType) + if (mergedAllowedIntegrations === null) return true + return mergedAllowedIntegrations.includes(blockType.toLowerCase()) } - }, [config.allowedIntegrations]) + }, [mergedAllowedIntegrations]) const isProviderAllowed = useMemo(() => { return (providerId: string) => { @@ -57,13 +95,14 @@ export function usePermissionConfig(): PermissionConfigResult { const filterBlocks = useMemo(() => { return (blocks: T[]): T[] => { - if (config.allowedIntegrations === null) return blocks + if (mergedAllowedIntegrations === null) return blocks return blocks.filter( (block) => - block.type === 'start_trigger' || config.allowedIntegrations!.includes(block.type) + block.type === 'start_trigger' || + mergedAllowedIntegrations.includes(block.type.toLowerCase()) ) } - }, [config.allowedIntegrations]) + }, [mergedAllowedIntegrations]) const filterProviders = useMemo(() => { return (providerIds: string[]): string[] => { @@ -77,9 +116,14 @@ export function usePermissionConfig(): PermissionConfigResult { return featureFlagDisabled || config.disableInvitations }, [config.disableInvitations]) + const mergedConfig = useMemo( + () => ({ ...config, allowedIntegrations: mergedAllowedIntegrations }), + [config, mergedAllowedIntegrations] + ) + return useMemo( () => ({ - config, + config: mergedConfig, isLoading, isInPermissionGroup, filterBlocks, @@ -89,7 +133,7 @@ export function usePermissionConfig(): PermissionConfigResult { isInvitationsDisabled, }), [ - config, + mergedConfig, isLoading, isInPermissionGroup, filterBlocks, diff --git a/apps/sim/lib/copilot/process-contents.ts b/apps/sim/lib/copilot/process-contents.ts index 9e1eeb079..46709ef44 100644 --- a/apps/sim/lib/copilot/process-contents.ts +++ b/apps/sim/lib/copilot/process-contents.ts @@ -2,6 +2,7 @@ import { db } from '@sim/db' import { copilotChats, document, knowledgeBase, templates } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq, isNull } from 'drizzle-orm' +import { getAllowedIntegrationsFromEnv } from '@/lib/core/config/feature-flags' import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils' import { sanitizeForCopilot } from '@/lib/workflows/sanitization/json-sanitizer' import { isHiddenFromDisplay } from '@/blocks/types' @@ -349,16 +350,14 @@ async function processBlockMetadata( userId?: string ): Promise { try { - if (userId) { - const permissionConfig = await getUserPermissionConfig(userId) - const allowedIntegrations = permissionConfig?.allowedIntegrations - if (allowedIntegrations != null && !allowedIntegrations.includes(blockId)) { - logger.debug('Block not allowed by permission group', { blockId, userId }) - return null - } + const permissionConfig = userId ? await getUserPermissionConfig(userId) : null + const allowedIntegrations = + permissionConfig?.allowedIntegrations ?? getAllowedIntegrationsFromEnv() + if (allowedIntegrations != null && !allowedIntegrations.includes(blockId.toLowerCase())) { + logger.debug('Block not allowed by integration allowlist', { blockId, userId }) + return null } - // Reuse registry to match get_blocks_metadata tool result const { registry: blockRegistry } = await import('@/blocks/registry') const { tools: toolsRegistry } = await import('@/tools/registry') const SPECIAL_BLOCKS_METADATA: Record = {} @@ -466,7 +465,6 @@ async function processWorkflowBlockFromDb( if (!block) return null const tag = label ? `@${label} in Workflow` : `@${block.name || blockId} in Workflow` - // Build content: isolate the block and include its subBlocks fully const contentObj = { workflowId, block: block, @@ -518,7 +516,6 @@ async function processExecutionLogFromDb( endedAt: log.endedAt?.toISOString?.() || (log.endedAt ? String(log.endedAt) : null), totalDurationMs: log.totalDurationMs ?? null, workflowName: log.workflowName || '', - // Include trace spans and any available details without being huge executionData: log.executionData ? { traceSpans: (log.executionData as any).traceSpans || undefined, diff --git a/apps/sim/lib/copilot/tools/server/blocks/get-block-config.ts b/apps/sim/lib/copilot/tools/server/blocks/get-block-config.ts index 64021e07c..b9df7fa2c 100644 --- a/apps/sim/lib/copilot/tools/server/blocks/get-block-config.ts +++ b/apps/sim/lib/copilot/tools/server/blocks/get-block-config.ts @@ -6,6 +6,7 @@ import { GetBlockConfigResult, type GetBlockConfigResultType, } from '@/lib/copilot/tools/shared/schemas' +import { getAllowedIntegrationsFromEnv } from '@/lib/core/config/feature-flags' import { registry as blockRegistry, getLatestBlock } from '@/blocks/registry' import { isHiddenFromDisplay, type SubBlockConfig } from '@/blocks/types' import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' @@ -439,9 +440,10 @@ export const getBlockConfigServerTool: BaseServerTool< } const permissionConfig = context?.userId ? await getUserPermissionConfig(context.userId) : null - const allowedIntegrations = permissionConfig?.allowedIntegrations + const allowedIntegrations = + permissionConfig?.allowedIntegrations ?? getAllowedIntegrationsFromEnv() - if (allowedIntegrations != null && !allowedIntegrations.includes(blockType)) { + if (allowedIntegrations != null && !allowedIntegrations.includes(blockType.toLowerCase())) { throw new Error(`Block "${blockType}" is not available`) } diff --git a/apps/sim/lib/copilot/tools/server/blocks/get-block-options.ts b/apps/sim/lib/copilot/tools/server/blocks/get-block-options.ts index 49c0648b2..1e7772748 100644 --- a/apps/sim/lib/copilot/tools/server/blocks/get-block-options.ts +++ b/apps/sim/lib/copilot/tools/server/blocks/get-block-options.ts @@ -6,6 +6,7 @@ import { GetBlockOptionsResult, type GetBlockOptionsResultType, } from '@/lib/copilot/tools/shared/schemas' +import { getAllowedIntegrationsFromEnv } from '@/lib/core/config/feature-flags' import { registry as blockRegistry, getLatestBlock } from '@/blocks/registry' import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' import { tools as toolsRegistry } from '@/tools/registry' @@ -59,9 +60,10 @@ export const getBlockOptionsServerTool: BaseServerTool< } const permissionConfig = context?.userId ? await getUserPermissionConfig(context.userId) : null - const allowedIntegrations = permissionConfig?.allowedIntegrations + const allowedIntegrations = + permissionConfig?.allowedIntegrations ?? getAllowedIntegrationsFromEnv() - if (allowedIntegrations != null && !allowedIntegrations.includes(blockId)) { + if (allowedIntegrations != null && !allowedIntegrations.includes(blockId.toLowerCase())) { throw new Error(`Block "${blockId}" is not available`) } diff --git a/apps/sim/lib/copilot/tools/server/blocks/get-blocks-and-tools.ts b/apps/sim/lib/copilot/tools/server/blocks/get-blocks-and-tools.ts index e695f270e..bdd6ee256 100644 --- a/apps/sim/lib/copilot/tools/server/blocks/get-blocks-and-tools.ts +++ b/apps/sim/lib/copilot/tools/server/blocks/get-blocks-and-tools.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import type { BaseServerTool } from '@/lib/copilot/tools/server/base-tool' import { GetBlocksAndToolsInput, GetBlocksAndToolsResult } from '@/lib/copilot/tools/shared/schemas' +import { getAllowedIntegrationsFromEnv } from '@/lib/core/config/feature-flags' import { registry as blockRegistry } from '@/blocks/registry' import type { BlockConfig } from '@/blocks/types' import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' @@ -17,7 +18,8 @@ export const getBlocksAndToolsServerTool: BaseServerTool< logger.debug('Executing get_blocks_and_tools') const permissionConfig = context?.userId ? await getUserPermissionConfig(context.userId) : null - const allowedIntegrations = permissionConfig?.allowedIntegrations + const allowedIntegrations = + permissionConfig?.allowedIntegrations ?? getAllowedIntegrationsFromEnv() type BlockListItem = { type: string @@ -30,7 +32,8 @@ export const getBlocksAndToolsServerTool: BaseServerTool< Object.entries(blockRegistry) .filter(([blockType, blockConfig]: [string, BlockConfig]) => { if (blockConfig.hideFromToolbar) return false - if (allowedIntegrations != null && !allowedIntegrations.includes(blockType)) return false + if (allowedIntegrations != null && !allowedIntegrations.includes(blockType.toLowerCase())) + return false return true }) .forEach(([blockType, blockConfig]: [string, BlockConfig]) => { diff --git a/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.ts b/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.ts index ed6b60acb..4e6e7f6fe 100644 --- a/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.ts +++ b/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.ts @@ -3,6 +3,7 @@ import { join } from 'path' import { createLogger } from '@sim/logger' import type { BaseServerTool } from '@/lib/copilot/tools/server/base-tool' import { GetBlocksMetadataInput, GetBlocksMetadataResult } from '@/lib/copilot/tools/shared/schemas' +import { getAllowedIntegrationsFromEnv } from '@/lib/core/config/feature-flags' import { registry as blockRegistry } from '@/blocks/registry' import { AuthMode, type BlockConfig, isHiddenFromDisplay } from '@/blocks/types' import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' @@ -112,11 +113,12 @@ export const getBlocksMetadataServerTool: BaseServerTool< logger.debug('Executing get_blocks_metadata', { count: blockIds?.length }) const permissionConfig = context?.userId ? await getUserPermissionConfig(context.userId) : null - const allowedIntegrations = permissionConfig?.allowedIntegrations + const allowedIntegrations = + permissionConfig?.allowedIntegrations ?? getAllowedIntegrationsFromEnv() const result: Record = {} for (const blockId of blockIds || []) { - if (allowedIntegrations != null && !allowedIntegrations.includes(blockId)) { + if (allowedIntegrations != null && !allowedIntegrations.includes(blockId.toLowerCase())) { logger.debug('Block not allowed by permission group', { blockId }) continue } @@ -420,7 +422,6 @@ function extractInputs(metadata: CopilotBlockMetadata): { } if (schema.options && schema.options.length > 0) { - // Always return the id (actual value to use), not the display label input.options = schema.options.map((opt) => opt.id || opt.label) } diff --git a/apps/sim/lib/copilot/tools/server/blocks/get-trigger-blocks.ts b/apps/sim/lib/copilot/tools/server/blocks/get-trigger-blocks.ts index 367c61475..df77ad6f7 100644 --- a/apps/sim/lib/copilot/tools/server/blocks/get-trigger-blocks.ts +++ b/apps/sim/lib/copilot/tools/server/blocks/get-trigger-blocks.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { z } from 'zod' import type { BaseServerTool } from '@/lib/copilot/tools/server/base-tool' +import { getAllowedIntegrationsFromEnv } from '@/lib/core/config/feature-flags' import { registry as blockRegistry } from '@/blocks/registry' import type { BlockConfig } from '@/blocks/types' import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' @@ -22,13 +23,15 @@ export const getTriggerBlocksServerTool: BaseServerTool< logger.debug('Executing get_trigger_blocks') const permissionConfig = context?.userId ? await getUserPermissionConfig(context.userId) : null - const allowedIntegrations = permissionConfig?.allowedIntegrations + const allowedIntegrations = + permissionConfig?.allowedIntegrations ?? getAllowedIntegrationsFromEnv() const triggerBlockIds: string[] = [] Object.entries(blockRegistry).forEach(([blockType, blockConfig]: [string, BlockConfig]) => { if (blockConfig.hideFromToolbar) return - if (allowedIntegrations != null && !allowedIntegrations.includes(blockType)) return + if (allowedIntegrations != null && !allowedIntegrations.includes(blockType.toLowerCase())) + return if (blockConfig.category === 'triggers') { triggerBlockIds.push(blockType) diff --git a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.ts b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.ts index 424be9d25..18b787ba9 100644 --- a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.ts +++ b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.ts @@ -657,7 +657,7 @@ export function isBlockTypeAllowed( if (!permissionConfig || permissionConfig.allowedIntegrations === null) { return true } - return permissionConfig.allowedIntegrations.includes(blockType) + return permissionConfig.allowedIntegrations.includes(blockType.toLowerCase()) } /** diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index 8d06f938c..7d6a414ba 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -94,6 +94,7 @@ export const env = createEnv({ BLACKLISTED_PROVIDERS: z.string().optional(), // Comma-separated provider IDs to hide (e.g., "openai,anthropic") BLACKLISTED_MODELS: z.string().optional(), // Comma-separated model names/prefixes to hide (e.g., "gpt-4,claude-*") ALLOWED_MCP_DOMAINS: z.string().optional(), // Comma-separated domains for MCP servers (e.g., "internal.company.com,mcp.example.org"). Empty = all allowed. + ALLOWED_INTEGRATIONS: z.string().optional(), // Comma-separated block types to allow (e.g., "slack,github,agent"). Empty = all allowed. // Azure Configuration - Shared credentials with feature-specific models AZURE_OPENAI_ENDPOINT: z.string().url().optional(), // Shared Azure OpenAI service endpoint diff --git a/apps/sim/lib/core/config/feature-flags.ts b/apps/sim/lib/core/config/feature-flags.ts index 3ff517fb8..75dc1a177 100644 --- a/apps/sim/lib/core/config/feature-flags.ts +++ b/apps/sim/lib/core/config/feature-flags.ts @@ -123,6 +123,18 @@ export const isReactGrabEnabled = isDev && isTruthy(env.REACT_GRAB_ENABLED) */ export const isReactScanEnabled = isDev && isTruthy(env.REACT_SCAN_ENABLED) +/** + * Returns the parsed allowlist of integration block types from the environment variable. + * If not set or empty, returns null (meaning all integrations are allowed). + */ +export function getAllowedIntegrationsFromEnv(): string[] | null { + if (!env.ALLOWED_INTEGRATIONS) return null + const parsed = env.ALLOWED_INTEGRATIONS.split(',') + .map((i) => i.trim().toLowerCase()) + .filter(Boolean) + return parsed.length > 0 ? parsed : null +} + /** * Normalizes a domain entry from the ALLOWED_MCP_DOMAINS env var. * Accepts bare hostnames (e.g., "mcp.company.com") or full URLs (e.g., "https://mcp.company.com"). diff --git a/helm/sim/values.yaml b/helm/sim/values.yaml index dc7b2081a..61001c6fb 100644 --- a/helm/sim/values.yaml +++ b/helm/sim/values.yaml @@ -195,6 +195,9 @@ app: BLACKLISTED_MODELS: "" # Comma-separated model names/prefixes to hide (e.g., "gpt-4,claude-*") ALLOWED_MCP_DOMAINS: "" # Comma-separated domains for MCP servers (e.g., "internal.company.com,mcp.example.org"). Empty = all allowed. + # Integration/Block Restrictions (leave empty if not restricting) + ALLOWED_INTEGRATIONS: "" # Comma-separated block types to allow (e.g., "slack,github,agent"). Empty = all allowed. + # Invitation Control DISABLE_INVITATIONS: "" # Set to "true" to disable workspace invitations globally NEXT_PUBLIC_DISABLE_INVITATIONS: "" # Set to "true" to hide invitation UI elements From eab01e0272e544122ef79d213d772349189463df Mon Sep 17 00:00:00 2001 From: Emir Karabeg <78010029+emir-karabeg@users.noreply.github.com> Date: Tue, 17 Feb 2026 18:47:07 -0800 Subject: [PATCH 05/10] fix(copilot): copilot shortcut conflict (#3219) * fix: prevent copilot keyboard shortcuts from triggering when panel is inactive The OptionsSelector component was capturing keyboard events (1-9 number keys and Enter) globally on the document, causing accidental option selections when users were interacting with other parts of the application. This fix adds a check to only handle keyboard shortcuts when the copilot panel is the active tab, preventing the shortcuts from interfering with other workflows. Co-authored-by: Emir Karabeg * lint --------- Co-authored-by: Cursor Agent Co-authored-by: Emir Karabeg Co-authored-by: Waleed Latif --- .../copilot/components/tool-call/tool-call.tsx | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/tool-call/tool-call.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/tool-call/tool-call.tsx index c7f103209..53af845ef 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/tool-call/tool-call.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/tool-call/tool-call.tsx @@ -23,7 +23,7 @@ import { ParallelTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/compo import { getDisplayValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block' import { getBlock } from '@/blocks/registry' import type { CopilotToolCall } from '@/stores/panel' -import { useCopilotStore } from '@/stores/panel' +import { useCopilotStore, usePanelStore } from '@/stores/panel' import type { SubAgentContentBlock } from '@/stores/panel/copilot/types' import { useWorkflowStore } from '@/stores/workflows/workflow/store' @@ -341,16 +341,20 @@ export function OptionsSelector({ const [hoveredIndex, setHoveredIndex] = useState(-1) const [chosenKey, setChosenKey] = useState(selectedOptionKey) const containerRef = useRef(null) + const activeTab = usePanelStore((s) => s.activeTab) const isLocked = chosenKey !== null - // Handle keyboard navigation - only for the active options selector + // Handle keyboard navigation - only for the active options selector when copilot is active useEffect(() => { if (isInteractionDisabled || !enableKeyboardNav || isLocked) return const handleKeyDown = (e: KeyboardEvent) => { if (e.defaultPrevented) return + // Only handle keyboard shortcuts when the copilot panel is active + if (activeTab !== 'copilot') return + const activeElement = document.activeElement const isInputFocused = activeElement?.tagName === 'INPUT' || @@ -387,7 +391,15 @@ export function OptionsSelector({ document.addEventListener('keydown', handleKeyDown) return () => document.removeEventListener('keydown', handleKeyDown) - }, [isInteractionDisabled, enableKeyboardNav, isLocked, sortedOptions, hoveredIndex, onSelect]) + }, [ + isInteractionDisabled, + enableKeyboardNav, + isLocked, + sortedOptions, + hoveredIndex, + onSelect, + activeTab, + ]) if (sortedOptions.length === 0) return null From 11f3a14c026990a3fe34e06a850c13c2893fa951 Mon Sep 17 00:00:00 2001 From: Waleed Date: Wed, 18 Feb 2026 00:32:09 -0800 Subject: [PATCH 06/10] fix(lock): prevent socket crash when locking agent blocks (#3245) --- .../components/panel/components/editor/editor.tsx | 9 +++++++++ apps/sim/socket/handlers/subblocks.ts | 10 ++++++++++ 2 files changed, 19 insertions(+) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/editor.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/editor.tsx index 6d7829c17..42e88b22a 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/editor.tsx @@ -618,6 +618,15 @@ export function Editor() {
)} + {hasAdvancedOnlyFields && !canEditBlock && displayAdvancedOptions && ( +
+
+ + Additional fields + +
+
+ )} {advancedOnlySubBlocks.map((subBlock, index) => { const stableKey = getSubBlockStableKey( diff --git a/apps/sim/socket/handlers/subblocks.ts b/apps/sim/socket/handlers/subblocks.ts index 0c4e6e449..997f8416c 100644 --- a/apps/sim/socket/handlers/subblocks.ts +++ b/apps/sim/socket/handlers/subblocks.ts @@ -232,6 +232,7 @@ async function flushSubblockUpdate( } let updateSuccessful = false + let blockLocked = false await db.transaction(async (tx) => { const [block] = await tx .select({ @@ -250,6 +251,7 @@ async function flushSubblockUpdate( // Check if block is locked directly if (block.locked) { logger.info(`Skipping subblock update - block ${blockId} is locked`) + blockLocked = true return } @@ -266,6 +268,7 @@ async function flushSubblockUpdate( if (parentBlock?.locked) { logger.info(`Skipping subblock update - parent ${parentId} is locked`) + blockLocked = true return } } @@ -308,6 +311,13 @@ async function flushSubblockUpdate( serverTimestamp: Date.now(), }) }) + } else if (blockLocked) { + pending.opToSocket.forEach((socketId, opId) => { + io.to(socketId).emit('operation-confirmed', { + operationId: opId, + serverTimestamp: Date.now(), + }) + }) } else { pending.opToSocket.forEach((socketId, opId) => { io.to(socketId).emit('operation-failed', { From e37b4a926d45debdbbba09c273df201b75e258f1 Mon Sep 17 00:00:00 2001 From: Waleed Date: Wed, 18 Feb 2026 00:54:52 -0800 Subject: [PATCH 07/10] feat(audit-log): add persistent audit log system with comprehensive route instrumentation (#3242) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(audit-log): add persistent audit log system with comprehensive route instrumentation * fix(audit-log): address PR review — nullable workspaceId, enum usage, remove redundant queries - Make audit_log.workspace_id nullable with ON DELETE SET NULL (logs survive workspace/user deletion) - Make audit_log.actor_id nullable with ON DELETE SET NULL - Replace all 53 routes' string literal action/resourceType with AuditAction.X and AuditResourceType.X enums - Fix empty workspaceId ('') → null for OAuth, form, and org routes to avoid FK violations - Remove redundant DB queries in chat manage route (use checkChatAccess return data) - Fix organization routes to pass workspaceId: null instead of organizationId * fix(audit-log): replace remaining workspaceId '' fallbacks with null * fix(audit-log): credential-set org IDs, workspace deletion FK, actorId fallback, string literal action * reran migrations * fix(mcp,audit): tighten env var domain bypass, add post-resolution check, form workspaceId - Only bypass MCP domain check when env var is in hostname/authority, not path/query - Add post-resolution validateMcpDomain call in test-connection endpoint - Match client-side isDomainAllowed to same hostname-only bypass logic - Return workspaceId from checkFormAccess, use in form audit logs - Add 49 comprehensive domain-check tests covering all edge cases * fix(mcp): stateful regex lastIndex bug, RFC 3986 authority parsing - Remove /g flag from module-level ENV_VAR_PATTERN to avoid lastIndex state - Create fresh regex instances per call in server-side hasEnvVarInHostname - Fix authority extraction to terminate at /, ?, or # per RFC 3986 - Prevents bypass via https://evil.com?token={{SECRET}} (no path) - Add test cases for query-only and fragment-only env var URLs (53 total) * fix(audit-log): try/catch for never-throw contract, accept null actorName/Email, fix misleading action - Wrap recordAudit body in try/catch so nanoid() or header extraction can't throw - Accept string | null for actorName and actorEmail (session.user.name can be null) - Normalize null -> undefined before insert to match DB column types - Fix org members route: ORG_MEMBER_ADDED -> ORG_INVITATION_CREATED (sends invite, not adds member) * improvement(audit-log): add resource names and specific invitation actions * fix(audit-log): use validated chat record, add mock sync tests --- .../api/auth/oauth/disconnect/route.test.ts | 4 +- .../app/api/auth/oauth/disconnect/route.ts | 15 + .../app/api/chat/manage/[id]/route.test.ts | 32 +- apps/sim/app/api/chat/manage/[id]/route.ts | 39 +- apps/sim/app/api/chat/route.test.ts | 5 +- apps/sim/app/api/chat/route.ts | 19 +- apps/sim/app/api/chat/utils.ts | 6 +- .../api/credential-sets/[id]/invite/route.ts | 27 + .../api/credential-sets/[id]/members/route.ts | 15 + .../sim/app/api/credential-sets/[id]/route.ts | 27 + apps/sim/app/api/credential-sets/route.ts | 14 + .../app/api/folders/[id]/duplicate/route.ts | 14 + apps/sim/app/api/folders/[id]/route.test.ts | 3 + apps/sim/app/api/folders/[id]/route.ts | 14 + apps/sim/app/api/folders/route.test.ts | 10 +- apps/sim/app/api/folders/route.ts | 15 + apps/sim/app/api/form/manage/[id]/route.ts | 39 +- apps/sim/app/api/form/route.ts | 16 +- apps/sim/app/api/form/utils.ts | 6 +- .../[id]/documents/[documentId]/route.test.ts | 3 + .../[id]/documents/[documentId]/route.ts | 23 + .../knowledge/[id]/documents/route.test.ts | 3 + .../app/api/knowledge/[id]/documents/route.ts | 23 + apps/sim/app/api/knowledge/[id]/route.test.ts | 3 + apps/sim/app/api/knowledge/[id]/route.ts | 23 + apps/sim/app/api/knowledge/route.test.ts | 3 + apps/sim/app/api/knowledge/route.ts | 15 + apps/sim/app/api/knowledge/utils.ts | 8 +- apps/sim/app/api/mcp/servers/[id]/route.ts | 13 + apps/sim/app/api/mcp/servers/route.ts | 25 + .../api/mcp/servers/test-connection/route.ts | 10 + .../api/mcp/workflow-servers/[id]/route.ts | 23 + .../[id]/tools/[toolId]/route.ts | 23 + .../mcp/workflow-servers/[id]/tools/route.ts | 12 + .../sim/app/api/mcp/workflow-servers/route.ts | 12 + .../[id]/invitations/[invitationId]/route.ts | 20 + .../organizations/[id]/invitations/route.ts | 30 + .../[id]/members/[memberId]/route.ts | 30 + .../api/organizations/[id]/members/route.ts | 14 + apps/sim/app/api/organizations/[id]/route.ts | 15 + apps/sim/app/api/organizations/route.ts | 14 + .../permission-groups/[id]/members/route.ts | 30 + .../app/api/permission-groups/[id]/route.ts | 27 + apps/sim/app/api/permission-groups/route.ts | 14 + apps/sim/app/api/schedules/[id]/route.test.ts | 4 +- apps/sim/app/api/schedules/[id]/route.ts | 13 + .../app/api/users/me/api-keys/[id]/route.ts | 18 +- apps/sim/app/api/users/me/api-keys/route.ts | 14 + apps/sim/app/api/webhooks/[id]/route.ts | 13 + apps/sim/app/api/webhooks/route.ts | 13 + .../app/api/workflows/[id]/deploy/route.ts | 37 +- .../deployments/[version]/revert/route.ts | 20 +- .../app/api/workflows/[id]/duplicate/route.ts | 13 + apps/sim/app/api/workflows/[id]/route.test.ts | 4 +- apps/sim/app/api/workflows/[id]/route.ts | 12 + .../workflows/[id]/variables/route.test.ts | 3 + .../app/api/workflows/[id]/variables/route.ts | 12 + apps/sim/app/api/workflows/route.ts | 13 + .../workspaces/[id]/api-keys/[keyId]/route.ts | 31 +- .../app/api/workspaces/[id]/api-keys/route.ts | 28 + .../api/workspaces/[id]/byok-keys/route.ts | 28 + .../api/workspaces/[id]/duplicate/route.ts | 14 + .../api/workspaces/[id]/environment/route.ts | 14 + .../workspaces/[id]/files/[fileId]/route.ts | 13 + .../app/api/workspaces/[id]/files/route.ts | 14 + .../notifications/[notificationId]/route.ts | 34 +- .../workspaces/[id]/notifications/route.ts | 14 + .../api/workspaces/[id]/permissions/route.ts | 16 + apps/sim/app/api/workspaces/[id]/route.ts | 21 + .../invitations/[invitationId]/route.test.ts | 4 +- .../invitations/[invitationId]/route.ts | 27 + .../api/workspaces/invitations/route.test.ts | 4 +- .../app/api/workspaces/invitations/route.ts | 15 + .../app/api/workspaces/members/[id]/route.ts | 14 + apps/sim/app/api/workspaces/route.ts | 15 + .../settings-modal/components/mcp/mcp.tsx | 27 +- apps/sim/lib/audit/log.test.ts | 272 + apps/sim/lib/audit/log.ts | 207 + apps/sim/lib/mcp/domain-check.test.ts | 262 +- apps/sim/lib/mcp/domain-check.ts | 49 +- apps/sim/lib/mcp/service.ts | 3 +- packages/db/migrations/0155_strong_spyke.sql | 23 + .../db/migrations/meta/0155_snapshot.json | 11154 ++++++++++++++++ packages/db/migrations/meta/_journal.json | 7 + packages/db/schema.ts | 29 + packages/testing/src/index.ts | 1 + packages/testing/src/mocks/audit.mock.ts | 108 + packages/testing/src/mocks/index.ts | 2 + 88 files changed, 13250 insertions(+), 125 deletions(-) create mode 100644 apps/sim/lib/audit/log.test.ts create mode 100644 apps/sim/lib/audit/log.ts create mode 100644 packages/db/migrations/0155_strong_spyke.sql create mode 100644 packages/db/migrations/meta/0155_snapshot.json create mode 100644 packages/testing/src/mocks/audit.mock.ts 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 9a504982a..2105b8370 100644 --- a/apps/sim/app/api/auth/oauth/disconnect/route.test.ts +++ b/apps/sim/app/api/auth/oauth/disconnect/route.test.ts @@ -3,7 +3,7 @@ * * @vitest-environment node */ -import { createMockLogger, createMockRequest } from '@sim/testing' +import { auditMock, createMockLogger, createMockRequest } from '@sim/testing' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' describe('OAuth Disconnect API Route', () => { @@ -67,6 +67,8 @@ describe('OAuth Disconnect API Route', () => { vi.doMock('@/lib/webhooks/utils.server', () => ({ syncAllWebhooksForCredentialSet: mockSyncAllWebhooksForCredentialSet, })) + + vi.doMock('@/lib/audit/log', () => auditMock) }) afterEach(() => { diff --git a/apps/sim/app/api/auth/oauth/disconnect/route.ts b/apps/sim/app/api/auth/oauth/disconnect/route.ts index be645aa73..ab35816c7 100644 --- a/apps/sim/app/api/auth/oauth/disconnect/route.ts +++ b/apps/sim/app/api/auth/oauth/disconnect/route.ts @@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger' import { and, eq, like, or } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' +import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' import { syncAllWebhooksForCredentialSet } from '@/lib/webhooks/utils.server' @@ -118,6 +119,20 @@ export async function POST(request: NextRequest) { } } + recordAudit({ + workspaceId: null, + actorId: session.user.id, + action: AuditAction.OAUTH_DISCONNECTED, + resourceType: AuditResourceType.OAUTH, + resourceId: providerId ?? provider, + actorName: session.user.name ?? undefined, + actorEmail: session.user.email ?? undefined, + resourceName: provider, + description: `Disconnected OAuth provider: ${provider}`, + metadata: { provider, providerId }, + request, + }) + return NextResponse.json({ success: true }, { status: 200 }) } catch (error) { logger.error(`[${requestId}] Error disconnecting OAuth provider`, error) diff --git a/apps/sim/app/api/chat/manage/[id]/route.test.ts b/apps/sim/app/api/chat/manage/[id]/route.test.ts index 12e6b01a9..71e92f957 100644 --- a/apps/sim/app/api/chat/manage/[id]/route.test.ts +++ b/apps/sim/app/api/chat/manage/[id]/route.test.ts @@ -3,10 +3,12 @@ * * @vitest-environment node */ -import { loggerMock } from '@sim/testing' +import { auditMock, loggerMock } from '@sim/testing' import { NextRequest } from 'next/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +vi.mock('@/lib/audit/log', () => auditMock) + vi.mock('@/lib/core/config/feature-flags', () => ({ isDev: true, isHosted: false, @@ -216,8 +218,11 @@ describe('Chat Edit API Route', () => { workflowId: 'workflow-123', } - mockCheckChatAccess.mockResolvedValue({ hasAccess: true, chat: mockChat }) - mockLimit.mockResolvedValueOnce([]) // No identifier conflict + mockCheckChatAccess.mockResolvedValue({ + hasAccess: true, + chat: mockChat, + workspaceId: 'workspace-123', + }) const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123', { method: 'PATCH', @@ -311,8 +316,11 @@ describe('Chat Edit API Route', () => { workflowId: 'workflow-123', } - mockCheckChatAccess.mockResolvedValue({ hasAccess: true, chat: mockChat }) - mockLimit.mockResolvedValueOnce([]) + mockCheckChatAccess.mockResolvedValue({ + hasAccess: true, + chat: mockChat, + workspaceId: 'workspace-123', + }) const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123', { method: 'PATCH', @@ -371,8 +379,11 @@ describe('Chat Edit API Route', () => { }), })) - mockCheckChatAccess.mockResolvedValue({ hasAccess: true }) - mockWhere.mockResolvedValue(undefined) + mockCheckChatAccess.mockResolvedValue({ + hasAccess: true, + chat: { title: 'Test Chat', workflowId: 'workflow-123' }, + workspaceId: 'workspace-123', + }) const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123', { method: 'DELETE', @@ -393,8 +404,11 @@ describe('Chat Edit API Route', () => { }), })) - mockCheckChatAccess.mockResolvedValue({ hasAccess: true }) - mockWhere.mockResolvedValue(undefined) + mockCheckChatAccess.mockResolvedValue({ + hasAccess: true, + chat: { title: 'Test Chat', workflowId: 'workflow-123' }, + workspaceId: 'workspace-123', + }) const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123', { method: 'DELETE', diff --git a/apps/sim/app/api/chat/manage/[id]/route.ts b/apps/sim/app/api/chat/manage/[id]/route.ts index 236ae1001..6f09bd210 100644 --- a/apps/sim/app/api/chat/manage/[id]/route.ts +++ b/apps/sim/app/api/chat/manage/[id]/route.ts @@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger' import { eq } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { z } from 'zod' +import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { isDev } from '@/lib/core/config/feature-flags' import { encryptSecret } from '@/lib/core/security/encryption' @@ -103,7 +104,11 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise< try { const validatedData = chatUpdateSchema.parse(body) - const { hasAccess, chat: existingChatRecord } = await checkChatAccess(chatId, session.user.id) + const { + hasAccess, + chat: existingChatRecord, + workspaceId: chatWorkspaceId, + } = await checkChatAccess(chatId, session.user.id) if (!hasAccess || !existingChatRecord) { return createErrorResponse('Chat not found or access denied', 404) @@ -217,6 +222,19 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise< logger.info(`Chat "${chatId}" updated successfully`) + recordAudit({ + workspaceId: chatWorkspaceId || null, + actorId: session.user.id, + actorName: session.user.name, + actorEmail: session.user.email, + action: AuditAction.CHAT_UPDATED, + resourceType: AuditResourceType.CHAT, + resourceId: chatId, + resourceName: title || existingChatRecord.title, + description: `Updated chat deployment "${title || existingChatRecord.title}"`, + request, + }) + return createSuccessResponse({ id: chatId, chatUrl, @@ -252,7 +270,11 @@ export async function DELETE( return createErrorResponse('Unauthorized', 401) } - const { hasAccess } = await checkChatAccess(chatId, session.user.id) + const { + hasAccess, + chat: chatRecord, + workspaceId: chatWorkspaceId, + } = await checkChatAccess(chatId, session.user.id) if (!hasAccess) { return createErrorResponse('Chat not found or access denied', 404) @@ -262,6 +284,19 @@ export async function DELETE( logger.info(`Chat "${chatId}" deleted successfully`) + recordAudit({ + workspaceId: chatWorkspaceId || null, + actorId: session.user.id, + actorName: session.user.name, + actorEmail: session.user.email, + action: AuditAction.CHAT_DELETED, + resourceType: AuditResourceType.CHAT, + resourceId: chatId, + resourceName: chatRecord?.title || chatId, + description: `Deleted chat deployment "${chatRecord?.title || chatId}"`, + request: _request, + }) + return createSuccessResponse({ message: 'Chat deployment deleted successfully', }) diff --git a/apps/sim/app/api/chat/route.test.ts b/apps/sim/app/api/chat/route.test.ts index 4fb96da4e..0dfc2df5e 100644 --- a/apps/sim/app/api/chat/route.test.ts +++ b/apps/sim/app/api/chat/route.test.ts @@ -1,9 +1,10 @@ -import { NextRequest } from 'next/server' /** * Tests for chat API route * * @vitest-environment node */ +import { auditMock } from '@sim/testing' +import { NextRequest } from 'next/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' describe('Chat API Route', () => { @@ -30,6 +31,8 @@ describe('Chat API Route', () => { mockInsert.mockReturnValue({ values: mockValues }) mockValues.mockReturnValue({ returning: mockReturning }) + vi.doMock('@/lib/audit/log', () => auditMock) + vi.doMock('@sim/db', () => ({ db: { select: mockSelect, diff --git a/apps/sim/app/api/chat/route.ts b/apps/sim/app/api/chat/route.ts index e9ad9c079..dd04f292e 100644 --- a/apps/sim/app/api/chat/route.ts +++ b/apps/sim/app/api/chat/route.ts @@ -5,6 +5,7 @@ import { eq } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { v4 as uuidv4 } from 'uuid' import { z } from 'zod' +import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { isDev } from '@/lib/core/config/feature-flags' import { encryptSecret } from '@/lib/core/security/encryption' @@ -42,7 +43,7 @@ const chatSchema = z.object({ .default([]), }) -export async function GET(request: NextRequest) { +export async function GET(_request: NextRequest) { try { const session = await getSession() @@ -174,7 +175,7 @@ export async function POST(request: NextRequest) { userId: session.user.id, identifier, title, - description: description || '', + description: description || null, customizations: mergedCustomizations, isActive: true, authType, @@ -224,6 +225,20 @@ export async function POST(request: NextRequest) { // Silently fail } + recordAudit({ + workspaceId: workflowRecord.workspaceId || null, + actorId: session.user.id, + actorName: session.user.name, + actorEmail: session.user.email, + action: AuditAction.CHAT_DEPLOYED, + resourceType: AuditResourceType.CHAT, + resourceId: id, + resourceName: title, + description: `Deployed chat "${title}"`, + metadata: { workflowId, identifier, authType }, + request, + }) + return createSuccessResponse({ id, chatUrl, diff --git a/apps/sim/app/api/chat/utils.ts b/apps/sim/app/api/chat/utils.ts index e6c5a5521..ae9b7f443 100644 --- a/apps/sim/app/api/chat/utils.ts +++ b/apps/sim/app/api/chat/utils.ts @@ -52,7 +52,7 @@ export async function checkWorkflowAccessForChatCreation( export async function checkChatAccess( chatId: string, userId: string -): Promise<{ hasAccess: boolean; chat?: any }> { +): Promise<{ hasAccess: boolean; chat?: any; workspaceId?: string }> { const chatData = await db .select({ chat: chat, @@ -78,7 +78,9 @@ export async function checkChatAccess( action: 'admin', }) - return authorization.allowed ? { hasAccess: true, chat: chatRecord } : { hasAccess: false } + return authorization.allowed + ? { hasAccess: true, chat: chatRecord, workspaceId: workflowWorkspaceId } + : { hasAccess: false } } export async function validateChatAuth( diff --git a/apps/sim/app/api/credential-sets/[id]/invite/route.ts b/apps/sim/app/api/credential-sets/[id]/invite/route.ts index 3a2e59df5..914dd37ac 100644 --- a/apps/sim/app/api/credential-sets/[id]/invite/route.ts +++ b/apps/sim/app/api/credential-sets/[id]/invite/route.ts @@ -5,6 +5,7 @@ import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { getEmailSubject, renderPollingGroupInvitationEmail } from '@/components/emails' +import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { hasCredentialSetsAccess } from '@/lib/billing' import { getBaseUrl } from '@/lib/core/utils/urls' @@ -175,6 +176,19 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ 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}` : ''}`, + request: req, + }) + return NextResponse.json({ invitation: { ...invitation, @@ -235,6 +249,19 @@ export async function DELETE(req: NextRequest, { params }: { params: Promise<{ i ) ) + 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}"`, + request: req, + }) + return NextResponse.json({ success: true }) } catch (error) { logger.error('Error cancelling invitation', error) diff --git a/apps/sim/app/api/credential-sets/[id]/members/route.ts b/apps/sim/app/api/credential-sets/[id]/members/route.ts index c09d39f86..2ea8b1889 100644 --- a/apps/sim/app/api/credential-sets/[id]/members/route.ts +++ b/apps/sim/app/api/credential-sets/[id]/members/route.ts @@ -3,6 +3,7 @@ import { account, credentialSet, credentialSetMember, member, user } from '@sim/ import { createLogger } from '@sim/logger' import { and, eq, inArray } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' +import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { hasCredentialSetsAccess } from '@/lib/billing' import { syncAllWebhooksForCredentialSet } from '@/lib/webhooks/utils.server' @@ -13,6 +14,7 @@ async function getCredentialSetWithAccess(credentialSetId: string, userId: strin const [set] = await db .select({ id: credentialSet.id, + name: credentialSet.name, organizationId: credentialSet.organizationId, providerId: credentialSet.providerId, }) @@ -177,6 +179,19 @@ export async function DELETE(req: NextRequest, { params }: { params: Promise<{ i 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}"`, + request: req, + }) + return NextResponse.json({ success: true }) } catch (error) { logger.error('Error removing member from credential set', error) diff --git a/apps/sim/app/api/credential-sets/[id]/route.ts b/apps/sim/app/api/credential-sets/[id]/route.ts index fb40336fd..51110916e 100644 --- a/apps/sim/app/api/credential-sets/[id]/route.ts +++ b/apps/sim/app/api/credential-sets/[id]/route.ts @@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger' import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' +import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { hasCredentialSetsAccess } from '@/lib/billing' @@ -131,6 +132,19 @@ export async function PUT(req: NextRequest, { params }: { params: Promise<{ 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}"`, + request: req, + }) + return NextResponse.json({ credentialSet: updated }) } catch (error) { if (error instanceof z.ZodError) { @@ -175,6 +189,19 @@ export async function DELETE(req: NextRequest, { params }: { params: Promise<{ i 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}"`, + request: req, + }) + return NextResponse.json({ success: true }) } catch (error) { logger.error('Error deleting credential set', error) diff --git a/apps/sim/app/api/credential-sets/route.ts b/apps/sim/app/api/credential-sets/route.ts index 68a5e5b9d..621f651b6 100644 --- a/apps/sim/app/api/credential-sets/route.ts +++ b/apps/sim/app/api/credential-sets/route.ts @@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger' import { and, count, desc, eq } from 'drizzle-orm' import { NextResponse } from 'next/server' import { z } from 'zod' +import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { hasCredentialSetsAccess } from '@/lib/billing' @@ -165,6 +166,19 @@ export async function POST(req: Request) { 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}"`, + request: req, + }) + return NextResponse.json({ credentialSet: newCredentialSet }, { status: 201 }) } catch (error) { if (error instanceof z.ZodError) { diff --git a/apps/sim/app/api/folders/[id]/duplicate/route.ts b/apps/sim/app/api/folders/[id]/duplicate/route.ts index 60b3e9996..92bc5ed75 100644 --- a/apps/sim/app/api/folders/[id]/duplicate/route.ts +++ b/apps/sim/app/api/folders/[id]/duplicate/route.ts @@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger' import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' +import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' import { duplicateWorkflow } from '@/lib/workflows/persistence/duplicate' @@ -115,6 +116,19 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: } ) + recordAudit({ + workspaceId: targetWorkspaceId, + actorId: session.user.id, + action: AuditAction.FOLDER_DUPLICATED, + resourceType: AuditResourceType.FOLDER, + resourceId: newFolderId, + actorName: session.user.name ?? undefined, + actorEmail: session.user.email ?? undefined, + resourceName: name, + description: `Duplicated folder "${sourceFolder.name}" as "${name}"`, + request: req, + }) + return NextResponse.json( { id: newFolderId, diff --git a/apps/sim/app/api/folders/[id]/route.test.ts b/apps/sim/app/api/folders/[id]/route.test.ts index ce2522880..77a5ab269 100644 --- a/apps/sim/app/api/folders/[id]/route.test.ts +++ b/apps/sim/app/api/folders/[id]/route.test.ts @@ -4,6 +4,7 @@ * @vitest-environment node */ import { + auditMock, createMockRequest, type MockUser, mockAuth, @@ -12,6 +13,8 @@ import { } from '@sim/testing' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +vi.mock('@/lib/audit/log', () => auditMock) + /** Type for captured folder values in tests */ interface CapturedFolderValues { name?: string diff --git a/apps/sim/app/api/folders/[id]/route.ts b/apps/sim/app/api/folders/[id]/route.ts index 35d41f693..64fb830e2 100644 --- a/apps/sim/app/api/folders/[id]/route.ts +++ b/apps/sim/app/api/folders/[id]/route.ts @@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger' import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' +import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' @@ -167,6 +168,19 @@ export async function DELETE( deletionStats, }) + recordAudit({ + workspaceId: existingFolder.workspaceId, + actorId: session.user.id, + actorName: session.user.name, + actorEmail: session.user.email, + action: AuditAction.FOLDER_DELETED, + resourceType: AuditResourceType.FOLDER, + resourceId: id, + resourceName: existingFolder.name, + description: `Deleted folder "${existingFolder.name}"`, + request, + }) + return NextResponse.json({ success: true, deletedItems: deletionStats, diff --git a/apps/sim/app/api/folders/route.test.ts b/apps/sim/app/api/folders/route.test.ts index 6ad39d75e..89c0ad451 100644 --- a/apps/sim/app/api/folders/route.test.ts +++ b/apps/sim/app/api/folders/route.test.ts @@ -3,9 +3,17 @@ * * @vitest-environment node */ -import { createMockRequest, mockAuth, mockConsoleLogger, setupCommonApiMocks } from '@sim/testing' +import { + auditMock, + createMockRequest, + mockAuth, + mockConsoleLogger, + setupCommonApiMocks, +} from '@sim/testing' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +vi.mock('@/lib/audit/log', () => auditMock) + interface CapturedFolderValues { name?: string color?: string diff --git a/apps/sim/app/api/folders/route.ts b/apps/sim/app/api/folders/route.ts index 13f07f520..19aef4ca4 100644 --- a/apps/sim/app/api/folders/route.ts +++ b/apps/sim/app/api/folders/route.ts @@ -3,6 +3,7 @@ import { workflowFolder } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, asc, desc, eq, isNull } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' +import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' @@ -119,6 +120,20 @@ export async function POST(request: NextRequest) { logger.info('Created new folder:', { id, name, workspaceId, parentId }) + recordAudit({ + workspaceId, + actorId: session.user.id, + actorName: session.user.name, + actorEmail: session.user.email, + action: AuditAction.FOLDER_CREATED, + resourceType: AuditResourceType.FOLDER, + resourceId: id, + resourceName: name.trim(), + description: `Created folder "${name.trim()}"`, + metadata: { name: name.trim() }, + request, + }) + return NextResponse.json({ folder: newFolder }) } catch (error) { logger.error('Error creating folder:', { error }) diff --git a/apps/sim/app/api/form/manage/[id]/route.ts b/apps/sim/app/api/form/manage/[id]/route.ts index f2f1cbd1f..e64e52fb1 100644 --- a/apps/sim/app/api/form/manage/[id]/route.ts +++ b/apps/sim/app/api/form/manage/[id]/route.ts @@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger' import { eq } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { z } from 'zod' +import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { encryptSecret } from '@/lib/core/security/encryption' import { checkFormAccess, DEFAULT_FORM_CUSTOMIZATIONS } from '@/app/api/form/utils' @@ -102,7 +103,11 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise< const { id } = await params - const { hasAccess, form: formRecord } = await checkFormAccess(id, session.user.id) + const { + hasAccess, + form: formRecord, + workspaceId: formWorkspaceId, + } = await checkFormAccess(id, session.user.id) if (!hasAccess || !formRecord) { return createErrorResponse('Form not found or access denied', 404) @@ -184,6 +189,19 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise< logger.info(`Form ${id} updated successfully`) + recordAudit({ + workspaceId: formWorkspaceId ?? null, + actorId: session.user.id, + action: AuditAction.FORM_UPDATED, + resourceType: AuditResourceType.FORM, + resourceId: id, + actorName: session.user.name ?? undefined, + actorEmail: session.user.email ?? undefined, + resourceName: formRecord.title ?? undefined, + description: `Updated form "${formRecord.title}"`, + request, + }) + return createSuccessResponse({ message: 'Form updated successfully', }) @@ -213,7 +231,11 @@ export async function DELETE( const { id } = await params - const { hasAccess, form: formRecord } = await checkFormAccess(id, session.user.id) + const { + hasAccess, + form: formRecord, + workspaceId: formWorkspaceId, + } = await checkFormAccess(id, session.user.id) if (!hasAccess || !formRecord) { return createErrorResponse('Form not found or access denied', 404) @@ -223,6 +245,19 @@ export async function DELETE( logger.info(`Form ${id} deleted (soft delete)`) + recordAudit({ + workspaceId: formWorkspaceId ?? null, + actorId: session.user.id, + action: AuditAction.FORM_DELETED, + resourceType: AuditResourceType.FORM, + resourceId: id, + actorName: session.user.name ?? undefined, + actorEmail: session.user.email ?? undefined, + resourceName: formRecord.title ?? undefined, + description: `Deleted form "${formRecord.title}"`, + request, + }) + return createSuccessResponse({ message: 'Form deleted successfully', }) diff --git a/apps/sim/app/api/form/route.ts b/apps/sim/app/api/form/route.ts index ada13f5ee..4ebb577f1 100644 --- a/apps/sim/app/api/form/route.ts +++ b/apps/sim/app/api/form/route.ts @@ -5,6 +5,7 @@ import { eq } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { v4 as uuidv4 } from 'uuid' import { z } from 'zod' +import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { isDev } from '@/lib/core/config/feature-flags' import { encryptSecret } from '@/lib/core/security/encryption' @@ -178,7 +179,7 @@ export async function POST(request: NextRequest) { userId: session.user.id, identifier, title, - description: description || '', + description: description || null, customizations: mergedCustomizations, isActive: true, authType, @@ -195,6 +196,19 @@ export async function POST(request: NextRequest) { logger.info(`Form "${title}" deployed successfully at ${formUrl}`) + recordAudit({ + workspaceId: workflowRecord.workspaceId ?? null, + actorId: session.user.id, + action: AuditAction.FORM_CREATED, + resourceType: AuditResourceType.FORM, + resourceId: id, + actorName: session.user.name ?? undefined, + actorEmail: session.user.email ?? undefined, + resourceName: title, + description: `Created form "${title}" for workflow ${workflowId}`, + request, + }) + return createSuccessResponse({ id, formUrl, diff --git a/apps/sim/app/api/form/utils.ts b/apps/sim/app/api/form/utils.ts index a5e89b5a0..e39d210ac 100644 --- a/apps/sim/app/api/form/utils.ts +++ b/apps/sim/app/api/form/utils.ts @@ -52,7 +52,7 @@ export async function checkWorkflowAccessForFormCreation( export async function checkFormAccess( formId: string, userId: string -): Promise<{ hasAccess: boolean; form?: any }> { +): Promise<{ hasAccess: boolean; form?: any; workspaceId?: string }> { const formData = await db .select({ form: form, workflowWorkspaceId: workflow.workspaceId }) .from(form) @@ -75,7 +75,9 @@ export async function checkFormAccess( action: 'admin', }) - return authorization.allowed ? { hasAccess: true, form: formRecord } : { hasAccess: false } + return authorization.allowed + ? { hasAccess: true, form: formRecord, workspaceId: workflowWorkspaceId } + : { hasAccess: false } } export async function validateFormAuth( diff --git a/apps/sim/app/api/knowledge/[id]/documents/[documentId]/route.test.ts b/apps/sim/app/api/knowledge/[id]/documents/[documentId]/route.test.ts index 6b63ac13f..b3be9e796 100644 --- a/apps/sim/app/api/knowledge/[id]/documents/[documentId]/route.test.ts +++ b/apps/sim/app/api/knowledge/[id]/documents/[documentId]/route.test.ts @@ -4,6 +4,7 @@ * @vitest-environment node */ import { + auditMock, createMockRequest, mockAuth, mockConsoleLogger, @@ -35,6 +36,8 @@ vi.mock('@/lib/knowledge/documents/service', () => ({ mockDrizzleOrm() mockConsoleLogger() +vi.mock('@/lib/audit/log', () => auditMock) + describe('Document By ID API Route', () => { const mockAuth$ = mockAuth() diff --git a/apps/sim/app/api/knowledge/[id]/documents/[documentId]/route.ts b/apps/sim/app/api/knowledge/[id]/documents/[documentId]/route.ts index fb7add9e3..02fadc7d1 100644 --- a/apps/sim/app/api/knowledge/[id]/documents/[documentId]/route.ts +++ b/apps/sim/app/api/knowledge/[id]/documents/[documentId]/route.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' +import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { @@ -197,6 +198,17 @@ export async function PUT( `[${requestId}] Document updated: ${documentId} in knowledge base ${knowledgeBaseId}` ) + recordAudit({ + workspaceId: accessCheck.knowledgeBase?.workspaceId ?? null, + actorId: userId, + action: AuditAction.DOCUMENT_UPDATED, + resourceType: AuditResourceType.DOCUMENT, + resourceId: documentId, + resourceName: validatedData.filename ?? accessCheck.document?.filename, + description: `Updated document "${documentId}" in knowledge base "${knowledgeBaseId}"`, + request: req, + }) + return NextResponse.json({ success: true, data: updatedDocument, @@ -257,6 +269,17 @@ export async function DELETE( `[${requestId}] Document deleted: ${documentId} from knowledge base ${knowledgeBaseId}` ) + recordAudit({ + workspaceId: accessCheck.knowledgeBase?.workspaceId ?? null, + actorId: userId, + action: AuditAction.DOCUMENT_DELETED, + resourceType: AuditResourceType.DOCUMENT, + resourceId: documentId, + resourceName: accessCheck.document?.filename, + description: `Deleted document "${documentId}" from knowledge base "${knowledgeBaseId}"`, + request: req, + }) + return NextResponse.json({ success: true, data: result, diff --git a/apps/sim/app/api/knowledge/[id]/documents/route.test.ts b/apps/sim/app/api/knowledge/[id]/documents/route.test.ts index f006fb5f7..e08774786 100644 --- a/apps/sim/app/api/knowledge/[id]/documents/route.test.ts +++ b/apps/sim/app/api/knowledge/[id]/documents/route.test.ts @@ -4,6 +4,7 @@ * @vitest-environment node */ import { + auditMock, createMockRequest, mockAuth, mockConsoleLogger, @@ -40,6 +41,8 @@ vi.mock('@/lib/knowledge/documents/service', () => ({ mockDrizzleOrm() mockConsoleLogger() +vi.mock('@/lib/audit/log', () => auditMock) + describe('Knowledge Base Documents API Route', () => { const mockAuth$ = mockAuth() diff --git a/apps/sim/app/api/knowledge/[id]/documents/route.ts b/apps/sim/app/api/knowledge/[id]/documents/route.ts index 30c1beafa..55817ea10 100644 --- a/apps/sim/app/api/knowledge/[id]/documents/route.ts +++ b/apps/sim/app/api/knowledge/[id]/documents/route.ts @@ -2,6 +2,7 @@ import { randomUUID } from 'crypto' import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' +import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { @@ -244,6 +245,17 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: logger.error(`[${requestId}] Critical error in document processing pipeline:`, error) }) + recordAudit({ + workspaceId: accessCheck.knowledgeBase?.workspaceId ?? null, + actorId: userId, + action: AuditAction.DOCUMENT_UPLOADED, + resourceType: AuditResourceType.DOCUMENT, + resourceId: knowledgeBaseId, + resourceName: `${createdDocuments.length} document(s)`, + description: `Uploaded ${createdDocuments.length} document(s) to knowledge base "${knowledgeBaseId}"`, + request: req, + }) + return NextResponse.json({ success: true, data: { @@ -292,6 +304,17 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: // Silently fail } + recordAudit({ + workspaceId: accessCheck.knowledgeBase?.workspaceId ?? null, + actorId: userId, + action: AuditAction.DOCUMENT_UPLOADED, + resourceType: AuditResourceType.DOCUMENT, + resourceId: knowledgeBaseId, + resourceName: validatedData.filename, + description: `Uploaded document "${validatedData.filename}" to knowledge base "${knowledgeBaseId}"`, + request: req, + }) + return NextResponse.json({ success: true, data: newDocument, diff --git a/apps/sim/app/api/knowledge/[id]/route.test.ts b/apps/sim/app/api/knowledge/[id]/route.test.ts index 20bbc710f..3764c87d5 100644 --- a/apps/sim/app/api/knowledge/[id]/route.test.ts +++ b/apps/sim/app/api/knowledge/[id]/route.test.ts @@ -4,6 +4,7 @@ * @vitest-environment node */ import { + auditMock, createMockRequest, mockAuth, mockConsoleLogger, @@ -16,6 +17,8 @@ mockKnowledgeSchemas() mockDrizzleOrm() mockConsoleLogger() +vi.mock('@/lib/audit/log', () => auditMock) + vi.mock('@/lib/knowledge/service', () => ({ getKnowledgeBaseById: vi.fn(), updateKnowledgeBase: vi.fn(), diff --git a/apps/sim/app/api/knowledge/[id]/route.ts b/apps/sim/app/api/knowledge/[id]/route.ts index 70da17626..09e42803e 100644 --- a/apps/sim/app/api/knowledge/[id]/route.ts +++ b/apps/sim/app/api/knowledge/[id]/route.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' +import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { PlatformEvents } from '@/lib/core/telemetry' import { generateRequestId } from '@/lib/core/utils/request' @@ -135,6 +136,17 @@ export async function PUT(req: NextRequest, { params }: { params: Promise<{ id: logger.info(`[${requestId}] Knowledge base updated: ${id} for user ${userId}`) + recordAudit({ + workspaceId: accessCheck.knowledgeBase.workspaceId ?? null, + actorId: userId, + action: AuditAction.KNOWLEDGE_BASE_UPDATED, + resourceType: AuditResourceType.KNOWLEDGE_BASE, + resourceId: id, + resourceName: validatedData.name ?? updatedKnowledgeBase.name, + description: `Updated knowledge base "${validatedData.name ?? updatedKnowledgeBase.name}"`, + request: req, + }) + return NextResponse.json({ success: true, data: updatedKnowledgeBase, @@ -197,6 +209,17 @@ export async function DELETE( logger.info(`[${requestId}] Knowledge base deleted: ${id} for user ${userId}`) + recordAudit({ + workspaceId: accessCheck.knowledgeBase.workspaceId ?? null, + actorId: userId, + action: AuditAction.KNOWLEDGE_BASE_DELETED, + resourceType: AuditResourceType.KNOWLEDGE_BASE, + resourceId: id, + resourceName: accessCheck.knowledgeBase.name, + description: `Deleted knowledge base "${accessCheck.knowledgeBase.name || id}"`, + request: _request, + }) + return NextResponse.json({ success: true, data: { message: 'Knowledge base deleted successfully' }, diff --git a/apps/sim/app/api/knowledge/route.test.ts b/apps/sim/app/api/knowledge/route.test.ts index c9b127299..3484ac520 100644 --- a/apps/sim/app/api/knowledge/route.test.ts +++ b/apps/sim/app/api/knowledge/route.test.ts @@ -4,6 +4,7 @@ * @vitest-environment node */ import { + auditMock, createMockRequest, mockAuth, mockConsoleLogger, @@ -16,6 +17,8 @@ mockKnowledgeSchemas() mockDrizzleOrm() mockConsoleLogger() +vi.mock('@/lib/audit/log', () => auditMock) + vi.mock('@/lib/workspaces/permissions/utils', () => ({ getUserEntityPermissions: vi.fn().mockResolvedValue('admin'), })) diff --git a/apps/sim/app/api/knowledge/route.ts b/apps/sim/app/api/knowledge/route.ts index 9aaf52b5a..f266d90d8 100644 --- a/apps/sim/app/api/knowledge/route.ts +++ b/apps/sim/app/api/knowledge/route.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' +import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { PlatformEvents } from '@/lib/core/telemetry' import { generateRequestId } from '@/lib/core/utils/request' @@ -109,6 +110,20 @@ export async function POST(req: NextRequest) { `[${requestId}] Knowledge base created: ${newKnowledgeBase.id} for user ${session.user.id}` ) + recordAudit({ + workspaceId: validatedData.workspaceId, + actorId: session.user.id, + actorName: session.user.name, + actorEmail: session.user.email, + action: AuditAction.KNOWLEDGE_BASE_CREATED, + resourceType: AuditResourceType.KNOWLEDGE_BASE, + resourceId: newKnowledgeBase.id, + resourceName: validatedData.name, + description: `Created knowledge base "${validatedData.name}"`, + metadata: { name: validatedData.name }, + request: req, + }) + return NextResponse.json({ success: true, data: newKnowledgeBase, diff --git a/apps/sim/app/api/knowledge/utils.ts b/apps/sim/app/api/knowledge/utils.ts index b82970917..e7abe35b3 100644 --- a/apps/sim/app/api/knowledge/utils.ts +++ b/apps/sim/app/api/knowledge/utils.ts @@ -99,7 +99,7 @@ export interface EmbeddingData { export interface KnowledgeBaseAccessResult { hasAccess: true - knowledgeBase: Pick + knowledgeBase: Pick } export interface KnowledgeBaseAccessDenied { @@ -113,7 +113,7 @@ export type KnowledgeBaseAccessCheck = KnowledgeBaseAccessResult | KnowledgeBase export interface DocumentAccessResult { hasAccess: true document: DocumentData - knowledgeBase: Pick + knowledgeBase: Pick } export interface DocumentAccessDenied { @@ -128,7 +128,7 @@ export interface ChunkAccessResult { hasAccess: true chunk: EmbeddingData document: DocumentData - knowledgeBase: Pick + knowledgeBase: Pick } export interface ChunkAccessDenied { @@ -151,6 +151,7 @@ export async function checkKnowledgeBaseAccess( id: knowledgeBase.id, userId: knowledgeBase.userId, workspaceId: knowledgeBase.workspaceId, + name: knowledgeBase.name, }) .from(knowledgeBase) .where(and(eq(knowledgeBase.id, knowledgeBaseId), isNull(knowledgeBase.deletedAt))) @@ -193,6 +194,7 @@ export async function checkKnowledgeBaseWriteAccess( id: knowledgeBase.id, userId: knowledgeBase.userId, workspaceId: knowledgeBase.workspaceId, + name: knowledgeBase.name, }) .from(knowledgeBase) .where(and(eq(knowledgeBase.id, knowledgeBaseId), isNull(knowledgeBase.deletedAt))) diff --git a/apps/sim/app/api/mcp/servers/[id]/route.ts b/apps/sim/app/api/mcp/servers/[id]/route.ts index be7c30c6f..d839357e2 100644 --- a/apps/sim/app/api/mcp/servers/[id]/route.ts +++ b/apps/sim/app/api/mcp/servers/[id]/route.ts @@ -3,6 +3,7 @@ import { mcpServers } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq, isNull } from 'drizzle-orm' import type { NextRequest } from 'next/server' +import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { McpDomainNotAllowedError, validateMcpDomain } from '@/lib/mcp/domain-check' import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware' import { mcpService } from '@/lib/mcp/service' @@ -85,6 +86,18 @@ export const PATCH = withMcpAuth<{ id: string }>('write')( } logger.info(`[${requestId}] Successfully updated MCP server: ${serverId}`) + + recordAudit({ + workspaceId, + actorId: userId, + action: AuditAction.MCP_SERVER_UPDATED, + resourceType: AuditResourceType.MCP_SERVER, + resourceId: serverId, + resourceName: updatedServer.name || serverId, + description: `Updated MCP server "${updatedServer.name || serverId}"`, + request, + }) + return createMcpSuccessResponse({ server: updatedServer }) } catch (error) { logger.error(`[${requestId}] Error updating MCP server:`, error) diff --git a/apps/sim/app/api/mcp/servers/route.ts b/apps/sim/app/api/mcp/servers/route.ts index f6bd6b782..dbc289fe0 100644 --- a/apps/sim/app/api/mcp/servers/route.ts +++ b/apps/sim/app/api/mcp/servers/route.ts @@ -3,6 +3,7 @@ import { mcpServers } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq, isNull } from 'drizzle-orm' import type { NextRequest } from 'next/server' +import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { McpDomainNotAllowedError, validateMcpDomain } from '@/lib/mcp/domain-check' import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware' import { mcpService } from '@/lib/mcp/service' @@ -161,6 +162,18 @@ export const POST = withMcpAuth('write')( // Silently fail } + recordAudit({ + workspaceId, + actorId: userId, + action: AuditAction.MCP_SERVER_ADDED, + resourceType: AuditResourceType.MCP_SERVER, + resourceId: serverId, + resourceName: body.name, + description: `Added MCP server "${body.name}"`, + metadata: { serverName: body.name, transport: body.transport }, + request, + }) + return createMcpSuccessResponse({ serverId }, 201) } catch (error) { logger.error(`[${requestId}] Error registering MCP server:`, error) @@ -208,6 +221,18 @@ export const DELETE = withMcpAuth('admin')( await mcpService.clearCache(workspaceId) logger.info(`[${requestId}] Successfully deleted MCP server: ${serverId}`) + + recordAudit({ + workspaceId, + actorId: userId, + action: AuditAction.MCP_SERVER_REMOVED, + resourceType: AuditResourceType.MCP_SERVER, + resourceId: serverId!, + resourceName: deletedServer.name, + description: `Removed MCP server "${deletedServer.name}"`, + request, + }) + return createMcpSuccessResponse({ message: `Server ${serverId} deleted successfully` }) } catch (error) { logger.error(`[${requestId}] Error deleting MCP server:`, error) diff --git a/apps/sim/app/api/mcp/servers/test-connection/route.ts b/apps/sim/app/api/mcp/servers/test-connection/route.ts index 5a5a3f85a..c666e4263 100644 --- a/apps/sim/app/api/mcp/servers/test-connection/route.ts +++ b/apps/sim/app/api/mcp/servers/test-connection/route.ts @@ -105,6 +105,16 @@ export const POST = withMcpAuth('write')( logger.warn(`[${requestId}] Some environment variables not found:`, { missingVars }) } + // Re-validate domain after env var resolution + try { + validateMcpDomain(testConfig.url) + } catch (e) { + if (e instanceof McpDomainNotAllowedError) { + return createMcpErrorResponse(e, e.message, 403) + } + throw e + } + const testSecurityPolicy = { requireConsent: false, auditLevel: 'none' as const, diff --git a/apps/sim/app/api/mcp/workflow-servers/[id]/route.ts b/apps/sim/app/api/mcp/workflow-servers/[id]/route.ts index e0a1f085e..8145c4d58 100644 --- a/apps/sim/app/api/mcp/workflow-servers/[id]/route.ts +++ b/apps/sim/app/api/mcp/workflow-servers/[id]/route.ts @@ -3,6 +3,7 @@ import { workflowMcpServer, workflowMcpTool } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq } from 'drizzle-orm' import type { NextRequest } from 'next/server' +import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware' import { mcpPubSub } from '@/lib/mcp/pubsub' import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils' @@ -112,6 +113,17 @@ export const PATCH = withMcpAuth('write')( logger.info(`[${requestId}] Successfully updated workflow MCP server: ${serverId}`) + recordAudit({ + workspaceId, + actorId: userId, + action: AuditAction.MCP_SERVER_UPDATED, + resourceType: AuditResourceType.MCP_SERVER, + resourceId: serverId, + resourceName: updatedServer.name, + description: `Updated workflow MCP server "${updatedServer.name}"`, + request, + }) + return createMcpSuccessResponse({ server: updatedServer }) } catch (error) { logger.error(`[${requestId}] Error updating workflow MCP server:`, error) @@ -149,6 +161,17 @@ export const DELETE = withMcpAuth('admin')( mcpPubSub?.publishWorkflowToolsChanged({ serverId, workspaceId }) + recordAudit({ + workspaceId, + actorId: userId, + action: AuditAction.MCP_SERVER_REMOVED, + resourceType: AuditResourceType.MCP_SERVER, + resourceId: serverId, + resourceName: deletedServer.name, + description: `Unpublished workflow MCP server "${deletedServer.name}"`, + request, + }) + return createMcpSuccessResponse({ message: `Server ${serverId} deleted successfully` }) } catch (error) { logger.error(`[${requestId}] Error deleting workflow MCP server:`, error) diff --git a/apps/sim/app/api/mcp/workflow-servers/[id]/tools/[toolId]/route.ts b/apps/sim/app/api/mcp/workflow-servers/[id]/tools/[toolId]/route.ts index 87113b868..89d4e8dea 100644 --- a/apps/sim/app/api/mcp/workflow-servers/[id]/tools/[toolId]/route.ts +++ b/apps/sim/app/api/mcp/workflow-servers/[id]/tools/[toolId]/route.ts @@ -3,6 +3,7 @@ import { workflowMcpServer, workflowMcpTool } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq } from 'drizzle-orm' import type { NextRequest } from 'next/server' +import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware' import { mcpPubSub } from '@/lib/mcp/pubsub' import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils' @@ -118,6 +119,17 @@ export const PATCH = withMcpAuth('write')( mcpPubSub?.publishWorkflowToolsChanged({ serverId, workspaceId }) + recordAudit({ + workspaceId, + actorId: userId, + action: AuditAction.MCP_SERVER_UPDATED, + resourceType: AuditResourceType.MCP_SERVER, + resourceId: serverId, + description: `Updated tool "${updatedTool.toolName}" in MCP server`, + metadata: { toolId, toolName: updatedTool.toolName }, + request, + }) + return createMcpSuccessResponse({ tool: updatedTool }) } catch (error) { logger.error(`[${requestId}] Error updating tool:`, error) @@ -165,6 +177,17 @@ export const DELETE = withMcpAuth('write')( mcpPubSub?.publishWorkflowToolsChanged({ serverId, workspaceId }) + recordAudit({ + workspaceId, + actorId: userId, + action: AuditAction.MCP_SERVER_UPDATED, + resourceType: AuditResourceType.MCP_SERVER, + resourceId: serverId, + description: `Removed tool "${deletedTool.toolName}" from MCP server`, + metadata: { toolId, toolName: deletedTool.toolName }, + request, + }) + return createMcpSuccessResponse({ message: `Tool ${toolId} deleted successfully` }) } catch (error) { logger.error(`[${requestId}] Error deleting tool:`, error) diff --git a/apps/sim/app/api/mcp/workflow-servers/[id]/tools/route.ts b/apps/sim/app/api/mcp/workflow-servers/[id]/tools/route.ts index 54b73fe86..4619b7c89 100644 --- a/apps/sim/app/api/mcp/workflow-servers/[id]/tools/route.ts +++ b/apps/sim/app/api/mcp/workflow-servers/[id]/tools/route.ts @@ -3,6 +3,7 @@ import { workflow, workflowMcpServer, workflowMcpTool } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq } from 'drizzle-orm' import type { NextRequest } from 'next/server' +import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware' import { mcpPubSub } from '@/lib/mcp/pubsub' import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils' @@ -197,6 +198,17 @@ export const POST = withMcpAuth('write')( mcpPubSub?.publishWorkflowToolsChanged({ serverId, workspaceId }) + recordAudit({ + workspaceId, + actorId: userId, + action: AuditAction.MCP_SERVER_UPDATED, + resourceType: AuditResourceType.MCP_SERVER, + resourceId: serverId, + description: `Added tool "${toolName}" to MCP server`, + metadata: { toolId, toolName, workflowId: body.workflowId }, + request, + }) + return createMcpSuccessResponse({ tool }, 201) } catch (error) { logger.error(`[${requestId}] Error adding tool:`, error) diff --git a/apps/sim/app/api/mcp/workflow-servers/route.ts b/apps/sim/app/api/mcp/workflow-servers/route.ts index 12c9de391..6ab7ef5f9 100644 --- a/apps/sim/app/api/mcp/workflow-servers/route.ts +++ b/apps/sim/app/api/mcp/workflow-servers/route.ts @@ -3,6 +3,7 @@ import { workflow, workflowMcpServer, workflowMcpTool } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { eq, inArray, sql } from 'drizzle-orm' import type { NextRequest } from 'next/server' +import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware' import { mcpPubSub } from '@/lib/mcp/pubsub' import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils' @@ -188,6 +189,17 @@ export const POST = withMcpAuth('write')( `[${requestId}] Successfully created workflow MCP server: ${body.name} (ID: ${serverId})` ) + recordAudit({ + workspaceId, + actorId: userId, + action: AuditAction.MCP_SERVER_ADDED, + resourceType: AuditResourceType.MCP_SERVER, + resourceId: serverId, + resourceName: body.name.trim(), + description: `Published workflow MCP server "${body.name.trim()}" with ${addedTools.length} tool(s)`, + request, + }) + return createMcpSuccessResponse({ server, addedTools }, 201) } catch (error) { logger.error(`[${requestId}] Error creating workflow MCP server:`, error) diff --git a/apps/sim/app/api/organizations/[id]/invitations/[invitationId]/route.ts b/apps/sim/app/api/organizations/[id]/invitations/[invitationId]/route.ts index d28e545fd..0c1cd871c 100644 --- a/apps/sim/app/api/organizations/[id]/invitations/[invitationId]/route.ts +++ b/apps/sim/app/api/organizations/[id]/invitations/[invitationId]/route.ts @@ -18,6 +18,7 @@ import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { getEmailSubject, renderInvitationEmail } from '@/components/emails' +import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { hasAccessControlAccess } from '@/lib/billing' import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage' @@ -552,6 +553,25 @@ export async function PUT( email: orgInvitation.email, }) + const auditActionMap = { + accepted: AuditAction.ORG_INVITATION_ACCEPTED, + rejected: AuditAction.ORG_INVITATION_REJECTED, + cancelled: AuditAction.ORG_INVITATION_CANCELLED, + } as const + + recordAudit({ + workspaceId: null, + actorId: session.user.id, + action: auditActionMap[status], + resourceType: AuditResourceType.ORGANIZATION, + resourceId: organizationId, + actorName: session.user.name ?? undefined, + actorEmail: session.user.email ?? undefined, + description: `Organization invitation ${status} for ${orgInvitation.email}`, + metadata: { invitationId, email: orgInvitation.email, status }, + request: req, + }) + return NextResponse.json({ success: true, message: `Invitation ${status} successfully`, diff --git a/apps/sim/app/api/organizations/[id]/invitations/route.ts b/apps/sim/app/api/organizations/[id]/invitations/route.ts index 905628696..54281c1a9 100644 --- a/apps/sim/app/api/organizations/[id]/invitations/route.ts +++ b/apps/sim/app/api/organizations/[id]/invitations/route.ts @@ -17,6 +17,7 @@ import { renderBatchInvitationEmail, renderInvitationEmail, } from '@/components/emails' +import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { validateBulkInvitations, @@ -411,6 +412,22 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ workspaceInvitationCount: workspaceInvitationIds.length, }) + for (const inv of invitationsToCreate) { + recordAudit({ + workspaceId: null, + actorId: session.user.id, + action: AuditAction.ORG_INVITATION_CREATED, + resourceType: AuditResourceType.ORGANIZATION, + resourceId: organizationId, + actorName: session.user.name ?? undefined, + actorEmail: session.user.email ?? undefined, + resourceName: organizationEntry[0]?.name, + description: `Invited ${inv.email} to organization as ${role}`, + metadata: { invitationId: inv.id, email: inv.email, role }, + request, + }) + } + return NextResponse.json({ success: true, message: `${invitationsToCreate.length} invitation(s) sent successfully`, @@ -532,6 +549,19 @@ export async function DELETE( email: result[0].email, }) + recordAudit({ + workspaceId: null, + actorId: session.user.id, + action: AuditAction.ORG_INVITATION_REVOKED, + resourceType: AuditResourceType.ORGANIZATION, + resourceId: organizationId, + actorName: session.user.name ?? undefined, + actorEmail: session.user.email ?? undefined, + description: `Revoked organization invitation for ${result[0].email}`, + metadata: { invitationId, email: result[0].email }, + request, + }) + return NextResponse.json({ success: true, message: 'Invitation cancelled successfully', diff --git a/apps/sim/app/api/organizations/[id]/members/[memberId]/route.ts b/apps/sim/app/api/organizations/[id]/members/[memberId]/route.ts index 6793a5d13..3a2f9fa87 100644 --- a/apps/sim/app/api/organizations/[id]/members/[memberId]/route.ts +++ b/apps/sim/app/api/organizations/[id]/members/[memberId]/route.ts @@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger' import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' +import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { getUserUsageData } from '@/lib/billing/core/usage' import { removeUserFromOrganization } from '@/lib/billing/organizations/membership' @@ -213,6 +214,19 @@ export async function PUT( updatedBy: session.user.id, }) + recordAudit({ + workspaceId: null, + actorId: session.user.id, + action: AuditAction.ORG_MEMBER_ROLE_CHANGED, + resourceType: AuditResourceType.ORGANIZATION, + resourceId: organizationId, + actorName: session.user.name ?? undefined, + actorEmail: session.user.email ?? undefined, + description: `Changed role for member ${memberId} to ${role}`, + metadata: { targetUserId: memberId, newRole: role }, + request, + }) + return NextResponse.json({ success: true, message: 'Member role updated successfully', @@ -305,6 +319,22 @@ export async function DELETE( billingActions: result.billingActions, }) + recordAudit({ + workspaceId: null, + actorId: session.user.id, + action: AuditAction.ORG_MEMBER_REMOVED, + resourceType: AuditResourceType.ORGANIZATION, + resourceId: organizationId, + actorName: session.user.name ?? undefined, + actorEmail: session.user.email ?? undefined, + description: + session.user.id === targetUserId + ? 'Left the organization' + : `Removed member ${targetUserId} from organization`, + metadata: { targetUserId, wasSelfRemoval: session.user.id === targetUserId }, + request, + }) + return NextResponse.json({ success: true, message: diff --git a/apps/sim/app/api/organizations/[id]/members/route.ts b/apps/sim/app/api/organizations/[id]/members/route.ts index eb3f4b0cd..659227bbd 100644 --- a/apps/sim/app/api/organizations/[id]/members/route.ts +++ b/apps/sim/app/api/organizations/[id]/members/route.ts @@ -5,6 +5,7 @@ import { createLogger } from '@sim/logger' import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { getEmailSubject, renderInvitationEmail } from '@/components/emails' +import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { getUserUsageData } from '@/lib/billing/core/usage' import { validateSeatAvailability } from '@/lib/billing/validation/seat-management' @@ -285,6 +286,19 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ // Don't fail the request if email fails } + recordAudit({ + workspaceId: null, + actorId: session.user.id, + action: AuditAction.ORG_INVITATION_CREATED, + resourceType: AuditResourceType.ORGANIZATION, + resourceId: organizationId, + actorName: session.user.name ?? undefined, + actorEmail: session.user.email ?? undefined, + description: `Invited ${normalizedEmail} to organization as ${role}`, + metadata: { invitationId, email: normalizedEmail, role }, + request, + }) + return NextResponse.json({ success: true, message: `Invitation sent to ${normalizedEmail}`, diff --git a/apps/sim/app/api/organizations/[id]/route.ts b/apps/sim/app/api/organizations/[id]/route.ts index b528e6025..03515b95a 100644 --- a/apps/sim/app/api/organizations/[id]/route.ts +++ b/apps/sim/app/api/organizations/[id]/route.ts @@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger' import { and, eq, ne } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' +import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { getOrganizationSeatAnalytics, @@ -192,6 +193,20 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ changes: { name, slug, logo }, }) + recordAudit({ + workspaceId: null, + actorId: session.user.id, + action: AuditAction.ORGANIZATION_UPDATED, + resourceType: AuditResourceType.ORGANIZATION, + resourceId: organizationId, + actorName: session.user.name ?? undefined, + actorEmail: session.user.email ?? undefined, + resourceName: updatedOrg[0].name, + description: `Updated organization settings`, + metadata: { changes: { name, slug, logo } }, + request, + }) + return NextResponse.json({ success: true, message: 'Organization updated successfully', diff --git a/apps/sim/app/api/organizations/route.ts b/apps/sim/app/api/organizations/route.ts index 28cc31183..5803f85dc 100644 --- a/apps/sim/app/api/organizations/route.ts +++ b/apps/sim/app/api/organizations/route.ts @@ -3,6 +3,7 @@ import { member, organization } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq, or } from 'drizzle-orm' import { NextResponse } from 'next/server' +import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { createOrganizationForTeamPlan } from '@/lib/billing/organization' @@ -115,6 +116,19 @@ export async function POST(request: Request) { organizationId, }) + recordAudit({ + workspaceId: null, + actorId: user.id, + action: AuditAction.ORGANIZATION_CREATED, + resourceType: AuditResourceType.ORGANIZATION, + resourceId: organizationId, + actorName: user.name ?? undefined, + actorEmail: user.email ?? undefined, + resourceName: organizationName ?? undefined, + description: `Created organization "${organizationName}"`, + request, + }) + return NextResponse.json({ success: true, organizationId, diff --git a/apps/sim/app/api/permission-groups/[id]/members/route.ts b/apps/sim/app/api/permission-groups/[id]/members/route.ts index 4979da755..40d71b0de 100644 --- a/apps/sim/app/api/permission-groups/[id]/members/route.ts +++ b/apps/sim/app/api/permission-groups/[id]/members/route.ts @@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger' import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' +import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { hasAccessControlAccess } from '@/lib/billing' @@ -13,6 +14,7 @@ async function getPermissionGroupWithAccess(groupId: string, userId: string) { const [group] = await db .select({ id: permissionGroup.id, + name: permissionGroup.name, organizationId: permissionGroup.organizationId, }) .from(permissionGroup) @@ -151,6 +153,20 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: assignedBy: session.user.id, }) + recordAudit({ + workspaceId: null, + actorId: session.user.id, + action: AuditAction.PERMISSION_GROUP_MEMBER_ADDED, + resourceType: AuditResourceType.PERMISSION_GROUP, + resourceId: id, + resourceName: result.group.name, + actorName: session.user.name ?? undefined, + actorEmail: session.user.email ?? undefined, + description: `Added member ${userId} to permission group "${result.group.name}"`, + metadata: { targetUserId: userId, permissionGroupId: id }, + request: req, + }) + return NextResponse.json({ member: newMember }, { status: 201 }) } catch (error) { if (error instanceof z.ZodError) { @@ -221,6 +237,20 @@ export async function DELETE(req: NextRequest, { params }: { params: Promise<{ i userId: session.user.id, }) + recordAudit({ + workspaceId: null, + actorId: session.user.id, + action: AuditAction.PERMISSION_GROUP_MEMBER_REMOVED, + resourceType: AuditResourceType.PERMISSION_GROUP, + resourceId: id, + resourceName: result.group.name, + actorName: session.user.name ?? undefined, + actorEmail: session.user.email ?? undefined, + description: `Removed member ${memberToRemove.userId} from permission group "${result.group.name}"`, + metadata: { targetUserId: memberToRemove.userId, memberId, permissionGroupId: id }, + request: req, + }) + return NextResponse.json({ success: true }) } catch (error) { logger.error('Error removing member from permission group', error) diff --git a/apps/sim/app/api/permission-groups/[id]/route.ts b/apps/sim/app/api/permission-groups/[id]/route.ts index bdad32bdb..14b871444 100644 --- a/apps/sim/app/api/permission-groups/[id]/route.ts +++ b/apps/sim/app/api/permission-groups/[id]/route.ts @@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger' import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' +import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { hasAccessControlAccess } from '@/lib/billing' import { @@ -181,6 +182,19 @@ export async function PUT(req: NextRequest, { params }: { params: Promise<{ id: .where(eq(permissionGroup.id, id)) .limit(1) + recordAudit({ + workspaceId: null, + actorId: session.user.id, + action: AuditAction.PERMISSION_GROUP_UPDATED, + resourceType: AuditResourceType.PERMISSION_GROUP, + resourceId: id, + actorName: session.user.name ?? undefined, + actorEmail: session.user.email ?? undefined, + resourceName: updated.name, + description: `Updated permission group "${updated.name}"`, + request: req, + }) + return NextResponse.json({ permissionGroup: { ...updated, @@ -229,6 +243,19 @@ export async function DELETE(req: NextRequest, { params }: { params: Promise<{ i logger.info('Deleted permission group', { permissionGroupId: id, userId: session.user.id }) + recordAudit({ + workspaceId: null, + actorId: session.user.id, + action: AuditAction.PERMISSION_GROUP_DELETED, + resourceType: AuditResourceType.PERMISSION_GROUP, + resourceId: id, + actorName: session.user.name ?? undefined, + actorEmail: session.user.email ?? undefined, + resourceName: result.group.name, + description: `Deleted permission group "${result.group.name}"`, + request: req, + }) + return NextResponse.json({ success: true }) } catch (error) { logger.error('Error deleting permission group', error) diff --git a/apps/sim/app/api/permission-groups/route.ts b/apps/sim/app/api/permission-groups/route.ts index 003c3131b..3fec50ae1 100644 --- a/apps/sim/app/api/permission-groups/route.ts +++ b/apps/sim/app/api/permission-groups/route.ts @@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger' import { and, count, desc, eq } from 'drizzle-orm' import { NextResponse } from 'next/server' import { z } from 'zod' +import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { hasAccessControlAccess } from '@/lib/billing' import { @@ -198,6 +199,19 @@ export async function POST(req: Request) { userId: session.user.id, }) + recordAudit({ + workspaceId: null, + actorId: session.user.id, + action: AuditAction.PERMISSION_GROUP_CREATED, + resourceType: AuditResourceType.PERMISSION_GROUP, + resourceId: newGroup.id, + actorName: session.user.name ?? undefined, + actorEmail: session.user.email ?? undefined, + resourceName: name, + description: `Created permission group "${name}"`, + request: req, + }) + return NextResponse.json({ permissionGroup: newGroup }, { status: 201 }) } catch (error) { if (error instanceof z.ZodError) { diff --git a/apps/sim/app/api/schedules/[id]/route.test.ts b/apps/sim/app/api/schedules/[id]/route.test.ts index f33ed5a24..e2377ddc3 100644 --- a/apps/sim/app/api/schedules/[id]/route.test.ts +++ b/apps/sim/app/api/schedules/[id]/route.test.ts @@ -3,7 +3,7 @@ * * @vitest-environment node */ -import { databaseMock, loggerMock } from '@sim/testing' +import { auditMock, databaseMock, loggerMock } from '@sim/testing' import { NextRequest } from 'next/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -37,6 +37,8 @@ vi.mock('@/lib/core/utils/request', () => ({ vi.mock('@sim/logger', () => loggerMock) +vi.mock('@/lib/audit/log', () => auditMock) + import { PUT } from './route' function createRequest(body: Record): NextRequest { diff --git a/apps/sim/app/api/schedules/[id]/route.ts b/apps/sim/app/api/schedules/[id]/route.ts index 3f0dff72e..c72aab7fa 100644 --- a/apps/sim/app/api/schedules/[id]/route.ts +++ b/apps/sim/app/api/schedules/[id]/route.ts @@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger' import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' +import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' import { validateCronExpression } from '@/lib/workflows/schedules/utils' @@ -106,6 +107,18 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ logger.info(`[${requestId}] Reactivated schedule: ${scheduleId}`) + recordAudit({ + workspaceId: authorization.workflow.workspaceId ?? null, + actorId: session.user.id, + action: AuditAction.SCHEDULE_UPDATED, + resourceType: AuditResourceType.SCHEDULE, + resourceId: scheduleId, + actorName: session.user.name ?? undefined, + actorEmail: session.user.email ?? undefined, + description: `Reactivated schedule for workflow ${schedule.workflowId}`, + request, + }) + return NextResponse.json({ message: 'Schedule activated successfully', nextRunAt, diff --git a/apps/sim/app/api/users/me/api-keys/[id]/route.ts b/apps/sim/app/api/users/me/api-keys/[id]/route.ts index 56be3ce7b..596d2812c 100644 --- a/apps/sim/app/api/users/me/api-keys/[id]/route.ts +++ b/apps/sim/app/api/users/me/api-keys/[id]/route.ts @@ -3,6 +3,7 @@ import { apiKey } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' +import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' @@ -34,12 +35,27 @@ export async function DELETE( const result = await db .delete(apiKey) .where(and(eq(apiKey.id, keyId), eq(apiKey.userId, userId))) - .returning({ id: apiKey.id }) + .returning({ id: apiKey.id, name: apiKey.name }) if (!result.length) { return NextResponse.json({ error: 'API key not found' }, { status: 404 }) } + const deletedKey = result[0] + + recordAudit({ + workspaceId: null, + actorId: userId, + action: AuditAction.PERSONAL_API_KEY_REVOKED, + resourceType: AuditResourceType.API_KEY, + resourceId: keyId, + actorName: session.user.name ?? undefined, + actorEmail: session.user.email ?? undefined, + resourceName: deletedKey.name, + description: `Revoked personal API key: ${deletedKey.name}`, + request, + }) + return NextResponse.json({ success: true }) } catch (error) { logger.error('Failed to delete API key', { error }) diff --git a/apps/sim/app/api/users/me/api-keys/route.ts b/apps/sim/app/api/users/me/api-keys/route.ts index 252011ec9..25a631208 100644 --- a/apps/sim/app/api/users/me/api-keys/route.ts +++ b/apps/sim/app/api/users/me/api-keys/route.ts @@ -5,6 +5,7 @@ import { and, eq } from 'drizzle-orm' import { nanoid } from 'nanoid' import { type NextRequest, NextResponse } from 'next/server' import { createApiKey, getApiKeyDisplayFormat } from '@/lib/api-key/auth' +import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' const logger = createLogger('ApiKeysAPI') @@ -110,6 +111,19 @@ export async function POST(request: NextRequest) { createdAt: apiKey.createdAt, }) + recordAudit({ + workspaceId: null, + actorId: userId, + action: AuditAction.PERSONAL_API_KEY_CREATED, + resourceType: AuditResourceType.API_KEY, + resourceId: newKey.id, + actorName: session.user.name ?? undefined, + actorEmail: session.user.email ?? undefined, + resourceName: name, + description: `Created personal API key: ${name}`, + request, + }) + return NextResponse.json({ key: { ...newKey, diff --git a/apps/sim/app/api/webhooks/[id]/route.ts b/apps/sim/app/api/webhooks/[id]/route.ts index b3e7bf3de..d605c8e49 100644 --- a/apps/sim/app/api/webhooks/[id]/route.ts +++ b/apps/sim/app/api/webhooks/[id]/route.ts @@ -3,6 +3,7 @@ import { webhook, workflow } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' +import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { validateInteger } from '@/lib/core/security/input-validation' import { PlatformEvents } from '@/lib/core/telemetry' @@ -261,6 +262,18 @@ export async function DELETE( logger.info(`[${requestId}] Successfully deleted webhook: ${id}`) } + recordAudit({ + workspaceId: webhookData.workflow.workspaceId || null, + actorId: userId, + action: AuditAction.WEBHOOK_DELETED, + resourceType: AuditResourceType.WEBHOOK, + resourceId: id, + resourceName: foundWebhook.provider || 'generic', + description: 'Deleted webhook', + metadata: { workflowId: webhookData.workflow.id }, + request, + }) + return NextResponse.json({ success: true }, { status: 200 }) } catch (error: any) { logger.error(`[${requestId}] Error deleting webhook`, { diff --git a/apps/sim/app/api/webhooks/route.ts b/apps/sim/app/api/webhooks/route.ts index b5f978792..4221ce52d 100644 --- a/apps/sim/app/api/webhooks/route.ts +++ b/apps/sim/app/api/webhooks/route.ts @@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger' import { and, desc, eq, inArray, isNull, or } from 'drizzle-orm' import { nanoid } from 'nanoid' import { type NextRequest, NextResponse } from 'next/server' +import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { PlatformEvents } from '@/lib/core/telemetry' import { generateRequestId } from '@/lib/core/utils/request' @@ -678,6 +679,18 @@ export async function POST(request: NextRequest) { } catch { // Telemetry should not fail the operation } + + recordAudit({ + workspaceId: workflowRecord.workspaceId || null, + actorId: userId, + action: AuditAction.WEBHOOK_CREATED, + resourceType: AuditResourceType.WEBHOOK, + resourceId: savedWebhook.id, + resourceName: provider || 'generic', + description: `Created ${provider || 'generic'} webhook`, + metadata: { provider, workflowId }, + request, + }) } const status = targetWebhookId ? 200 : 201 diff --git a/apps/sim/app/api/workflows/[id]/deploy/route.ts b/apps/sim/app/api/workflows/[id]/deploy/route.ts index 9fd15eb60..e82221c83 100644 --- a/apps/sim/app/api/workflows/[id]/deploy/route.ts +++ b/apps/sim/app/api/workflows/[id]/deploy/route.ts @@ -2,6 +2,7 @@ import { db, workflow, workflowDeploymentVersion } from '@sim/db' import { createLogger } from '@sim/logger' import { and, desc, eq } from 'drizzle-orm' import type { NextRequest } from 'next/server' +import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { generateRequestId } from '@/lib/core/utils/request' import { removeMcpToolsForWorkflow, syncMcpToolsForWorkflow } from '@/lib/mcp/workflow-mcp-sync' import { @@ -258,6 +259,19 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ // Sync MCP tools with the latest parameter schema await syncMcpToolsForWorkflow({ workflowId: id, requestId, context: 'deploy' }) + recordAudit({ + workspaceId: workflowData?.workspaceId || null, + actorId: actorUserId, + actorName: session?.user?.name, + actorEmail: session?.user?.email, + action: AuditAction.WORKFLOW_DEPLOYED, + resourceType: AuditResourceType.WORKFLOW, + resourceId: id, + resourceName: workflowData?.name, + description: `Deployed workflow "${workflowData?.name || id}"`, + request, + }) + const responseApiKeyInfo = workflowData!.workspaceId ? 'Workspace API keys' : 'Personal API keys' @@ -297,11 +311,11 @@ export async function DELETE( try { logger.debug(`[${requestId}] Undeploying workflow: ${id}`) - const { error, workflow: workflowData } = await validateWorkflowPermissions( - id, - requestId, - 'admin' - ) + const { + error, + session, + workflow: workflowData, + } = await validateWorkflowPermissions(id, requestId, 'admin') if (error) { return createErrorResponse(error.message, error.status) } @@ -325,6 +339,19 @@ export async function DELETE( // Silently fail } + recordAudit({ + workspaceId: workflowData?.workspaceId || null, + actorId: session!.user.id, + actorName: session?.user?.name, + actorEmail: session?.user?.email, + action: AuditAction.WORKFLOW_UNDEPLOYED, + resourceType: AuditResourceType.WORKFLOW, + resourceId: id, + resourceName: workflowData?.name, + description: `Undeployed workflow "${workflowData?.name || id}"`, + request, + }) + return createSuccessResponse({ isDeployed: false, deployedAt: null, diff --git a/apps/sim/app/api/workflows/[id]/deployments/[version]/revert/route.ts b/apps/sim/app/api/workflows/[id]/deployments/[version]/revert/route.ts index b7e31a0e3..6050bb4b2 100644 --- a/apps/sim/app/api/workflows/[id]/deployments/[version]/revert/route.ts +++ b/apps/sim/app/api/workflows/[id]/deployments/[version]/revert/route.ts @@ -2,6 +2,7 @@ import { db, workflow, workflowDeploymentVersion } from '@sim/db' import { createLogger } from '@sim/logger' import { and, eq } from 'drizzle-orm' import type { NextRequest } from 'next/server' +import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { env } from '@/lib/core/config/env' import { generateRequestId } from '@/lib/core/utils/request' import { syncMcpToolsForWorkflow } from '@/lib/mcp/workflow-mcp-sync' @@ -22,7 +23,11 @@ export async function POST( const { id, version } = await params try { - const { error } = await validateWorkflowPermissions(id, requestId, 'admin') + const { + error, + session, + workflow: workflowRecord, + } = await validateWorkflowPermissions(id, requestId, 'admin') if (error) { return createErrorResponse(error.message, error.status) } @@ -107,6 +112,19 @@ export async function POST( logger.error('Error sending workflow reverted event to socket server', e) } + recordAudit({ + workspaceId: workflowRecord?.workspaceId ?? null, + actorId: session!.user.id, + action: AuditAction.WORKFLOW_DEPLOYMENT_REVERTED, + resourceType: AuditResourceType.WORKFLOW, + resourceId: id, + actorName: session!.user.name ?? undefined, + actorEmail: session!.user.email ?? undefined, + resourceName: workflowRecord?.name ?? undefined, + description: `Reverted workflow to deployment version ${version}`, + request, + }) + return createSuccessResponse({ message: 'Reverted to deployment version', lastSaved: Date.now(), diff --git a/apps/sim/app/api/workflows/[id]/duplicate/route.ts b/apps/sim/app/api/workflows/[id]/duplicate/route.ts index 5a35fe86e..5a43359ce 100644 --- a/apps/sim/app/api/workflows/[id]/duplicate/route.ts +++ b/apps/sim/app/api/workflows/[id]/duplicate/route.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' +import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { PlatformEvents } from '@/lib/core/telemetry' import { generateRequestId } from '@/lib/core/utils/request' @@ -61,6 +62,18 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: `[${requestId}] Successfully duplicated workflow ${sourceWorkflowId} to ${result.id} in ${elapsed}ms` ) + recordAudit({ + workspaceId: workspaceId || null, + actorId: userId, + action: AuditAction.WORKFLOW_DUPLICATED, + resourceType: AuditResourceType.WORKFLOW, + resourceId: result.id, + resourceName: result.name, + description: `Duplicated workflow from ${sourceWorkflowId}`, + metadata: { sourceWorkflowId }, + request: req, + }) + return NextResponse.json(result, { status: 201 }) } catch (error) { if (error instanceof Error) { diff --git a/apps/sim/app/api/workflows/[id]/route.test.ts b/apps/sim/app/api/workflows/[id]/route.test.ts index 62b3d0437..3595a2685 100644 --- a/apps/sim/app/api/workflows/[id]/route.test.ts +++ b/apps/sim/app/api/workflows/[id]/route.test.ts @@ -5,7 +5,7 @@ * @vitest-environment node */ -import { loggerMock, setupGlobalFetchMock } from '@sim/testing' +import { auditMock, loggerMock, setupGlobalFetchMock } from '@sim/testing' import { NextRequest } from 'next/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -23,6 +23,8 @@ vi.mock('@/lib/auth', () => ({ vi.mock('@sim/logger', () => loggerMock) +vi.mock('@/lib/audit/log', () => auditMock) + vi.mock('@/lib/workflows/persistence/utils', () => ({ loadWorkflowFromNormalizedTables: (workflowId: string) => mockLoadWorkflowFromNormalizedTables(workflowId), diff --git a/apps/sim/app/api/workflows/[id]/route.ts b/apps/sim/app/api/workflows/[id]/route.ts index 764edfa8d..406216fa5 100644 --- a/apps/sim/app/api/workflows/[id]/route.ts +++ b/apps/sim/app/api/workflows/[id]/route.ts @@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger' import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' +import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { checkHybridAuth, checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { env } from '@/lib/core/config/env' import { PlatformEvents } from '@/lib/core/telemetry' @@ -336,6 +337,17 @@ export async function DELETE( // Don't fail the deletion if Socket.IO notification fails } + recordAudit({ + workspaceId: workflowData.workspaceId || null, + actorId: userId, + action: AuditAction.WORKFLOW_DELETED, + resourceType: AuditResourceType.WORKFLOW, + resourceId: workflowId, + resourceName: workflowData.name, + description: `Deleted workflow "${workflowData.name}"`, + request, + }) + return NextResponse.json({ success: true }, { status: 200 }) } catch (error: any) { const elapsed = Date.now() - startTime diff --git a/apps/sim/app/api/workflows/[id]/variables/route.test.ts b/apps/sim/app/api/workflows/[id]/variables/route.test.ts index 1e67caf3b..d3aa03d63 100644 --- a/apps/sim/app/api/workflows/[id]/variables/route.test.ts +++ b/apps/sim/app/api/workflows/[id]/variables/route.test.ts @@ -5,6 +5,7 @@ * @vitest-environment node */ import { + auditMock, databaseMock, defaultMockUser, mockAuth, @@ -27,6 +28,8 @@ describe('Workflow Variables API Route', () => { vi.doMock('@sim/db', () => databaseMock) + vi.doMock('@/lib/audit/log', () => auditMock) + vi.doMock('@/lib/workflows/utils', () => ({ authorizeWorkflowByWorkspacePermission: mockAuthorizeWorkflowByWorkspacePermission, })) diff --git a/apps/sim/app/api/workflows/[id]/variables/route.ts b/apps/sim/app/api/workflows/[id]/variables/route.ts index 786103756..75990d32b 100644 --- a/apps/sim/app/api/workflows/[id]/variables/route.ts +++ b/apps/sim/app/api/workflows/[id]/variables/route.ts @@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger' import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' +import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils' @@ -79,6 +80,17 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: }) .where(eq(workflow.id, workflowId)) + recordAudit({ + workspaceId: workflowData.workspaceId ?? null, + actorId: userId, + action: AuditAction.WORKFLOW_VARIABLES_UPDATED, + resourceType: AuditResourceType.WORKFLOW, + resourceId: workflowId, + resourceName: workflowData.name ?? undefined, + description: `Updated workflow variables`, + request: req, + }) + return NextResponse.json({ success: true }) } catch (validationError) { if (validationError instanceof z.ZodError) { diff --git a/apps/sim/app/api/workflows/route.ts b/apps/sim/app/api/workflows/route.ts index b21435ed2..641fbb12b 100644 --- a/apps/sim/app/api/workflows/route.ts +++ b/apps/sim/app/api/workflows/route.ts @@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger' import { and, asc, eq, inArray, isNull, min } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' +import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { getUserEntityPermissions, workspaceExists } from '@/lib/workspaces/permissions/utils' @@ -188,6 +189,18 @@ export async function POST(req: NextRequest) { logger.info(`[${requestId}] Successfully created empty workflow ${workflowId}`) + recordAudit({ + workspaceId, + actorId: userId, + action: AuditAction.WORKFLOW_CREATED, + resourceType: AuditResourceType.WORKFLOW, + resourceId: workflowId, + resourceName: name, + description: `Created workflow "${name}"`, + metadata: { name }, + request: req, + }) + return NextResponse.json({ id: workflowId, name, diff --git a/apps/sim/app/api/workspaces/[id]/api-keys/[keyId]/route.ts b/apps/sim/app/api/workspaces/[id]/api-keys/[keyId]/route.ts index f72a86f1d..21c4f52d2 100644 --- a/apps/sim/app/api/workspaces/[id]/api-keys/[keyId]/route.ts +++ b/apps/sim/app/api/workspaces/[id]/api-keys/[keyId]/route.ts @@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger' import { and, eq, not } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' +import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' @@ -86,6 +87,19 @@ export async function PUT( updatedAt: apiKey.updatedAt, }) + recordAudit({ + workspaceId, + actorId: userId, + action: AuditAction.API_KEY_UPDATED, + resourceType: AuditResourceType.API_KEY, + resourceId: keyId, + actorName: session.user.name ?? undefined, + actorEmail: session.user.email ?? undefined, + resourceName: name, + description: `Updated workspace API key: ${name}`, + request, + }) + logger.info(`[${requestId}] Updated workspace API key: ${keyId} in workspace ${workspaceId}`) return NextResponse.json({ key: updatedKey }) } catch (error: unknown) { @@ -123,12 +137,27 @@ export async function DELETE( .where( and(eq(apiKey.workspaceId, workspaceId), eq(apiKey.id, keyId), eq(apiKey.type, 'workspace')) ) - .returning({ id: apiKey.id }) + .returning({ id: apiKey.id, name: apiKey.name }) if (deletedRows.length === 0) { return NextResponse.json({ error: 'API key not found' }, { status: 404 }) } + const deletedKey = deletedRows[0] + + recordAudit({ + workspaceId, + actorId: userId, + action: AuditAction.API_KEY_REVOKED, + resourceType: AuditResourceType.API_KEY, + resourceId: keyId, + actorName: session.user.name ?? undefined, + actorEmail: session.user.email ?? undefined, + resourceName: deletedKey.name, + description: `Revoked workspace API key: ${deletedKey.name}`, + request, + }) + logger.info(`[${requestId}] Deleted workspace API key: ${keyId} from workspace ${workspaceId}`) return NextResponse.json({ success: true }) } catch (error: unknown) { diff --git a/apps/sim/app/api/workspaces/[id]/api-keys/route.ts b/apps/sim/app/api/workspaces/[id]/api-keys/route.ts index c64997214..631037089 100644 --- a/apps/sim/app/api/workspaces/[id]/api-keys/route.ts +++ b/apps/sim/app/api/workspaces/[id]/api-keys/route.ts @@ -6,6 +6,7 @@ import { nanoid } from 'nanoid' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { createApiKey, getApiKeyDisplayFormat } from '@/lib/api-key/auth' +import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { PlatformEvents } from '@/lib/core/telemetry' import { generateRequestId } from '@/lib/core/utils/request' @@ -159,6 +160,20 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ logger.info(`[${requestId}] Created workspace API key: ${name} in workspace ${workspaceId}`) + recordAudit({ + workspaceId, + actorId: userId, + actorName: session?.user?.name, + actorEmail: session?.user?.email, + action: AuditAction.API_KEY_CREATED, + resourceType: AuditResourceType.API_KEY, + resourceId: newKey.id, + resourceName: name, + description: `Created API key "${name}"`, + metadata: { keyName: name }, + request, + }) + return NextResponse.json({ key: { ...newKey, @@ -222,6 +237,19 @@ export async function DELETE( logger.info( `[${requestId}] Deleted ${deletedCount} workspace API keys from workspace ${workspaceId}` ) + + recordAudit({ + workspaceId, + actorId: userId, + actorName: session?.user?.name, + actorEmail: session?.user?.email, + action: AuditAction.API_KEY_REVOKED, + resourceType: AuditResourceType.API_KEY, + description: `Revoked ${deletedCount} API key(s)`, + metadata: { keyIds: keys, deletedCount }, + request, + }) + return NextResponse.json({ success: true, deletedCount }) } catch (error: unknown) { logger.error(`[${requestId}] Workspace API key DELETE error`, error) diff --git a/apps/sim/app/api/workspaces/[id]/byok-keys/route.ts b/apps/sim/app/api/workspaces/[id]/byok-keys/route.ts index 307855535..ab4c9600d 100644 --- a/apps/sim/app/api/workspaces/[id]/byok-keys/route.ts +++ b/apps/sim/app/api/workspaces/[id]/byok-keys/route.ts @@ -5,6 +5,7 @@ import { and, eq } from 'drizzle-orm' import { nanoid } from 'nanoid' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' +import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption' import { generateRequestId } from '@/lib/core/utils/request' @@ -185,6 +186,20 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ logger.info(`[${requestId}] Created BYOK key for ${providerId} in workspace ${workspaceId}`) + recordAudit({ + workspaceId, + actorId: userId, + actorName: session?.user?.name, + actorEmail: session?.user?.email, + action: AuditAction.BYOK_KEY_CREATED, + resourceType: AuditResourceType.BYOK_KEY, + resourceId: newKey.id, + resourceName: providerId, + description: `Added BYOK key for ${providerId}`, + metadata: { providerId }, + request, + }) + return NextResponse.json({ success: true, key: { @@ -242,6 +257,19 @@ export async function DELETE( logger.info(`[${requestId}] Deleted BYOK key for ${providerId} from workspace ${workspaceId}`) + recordAudit({ + workspaceId, + actorId: userId, + actorName: session?.user?.name, + actorEmail: session?.user?.email, + action: AuditAction.BYOK_KEY_DELETED, + resourceType: AuditResourceType.BYOK_KEY, + resourceName: providerId, + description: `Removed BYOK key for ${providerId}`, + metadata: { providerId }, + request, + }) + return NextResponse.json({ success: true }) } catch (error: unknown) { logger.error(`[${requestId}] BYOK key DELETE error`, error) diff --git a/apps/sim/app/api/workspaces/[id]/duplicate/route.ts b/apps/sim/app/api/workspaces/[id]/duplicate/route.ts index 50f1d9c2f..b8673546b 100644 --- a/apps/sim/app/api/workspaces/[id]/duplicate/route.ts +++ b/apps/sim/app/api/workspaces/[id]/duplicate/route.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' +import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' import { duplicateWorkspace } from '@/lib/workspaces/duplicate' @@ -45,6 +46,19 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: `[${requestId}] Successfully duplicated workspace ${sourceWorkspaceId} to ${result.id} in ${elapsed}ms` ) + recordAudit({ + workspaceId: sourceWorkspaceId, + actorId: session.user.id, + actorName: session.user.name, + actorEmail: session.user.email, + action: AuditAction.WORKSPACE_DUPLICATED, + resourceType: AuditResourceType.WORKSPACE, + resourceId: result.id, + resourceName: name, + description: `Duplicated workspace to "${name}"`, + request: req, + }) + return NextResponse.json(result, { status: 201 }) } catch (error) { if (error instanceof Error) { diff --git a/apps/sim/app/api/workspaces/[id]/environment/route.ts b/apps/sim/app/api/workspaces/[id]/environment/route.ts index f11da0ecc..c53f2b7b3 100644 --- a/apps/sim/app/api/workspaces/[id]/environment/route.ts +++ b/apps/sim/app/api/workspaces/[id]/environment/route.ts @@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger' import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' +import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption' import { generateRequestId } from '@/lib/core/utils/request' @@ -156,6 +157,19 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ set: { variables: merged, updatedAt: new Date() }, }) + recordAudit({ + workspaceId, + actorId: userId, + actorName: session?.user?.name, + actorEmail: session?.user?.email, + action: AuditAction.ENVIRONMENT_UPDATED, + resourceType: AuditResourceType.ENVIRONMENT, + resourceId: workspaceId, + description: `Updated environment variables`, + metadata: { keysUpdated: Object.keys(variables) }, + request, + }) + return NextResponse.json({ success: true }) } catch (error: any) { logger.error(`[${requestId}] Workspace env PUT error`, error) diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/route.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/route.ts index 2c646d8e1..80c91a2ad 100644 --- a/apps/sim/app/api/workspaces/[id]/files/[fileId]/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/route.ts @@ -1,5 +1,6 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' +import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' import { deleteWorkspaceFile } from '@/lib/uploads/contexts/workspace' @@ -39,6 +40,18 @@ export async function DELETE( logger.info(`[${requestId}] Deleted workspace file: ${fileId}`) + recordAudit({ + workspaceId, + actorId: session.user.id, + actorName: session.user.name, + actorEmail: session.user.email, + action: AuditAction.FILE_DELETED, + resourceType: AuditResourceType.FILE, + resourceId: fileId, + description: `Deleted file "${fileId}"`, + request, + }) + return NextResponse.json({ success: true, }) diff --git a/apps/sim/app/api/workspaces/[id]/files/route.ts b/apps/sim/app/api/workspaces/[id]/files/route.ts index 22a4233b0..a62575dce 100644 --- a/apps/sim/app/api/workspaces/[id]/files/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/route.ts @@ -1,5 +1,6 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' +import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' import { listWorkspaceFiles, uploadWorkspaceFile } from '@/lib/uploads/contexts/workspace' @@ -104,6 +105,19 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ logger.info(`[${requestId}] Uploaded workspace file: ${file.name}`) + recordAudit({ + workspaceId, + actorId: session.user.id, + actorName: session.user.name, + actorEmail: session.user.email, + action: AuditAction.FILE_UPLOADED, + resourceType: AuditResourceType.FILE, + resourceId: userFile.id, + resourceName: file.name, + description: `Uploaded file "${file.name}"`, + request, + }) + return NextResponse.json({ success: true, file: userFile, diff --git a/apps/sim/app/api/workspaces/[id]/notifications/[notificationId]/route.ts b/apps/sim/app/api/workspaces/[id]/notifications/[notificationId]/route.ts index 0fff01954..ddc273001 100644 --- a/apps/sim/app/api/workspaces/[id]/notifications/[notificationId]/route.ts +++ b/apps/sim/app/api/workspaces/[id]/notifications/[notificationId]/route.ts @@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger' import { and, eq, inArray } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' +import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { encryptSecret } from '@/lib/core/security/encryption' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' @@ -251,6 +252,19 @@ export async function PUT(request: NextRequest, { params }: RouteParams) { subscriptionId: subscription.id, }) + recordAudit({ + workspaceId, + actorId: session.user.id, + action: AuditAction.NOTIFICATION_UPDATED, + resourceType: AuditResourceType.NOTIFICATION, + resourceId: notificationId, + resourceName: subscription.notificationType, + actorName: session.user.name ?? undefined, + actorEmail: session.user.email ?? undefined, + description: `Updated ${subscription.notificationType} notification subscription`, + request, + }) + return NextResponse.json({ data: { id: subscription.id, @@ -300,17 +314,35 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) { eq(workspaceNotificationSubscription.workspaceId, workspaceId) ) ) - .returning({ id: workspaceNotificationSubscription.id }) + .returning({ + id: workspaceNotificationSubscription.id, + notificationType: workspaceNotificationSubscription.notificationType, + }) if (deleted.length === 0) { return NextResponse.json({ error: 'Notification not found' }, { status: 404 }) } + const deletedSubscription = deleted[0] + logger.info('Deleted notification subscription', { workspaceId, subscriptionId: notificationId, }) + recordAudit({ + workspaceId, + actorId: session.user.id, + action: AuditAction.NOTIFICATION_DELETED, + resourceType: AuditResourceType.NOTIFICATION, + resourceId: notificationId, + actorName: session.user.name ?? undefined, + actorEmail: session.user.email ?? undefined, + resourceName: deletedSubscription.notificationType, + description: `Deleted ${deletedSubscription.notificationType} notification subscription`, + request, + }) + return NextResponse.json({ success: true }) } catch (error) { logger.error('Error deleting notification', { error }) diff --git a/apps/sim/app/api/workspaces/[id]/notifications/route.ts b/apps/sim/app/api/workspaces/[id]/notifications/route.ts index 36d33204e..6fc8f4866 100644 --- a/apps/sim/app/api/workspaces/[id]/notifications/route.ts +++ b/apps/sim/app/api/workspaces/[id]/notifications/route.ts @@ -5,6 +5,7 @@ import { and, eq, inArray } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { v4 as uuidv4 } from 'uuid' import { z } from 'zod' +import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { encryptSecret } from '@/lib/core/security/encryption' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' @@ -256,6 +257,19 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ type: data.notificationType, }) + recordAudit({ + workspaceId, + actorId: session.user.id, + action: AuditAction.NOTIFICATION_CREATED, + resourceType: AuditResourceType.NOTIFICATION, + resourceId: subscription.id, + resourceName: data.notificationType, + actorName: session.user.name ?? undefined, + actorEmail: session.user.email ?? undefined, + description: `Created ${data.notificationType} notification subscription`, + request, + }) + return NextResponse.json({ data: { id: subscription.id, diff --git a/apps/sim/app/api/workspaces/[id]/permissions/route.ts b/apps/sim/app/api/workspaces/[id]/permissions/route.ts index 0025c90fc..e37cf4cfe 100644 --- a/apps/sim/app/api/workspaces/[id]/permissions/route.ts +++ b/apps/sim/app/api/workspaces/[id]/permissions/route.ts @@ -5,6 +5,7 @@ import { createLogger } from '@sim/logger' import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' +import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { getUsersWithPermissions, @@ -156,6 +157,21 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise< const updatedUsers = await getUsersWithPermissions(workspaceId) + for (const update of body.updates) { + recordAudit({ + workspaceId, + actorId: session.user.id, + action: AuditAction.MEMBER_ROLE_CHANGED, + resourceType: AuditResourceType.WORKSPACE, + resourceId: workspaceId, + actorName: session.user.name ?? undefined, + actorEmail: session.user.email ?? undefined, + description: `Changed permissions for user ${update.userId} to ${update.permissions}`, + metadata: { targetUserId: update.userId, newPermissions: update.permissions }, + request, + }) + } + return NextResponse.json({ message: 'Permissions updated successfully', users: updatedUsers, diff --git a/apps/sim/app/api/workspaces/[id]/route.ts b/apps/sim/app/api/workspaces/[id]/route.ts index eed710c7c..6e057f15d 100644 --- a/apps/sim/app/api/workspaces/[id]/route.ts +++ b/apps/sim/app/api/workspaces/[id]/route.ts @@ -3,6 +3,7 @@ import { createLogger } from '@sim/logger' import { and, eq, inArray } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' +import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' const logger = createLogger('WorkspaceByIdAPI') @@ -228,6 +229,13 @@ export async function DELETE( `Deleting workspace ${workspaceId} for user ${session.user.id}, deleteTemplates: ${deleteTemplates}` ) + // Fetch workspace name before deletion for audit logging + const [workspaceRecord] = await db + .select({ name: workspace.name }) + .from(workspace) + .where(eq(workspace.id, workspaceId)) + .limit(1) + // Delete workspace and all related data in a transaction await db.transaction(async (tx) => { // Get all workflows in this workspace before deletion @@ -281,6 +289,19 @@ export async function DELETE( logger.info(`Successfully deleted workspace ${workspaceId} and all related data`) }) + recordAudit({ + workspaceId: null, + actorId: session.user.id, + actorName: session.user.name, + actorEmail: session.user.email, + action: AuditAction.WORKSPACE_DELETED, + resourceType: AuditResourceType.WORKSPACE, + resourceId: workspaceId, + resourceName: workspaceRecord?.name, + description: `Deleted workspace "${workspaceRecord?.name || workspaceId}"`, + request, + }) + return NextResponse.json({ success: true }) } catch (error) { logger.error(`Error deleting workspace ${workspaceId}:`, error) diff --git a/apps/sim/app/api/workspaces/invitations/[invitationId]/route.test.ts b/apps/sim/app/api/workspaces/invitations/[invitationId]/route.test.ts index 389de676c..7a5098f84 100644 --- a/apps/sim/app/api/workspaces/invitations/[invitationId]/route.test.ts +++ b/apps/sim/app/api/workspaces/invitations/[invitationId]/route.test.ts @@ -1,4 +1,4 @@ -import { createSession, createWorkspaceRecord, loggerMock } from '@sim/testing' +import { auditMock, createSession, createWorkspaceRecord, loggerMock } from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -55,6 +55,8 @@ vi.mock('@/lib/workspaces/permissions/utils', () => ({ vi.mock('@sim/logger', () => loggerMock) +vi.mock('@/lib/audit/log', () => auditMock) + vi.mock('@/lib/core/utils/urls', () => ({ getBaseUrl: vi.fn().mockReturnValue('https://test.sim.ai'), })) diff --git a/apps/sim/app/api/workspaces/invitations/[invitationId]/route.ts b/apps/sim/app/api/workspaces/invitations/[invitationId]/route.ts index c7574a61e..85a9c2882 100644 --- a/apps/sim/app/api/workspaces/invitations/[invitationId]/route.ts +++ b/apps/sim/app/api/workspaces/invitations/[invitationId]/route.ts @@ -12,6 +12,7 @@ import { createLogger } from '@sim/logger' import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { WorkspaceInvitationEmail } from '@/components/emails' +import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { getBaseUrl } from '@/lib/core/utils/urls' import { sendEmail } from '@/lib/messaging/email/mailer' @@ -162,6 +163,19 @@ export async function GET( .where(eq(workspaceInvitation.id, invitation.id)) }) + recordAudit({ + workspaceId: invitation.workspaceId, + actorId: session.user.id, + action: AuditAction.INVITATION_ACCEPTED, + resourceType: AuditResourceType.WORKSPACE, + resourceId: invitation.workspaceId, + actorName: session.user.name ?? undefined, + actorEmail: session.user.email ?? undefined, + resourceName: workspaceDetails.name, + description: `Accepted workspace invitation to "${workspaceDetails.name}"`, + request: req, + }) + return NextResponse.redirect(new URL(`/workspace/${invitation.workspaceId}/w`, getBaseUrl())) } @@ -216,6 +230,19 @@ export async function DELETE( await db.delete(workspaceInvitation).where(eq(workspaceInvitation.id, invitationId)) + recordAudit({ + workspaceId: invitation.workspaceId, + actorId: session.user.id, + action: AuditAction.INVITATION_REVOKED, + resourceType: AuditResourceType.WORKSPACE, + resourceId: invitation.workspaceId, + actorName: session.user.name ?? undefined, + actorEmail: session.user.email ?? undefined, + description: `Revoked workspace invitation for ${invitation.email}`, + metadata: { invitationId, email: invitation.email }, + request: _request, + }) + return NextResponse.json({ success: true }) } catch (error) { logger.error('Error deleting workspace invitation:', error) diff --git a/apps/sim/app/api/workspaces/invitations/route.test.ts b/apps/sim/app/api/workspaces/invitations/route.test.ts index ac3545885..a636200d5 100644 --- a/apps/sim/app/api/workspaces/invitations/route.test.ts +++ b/apps/sim/app/api/workspaces/invitations/route.test.ts @@ -1,4 +1,4 @@ -import { createMockRequest, mockAuth, mockConsoleLogger } from '@sim/testing' +import { auditMock, createMockRequest, mockAuth, mockConsoleLogger } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' describe('Workspace Invitations API Route', () => { @@ -96,6 +96,8 @@ describe('Workspace Invitations API Route', () => { getEmailDomain: vi.fn().mockReturnValue('sim.ai'), })) + vi.doMock('@/lib/audit/log', () => auditMock) + vi.doMock('drizzle-orm', () => ({ and: vi.fn().mockImplementation((...args) => ({ type: 'and', conditions: args })), eq: vi.fn().mockImplementation((field, value) => ({ type: 'eq', field, value })), diff --git a/apps/sim/app/api/workspaces/invitations/route.ts b/apps/sim/app/api/workspaces/invitations/route.ts index e6116d840..169a6d868 100644 --- a/apps/sim/app/api/workspaces/invitations/route.ts +++ b/apps/sim/app/api/workspaces/invitations/route.ts @@ -13,6 +13,7 @@ import { createLogger } from '@sim/logger' import { and, eq, inArray } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { WorkspaceInvitationEmail } from '@/components/emails' +import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { PlatformEvents } from '@/lib/core/telemetry' import { getBaseUrl } from '@/lib/core/utils/urls' @@ -214,6 +215,20 @@ export async function POST(req: NextRequest) { token: token, }) + recordAudit({ + workspaceId, + actorId: session.user.id, + actorName: session.user.name, + actorEmail: session.user.email, + action: AuditAction.MEMBER_INVITED, + resourceType: AuditResourceType.WORKSPACE, + resourceId: workspaceId, + resourceName: email, + description: `Invited ${email} as ${permission}`, + metadata: { email, role: permission }, + request: req, + }) + return NextResponse.json({ success: true, invitation: invitationData }) } catch (error) { if (error instanceof InvitationsNotAllowedError) { diff --git a/apps/sim/app/api/workspaces/members/[id]/route.ts b/apps/sim/app/api/workspaces/members/[id]/route.ts index ec990da24..fb796ce07 100644 --- a/apps/sim/app/api/workspaces/members/[id]/route.ts +++ b/apps/sim/app/api/workspaces/members/[id]/route.ts @@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger' import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' +import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { hasWorkspaceAdminAccess } from '@/lib/workspaces/permissions/utils' @@ -101,6 +102,19 @@ export async function DELETE(req: NextRequest, { params }: { params: Promise<{ i ) ) + recordAudit({ + workspaceId, + actorId: session.user.id, + actorName: session.user.name, + actorEmail: session.user.email, + action: AuditAction.MEMBER_REMOVED, + resourceType: AuditResourceType.WORKSPACE, + resourceId: workspaceId, + description: isSelf ? 'Left the workspace' : 'Removed a member from the workspace', + metadata: { removedUserId: userId, selfRemoval: isSelf }, + request: req, + }) + return NextResponse.json({ success: true }) } catch (error) { logger.error('Error removing workspace member:', error) diff --git a/apps/sim/app/api/workspaces/route.ts b/apps/sim/app/api/workspaces/route.ts index 492922b79..79c2c436d 100644 --- a/apps/sim/app/api/workspaces/route.ts +++ b/apps/sim/app/api/workspaces/route.ts @@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger' import { and, desc, eq, isNull } from 'drizzle-orm' import { NextResponse } from 'next/server' import { z } from 'zod' +import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { PlatformEvents } from '@/lib/core/telemetry' import { buildDefaultWorkflowArtifacts } from '@/lib/workflows/defaults' @@ -68,6 +69,20 @@ export async function POST(req: Request) { const newWorkspace = await createWorkspace(session.user.id, name, skipDefaultWorkflow) + recordAudit({ + workspaceId: newWorkspace.id, + actorId: session.user.id, + actorName: session.user.name, + actorEmail: session.user.email, + action: AuditAction.WORKSPACE_CREATED, + resourceType: AuditResourceType.WORKSPACE, + resourceId: newWorkspace.id, + resourceName: newWorkspace.name, + description: `Created workspace "${newWorkspace.name}"`, + metadata: { name: newWorkspace.name }, + request: req, + }) + return NextResponse.json({ workspace: newWorkspace }) } catch (error) { logger.error('Error creating workspace:', error) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/mcp/mcp.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/mcp/mcp.tsx index b0f2079fd..8a6f8f8b6 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/mcp/mcp.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/mcp/mcp.tsx @@ -109,10 +109,29 @@ const logger = createLogger('McpSettings') /** * Checks if a URL's hostname is in the allowed domains list. * Returns true if no allowlist is configured (null) or the domain matches. + * Env var references in the hostname bypass the check since the domain + * can't be determined until resolution — but env vars only in the path/query + * do NOT bypass the check. */ +const ENV_VAR_PATTERN = /\{\{[^}]+\}\}/ + +function hasEnvVarInHostname(url: string): boolean { + // If the entire URL is an env var, hostname is unknown + const globalPattern = new RegExp(ENV_VAR_PATTERN.source, 'g') + if (url.trim().replace(globalPattern, '').trim() === '') return true + const protocolEnd = url.indexOf('://') + if (protocolEnd === -1) return ENV_VAR_PATTERN.test(url) + // Extract authority per RFC 3986 (terminated by /, ?, or #) + const afterProtocol = url.substring(protocolEnd + 3) + const authorityEnd = afterProtocol.search(/[/?#]/) + const authority = authorityEnd === -1 ? afterProtocol : afterProtocol.substring(0, authorityEnd) + return ENV_VAR_PATTERN.test(authority) +} + function isDomainAllowed(url: string | undefined, allowedDomains: string[] | null): boolean { if (allowedDomains === null) return true - if (!url) return true + if (!url) return false + if (hasEnvVarInHostname(url)) return true try { const hostname = new URL(url).hostname.toLowerCase() return allowedDomains.includes(hostname) @@ -1030,12 +1049,14 @@ export function MCP({ initialServerId }: MCPProps) { const showNoResults = searchTerm.trim() && filteredServers.length === 0 && servers.length > 0 const isFormValid = formData.name.trim() && formData.url?.trim() - const isAddDomainBlocked = !isDomainAllowed(formData.url, allowedMcpDomains) + const isAddDomainBlocked = + !!formData.url?.trim() && !isDomainAllowed(formData.url, allowedMcpDomains) const isSubmitDisabled = serversLoading || isAddingServer || !isFormValid || isAddDomainBlocked const testButtonLabel = getTestButtonLabel(testResult, isTestingConnection) const isEditFormValid = editFormData.name.trim() && editFormData.url?.trim() - const isEditDomainBlocked = !isDomainAllowed(editFormData.url, allowedMcpDomains) + const isEditDomainBlocked = + !!editFormData.url?.trim() && !isDomainAllowed(editFormData.url, allowedMcpDomains) const editTestButtonLabel = getTestButtonLabel(editTestResult, isEditTestingConnection) const hasEditChanges = useMemo(() => { if (editFormData.name !== editOriginalData.name) return true diff --git a/apps/sim/lib/audit/log.test.ts b/apps/sim/lib/audit/log.test.ts new file mode 100644 index 000000000..13b9216b4 --- /dev/null +++ b/apps/sim/lib/audit/log.test.ts @@ -0,0 +1,272 @@ +/** + * @vitest-environment node + */ +import { auditMock, databaseMock, loggerMock } from '@sim/testing' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@sim/db', () => ({ + ...databaseMock, + auditLog: { id: 'id', workspaceId: 'workspace_id' }, +})) +vi.mock('@sim/logger', () => loggerMock) +vi.mock('nanoid', () => ({ nanoid: () => 'test-id-123' })) + +import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' + +describe('AuditAction', () => { + it('contains all expected action categories', () => { + expect(AuditAction.WORKFLOW_CREATED).toBe('workflow.created') + expect(AuditAction.MEMBER_INVITED).toBe('member.invited') + expect(AuditAction.API_KEY_CREATED).toBe('api_key.created') + expect(AuditAction.ORGANIZATION_CREATED).toBe('organization.created') + }) + + it('has unique values for every key', () => { + const values = Object.values(AuditAction) + const unique = new Set(values) + expect(unique.size).toBe(values.length) + }) +}) + +describe('AuditResourceType', () => { + it('contains all expected resource types', () => { + expect(AuditResourceType.WORKFLOW).toBe('workflow') + expect(AuditResourceType.WORKSPACE).toBe('workspace') + expect(AuditResourceType.API_KEY).toBe('api_key') + expect(AuditResourceType.MCP_SERVER).toBe('mcp_server') + }) + + it('has unique values for every key', () => { + const values = Object.values(AuditResourceType) + const unique = new Set(values) + expect(unique.size).toBe(values.length) + }) +}) + +describe('recordAudit', () => { + const mockInsert = databaseMock.db.insert + let mockValues: ReturnType + + beforeEach(() => { + vi.clearAllMocks() + mockValues = vi.fn(() => Promise.resolve()) + mockInsert.mockReturnValue({ values: mockValues }) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('inserts an audit log entry with all required fields', async () => { + recordAudit({ + workspaceId: 'ws-1', + actorId: 'user-1', + action: AuditAction.WORKFLOW_CREATED, + resourceType: AuditResourceType.WORKFLOW, + resourceId: 'wf-1', + }) + + await vi.waitFor(() => { + expect(mockInsert).toHaveBeenCalledTimes(1) + }) + + expect(mockValues).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'test-id-123', + workspaceId: 'ws-1', + actorId: 'user-1', + action: 'workflow.created', + resourceType: 'workflow', + resourceId: 'wf-1', + metadata: {}, + }) + ) + }) + + it('includes optional denormalized fields when provided', async () => { + recordAudit({ + workspaceId: 'ws-1', + actorId: 'user-1', + action: AuditAction.FOLDER_CREATED, + resourceType: AuditResourceType.FOLDER, + resourceId: 'folder-1', + actorName: 'Waleed', + actorEmail: 'waleed@example.com', + resourceName: 'My Folder', + description: 'Created folder "My Folder"', + }) + + await vi.waitFor(() => { + expect(mockValues).toHaveBeenCalledTimes(1) + }) + + expect(mockValues).toHaveBeenCalledWith( + expect.objectContaining({ + actorName: 'Waleed', + actorEmail: 'waleed@example.com', + resourceName: 'My Folder', + description: 'Created folder "My Folder"', + }) + ) + }) + + it('sets optional fields to undefined when not provided', async () => { + recordAudit({ + workspaceId: 'ws-1', + actorId: 'user-1', + action: AuditAction.WORKSPACE_DELETED, + resourceType: AuditResourceType.WORKSPACE, + }) + + await vi.waitFor(() => { + expect(mockValues).toHaveBeenCalledTimes(1) + }) + + const insertedValues = mockValues.mock.calls[0][0] + expect(insertedValues.resourceId).toBeUndefined() + expect(insertedValues.actorName).toBeUndefined() + expect(insertedValues.actorEmail).toBeUndefined() + expect(insertedValues.resourceName).toBeUndefined() + expect(insertedValues.description).toBeUndefined() + }) + + it('extracts IP address from x-forwarded-for header', async () => { + const request = new Request('https://example.com', { + headers: { + 'x-forwarded-for': '1.2.3.4, 5.6.7.8', + 'user-agent': 'TestAgent/1.0', + }, + }) + + recordAudit({ + workspaceId: 'ws-1', + actorId: 'user-1', + action: AuditAction.MEMBER_INVITED, + resourceType: AuditResourceType.WORKSPACE, + request, + }) + + await vi.waitFor(() => { + expect(mockValues).toHaveBeenCalledTimes(1) + }) + + expect(mockValues).toHaveBeenCalledWith( + expect.objectContaining({ + ipAddress: '1.2.3.4', + userAgent: 'TestAgent/1.0', + }) + ) + }) + + it('falls back to x-real-ip when x-forwarded-for is absent', async () => { + const request = new Request('https://example.com', { + headers: { 'x-real-ip': '10.0.0.1' }, + }) + + recordAudit({ + workspaceId: 'ws-1', + actorId: 'user-1', + action: AuditAction.API_KEY_CREATED, + resourceType: AuditResourceType.API_KEY, + request, + }) + + await vi.waitFor(() => { + expect(mockValues).toHaveBeenCalledTimes(1) + }) + + expect(mockValues).toHaveBeenCalledWith( + expect.objectContaining({ + ipAddress: '10.0.0.1', + userAgent: undefined, + }) + ) + }) + + it('defaults metadata to empty object when not provided', async () => { + recordAudit({ + workspaceId: 'ws-1', + actorId: 'user-1', + action: AuditAction.ENVIRONMENT_UPDATED, + resourceType: AuditResourceType.ENVIRONMENT, + }) + + await vi.waitFor(() => { + expect(mockValues).toHaveBeenCalledTimes(1) + }) + + expect(mockValues).toHaveBeenCalledWith(expect.objectContaining({ metadata: {} })) + }) + + it('passes through metadata when provided', async () => { + recordAudit({ + workspaceId: 'ws-1', + actorId: 'user-1', + action: AuditAction.WEBHOOK_CREATED, + resourceType: AuditResourceType.WEBHOOK, + metadata: { provider: 'github', workflowId: 'wf-1' }, + }) + + await vi.waitFor(() => { + expect(mockValues).toHaveBeenCalledTimes(1) + }) + + expect(mockValues).toHaveBeenCalledWith( + expect.objectContaining({ + metadata: { provider: 'github', workflowId: 'wf-1' }, + }) + ) + }) + + it('does not throw when the database insert fails', async () => { + mockValues.mockReturnValue(Promise.reject(new Error('DB connection lost'))) + + expect(() => { + recordAudit({ + workspaceId: 'ws-1', + actorId: 'user-1', + action: AuditAction.WORKFLOW_DELETED, + resourceType: AuditResourceType.WORKFLOW, + }) + }).not.toThrow() + }) + + it('does not block — returns void synchronously', () => { + const result = recordAudit({ + workspaceId: 'ws-1', + actorId: 'user-1', + action: AuditAction.CHAT_DEPLOYED, + resourceType: AuditResourceType.CHAT, + }) + + expect(result).toBeUndefined() + }) +}) + +describe('auditMock sync', () => { + it('has the same AuditAction keys as the source', () => { + const sourceKeys = Object.keys(AuditAction).sort() + const mockKeys = Object.keys(auditMock.AuditAction).sort() + expect(mockKeys).toEqual(sourceKeys) + }) + + it('has the same AuditAction values as the source', () => { + for (const key of Object.keys(AuditAction)) { + expect(auditMock.AuditAction[key]).toBe(AuditAction[key as keyof typeof AuditAction]) + } + }) + + it('has the same AuditResourceType keys as the source', () => { + const sourceKeys = Object.keys(AuditResourceType).sort() + const mockKeys = Object.keys(auditMock.AuditResourceType).sort() + expect(mockKeys).toEqual(sourceKeys) + }) + + it('has the same AuditResourceType values as the source', () => { + for (const key of Object.keys(AuditResourceType)) { + expect(auditMock.AuditResourceType[key]).toBe( + AuditResourceType[key as keyof typeof AuditResourceType] + ) + } + }) +}) diff --git a/apps/sim/lib/audit/log.ts b/apps/sim/lib/audit/log.ts new file mode 100644 index 000000000..a3bed1d23 --- /dev/null +++ b/apps/sim/lib/audit/log.ts @@ -0,0 +1,207 @@ +import { auditLog, db } from '@sim/db' +import { createLogger } from '@sim/logger' +import { nanoid } from 'nanoid' + +const logger = createLogger('AuditLog') + +/** + * All auditable actions in the platform, grouped by resource type. + */ +export const AuditAction = { + // API Keys + API_KEY_CREATED: 'api_key.created', + API_KEY_UPDATED: 'api_key.updated', + API_KEY_REVOKED: 'api_key.revoked', + PERSONAL_API_KEY_CREATED: 'personal_api_key.created', + PERSONAL_API_KEY_REVOKED: 'personal_api_key.revoked', + + // BYOK Keys + BYOK_KEY_CREATED: 'byok_key.created', + BYOK_KEY_DELETED: 'byok_key.deleted', + + // Chat + CHAT_DEPLOYED: 'chat.deployed', + CHAT_UPDATED: 'chat.updated', + CHAT_DELETED: 'chat.deleted', + + // 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_INVITATION_CREATED: 'credential_set_invitation.created', + CREDENTIAL_SET_INVITATION_REVOKED: 'credential_set_invitation.revoked', + + // Documents + DOCUMENT_UPLOADED: 'document.uploaded', + DOCUMENT_UPDATED: 'document.updated', + DOCUMENT_DELETED: 'document.deleted', + + // Environment + ENVIRONMENT_UPDATED: 'environment.updated', + + // Files + FILE_UPLOADED: 'file.uploaded', + FILE_DELETED: 'file.deleted', + + // Folders + FOLDER_CREATED: 'folder.created', + FOLDER_DELETED: 'folder.deleted', + FOLDER_DUPLICATED: 'folder.duplicated', + + // Forms + FORM_CREATED: 'form.created', + FORM_UPDATED: 'form.updated', + FORM_DELETED: 'form.deleted', + + // Invitations + INVITATION_ACCEPTED: 'invitation.accepted', + INVITATION_REVOKED: 'invitation.revoked', + + // Knowledge Bases + KNOWLEDGE_BASE_CREATED: 'knowledge_base.created', + KNOWLEDGE_BASE_UPDATED: 'knowledge_base.updated', + KNOWLEDGE_BASE_DELETED: 'knowledge_base.deleted', + + // MCP Servers + MCP_SERVER_ADDED: 'mcp_server.added', + MCP_SERVER_UPDATED: 'mcp_server.updated', + MCP_SERVER_REMOVED: 'mcp_server.removed', + + // Members + MEMBER_INVITED: 'member.invited', + MEMBER_REMOVED: 'member.removed', + MEMBER_ROLE_CHANGED: 'member.role_changed', + + // Notifications + NOTIFICATION_CREATED: 'notification.created', + NOTIFICATION_UPDATED: 'notification.updated', + NOTIFICATION_DELETED: 'notification.deleted', + + // OAuth + OAUTH_DISCONNECTED: 'oauth.disconnected', + + // Organizations + ORGANIZATION_CREATED: 'organization.created', + ORGANIZATION_UPDATED: 'organization.updated', + ORG_MEMBER_ADDED: 'org_member.added', + ORG_MEMBER_REMOVED: 'org_member.removed', + ORG_MEMBER_ROLE_CHANGED: 'org_member.role_changed', + ORG_INVITATION_CREATED: 'org_invitation.created', + ORG_INVITATION_ACCEPTED: 'org_invitation.accepted', + ORG_INVITATION_REJECTED: 'org_invitation.rejected', + ORG_INVITATION_CANCELLED: 'org_invitation.cancelled', + ORG_INVITATION_REVOKED: 'org_invitation.revoked', + + // Permission Groups + PERMISSION_GROUP_CREATED: 'permission_group.created', + PERMISSION_GROUP_UPDATED: 'permission_group.updated', + PERMISSION_GROUP_DELETED: 'permission_group.deleted', + PERMISSION_GROUP_MEMBER_ADDED: 'permission_group_member.added', + PERMISSION_GROUP_MEMBER_REMOVED: 'permission_group_member.removed', + + // Schedules + SCHEDULE_UPDATED: 'schedule.updated', + + // Webhooks + WEBHOOK_CREATED: 'webhook.created', + WEBHOOK_DELETED: 'webhook.deleted', + + // Workflows + WORKFLOW_CREATED: 'workflow.created', + WORKFLOW_DELETED: 'workflow.deleted', + WORKFLOW_DEPLOYED: 'workflow.deployed', + WORKFLOW_UNDEPLOYED: 'workflow.undeployed', + WORKFLOW_DUPLICATED: 'workflow.duplicated', + WORKFLOW_DEPLOYMENT_REVERTED: 'workflow.deployment_reverted', + WORKFLOW_VARIABLES_UPDATED: 'workflow.variables_updated', + + // Workspaces + WORKSPACE_CREATED: 'workspace.created', + WORKSPACE_DELETED: 'workspace.deleted', + WORKSPACE_DUPLICATED: 'workspace.duplicated', +} as const + +export type AuditActionType = (typeof AuditAction)[keyof typeof AuditAction] + +/** + * All resource types that can appear in audit log entries. + */ +export const AuditResourceType = { + API_KEY: 'api_key', + BYOK_KEY: 'byok_key', + CHAT: 'chat', + CREDENTIAL_SET: 'credential_set', + DOCUMENT: 'document', + ENVIRONMENT: 'environment', + FILE: 'file', + FOLDER: 'folder', + FORM: 'form', + KNOWLEDGE_BASE: 'knowledge_base', + MCP_SERVER: 'mcp_server', + NOTIFICATION: 'notification', + OAUTH: 'oauth', + ORGANIZATION: 'organization', + PERMISSION_GROUP: 'permission_group', + SCHEDULE: 'schedule', + WEBHOOK: 'webhook', + WORKFLOW: 'workflow', + WORKSPACE: 'workspace', +} as const + +export type AuditResourceTypeValue = (typeof AuditResourceType)[keyof typeof AuditResourceType] + +interface AuditLogParams { + workspaceId?: string | null + actorId: string + action: AuditActionType + resourceType: AuditResourceTypeValue + resourceId?: string + actorName?: string | null + actorEmail?: string | null + resourceName?: string + description?: string + metadata?: Record + request?: Request +} + +/** + * Records an audit log entry. Fire-and-forget — never throws or blocks the caller. + */ +export function recordAudit(params: AuditLogParams): void { + try { + const ipAddress = + params.request?.headers.get('x-forwarded-for')?.split(',')[0].trim() ?? + params.request?.headers.get('x-real-ip') ?? + undefined + const userAgent = params.request?.headers.get('user-agent') ?? undefined + + db.insert(auditLog) + .values({ + id: nanoid(), + workspaceId: params.workspaceId || null, + actorId: params.actorId, + action: params.action, + resourceType: params.resourceType, + resourceId: params.resourceId, + actorName: params.actorName ?? undefined, + actorEmail: params.actorEmail ?? undefined, + resourceName: params.resourceName, + description: params.description, + metadata: params.metadata ?? {}, + ipAddress, + userAgent, + }) + .then(() => { + logger.debug('Audit log recorded', { + action: params.action, + resourceType: params.resourceType, + }) + }) + .catch((error) => { + logger.error('Failed to record audit log', { error, action: params.action }) + }) + } catch (error) { + logger.error('Failed to initiate audit log', { error, action: params.action }) + } +} diff --git a/apps/sim/lib/mcp/domain-check.test.ts b/apps/sim/lib/mcp/domain-check.test.ts index ff72f7e6f..d34974194 100644 --- a/apps/sim/lib/mcp/domain-check.test.ts +++ b/apps/sim/lib/mcp/domain-check.test.ts @@ -3,20 +3,19 @@ */ import { beforeEach, describe, expect, it, vi } from 'vitest' -const mockGetAllowedMcpDomainsFromEnv = vi.fn<() => string[] | null>() -const mockGetBaseUrl = vi.fn<() => string>() +const { mockGetAllowedMcpDomainsFromEnv } = vi.hoisted(() => ({ + mockGetAllowedMcpDomainsFromEnv: vi.fn<() => string[] | null>(), +})) -vi.doMock('@/lib/core/config/feature-flags', () => ({ +vi.mock('@/lib/core/config/feature-flags', () => ({ getAllowedMcpDomainsFromEnv: mockGetAllowedMcpDomainsFromEnv, })) -vi.doMock('@/lib/core/utils/urls', () => ({ - getBaseUrl: mockGetBaseUrl, +vi.mock('@/executor/utils/reference-validation', () => ({ + createEnvVarPattern: () => /\{\{([^}]+)\}\}/g, })) -const { McpDomainNotAllowedError, isMcpDomainAllowed, validateMcpDomain } = await import( - './domain-check' -) +import { isMcpDomainAllowed, McpDomainNotAllowedError, validateMcpDomain } from './domain-check' describe('McpDomainNotAllowedError', () => { it.concurrent('creates error with correct name and message', () => { @@ -50,63 +49,155 @@ describe('isMcpDomainAllowed', () => { it('allows empty string URL', () => { expect(isMcpDomainAllowed('')).toBe(true) }) + + it('allows env var URLs', () => { + expect(isMcpDomainAllowed('{{MCP_SERVER_URL}}')).toBe(true) + }) + + it('allows URLs with env vars anywhere', () => { + expect(isMcpDomainAllowed('https://server.com/{{PATH}}')).toBe(true) + }) }) describe('when allowlist is configured', () => { beforeEach(() => { mockGetAllowedMcpDomainsFromEnv.mockReturnValue(['allowed.com', 'internal.company.com']) - mockGetBaseUrl.mockReturnValue('https://platform.example.com') }) - it('allows URLs on the allowlist', () => { - expect(isMcpDomainAllowed('https://allowed.com/mcp')).toBe(true) - expect(isMcpDomainAllowed('https://internal.company.com/tools')).toBe(true) - }) + describe('basic domain matching', () => { + it('allows URLs on the allowlist', () => { + expect(isMcpDomainAllowed('https://allowed.com/mcp')).toBe(true) + expect(isMcpDomainAllowed('https://internal.company.com/tools')).toBe(true) + }) - it('rejects URLs not on the allowlist', () => { - expect(isMcpDomainAllowed('https://evil.com/mcp')).toBe(false) - }) + it('allows URLs with paths on allowlisted domains', () => { + expect(isMcpDomainAllowed('https://allowed.com/deep/path/to/mcp')).toBe(true) + }) - it('rejects undefined URL (fail-closed)', () => { - expect(isMcpDomainAllowed(undefined)).toBe(false) - }) + it('allows URLs with query params on allowlisted domains', () => { + expect(isMcpDomainAllowed('https://allowed.com/mcp?key=value&foo=bar')).toBe(true) + }) - it('rejects empty string URL (fail-closed)', () => { - expect(isMcpDomainAllowed('')).toBe(false) - }) + it('allows URLs with ports on allowlisted domains', () => { + expect(isMcpDomainAllowed('https://allowed.com:8080/mcp')).toBe(true) + }) - it('rejects malformed URLs', () => { - expect(isMcpDomainAllowed('not-a-url')).toBe(false) - }) + it('allows HTTP URLs on allowlisted domains', () => { + expect(isMcpDomainAllowed('http://allowed.com/mcp')).toBe(true) + }) - it('matches case-insensitively', () => { - expect(isMcpDomainAllowed('https://ALLOWED.COM/mcp')).toBe(true) - }) + it('matches case-insensitively', () => { + expect(isMcpDomainAllowed('https://ALLOWED.COM/mcp')).toBe(true) + expect(isMcpDomainAllowed('https://Allowed.Com/mcp')).toBe(true) + }) - it('always allows the platform hostname', () => { - expect(isMcpDomainAllowed('https://platform.example.com/mcp')).toBe(true) - }) + it('rejects URLs not on the allowlist', () => { + expect(isMcpDomainAllowed('https://evil.com/mcp')).toBe(false) + }) - it('allows platform hostname even when not in the allowlist', () => { - mockGetAllowedMcpDomainsFromEnv.mockReturnValue(['other.com']) - expect(isMcpDomainAllowed('https://platform.example.com/mcp')).toBe(true) - }) - }) + it('rejects subdomains of allowed domains', () => { + expect(isMcpDomainAllowed('https://sub.allowed.com/mcp')).toBe(false) + }) - describe('when getBaseUrl is not configured', () => { - beforeEach(() => { - mockGetAllowedMcpDomainsFromEnv.mockReturnValue(['allowed.com']) - mockGetBaseUrl.mockImplementation(() => { - throw new Error('Not configured') + it('rejects URLs with allowed domain in path only', () => { + expect(isMcpDomainAllowed('https://evil.com/allowed.com/mcp')).toBe(false) }) }) - it('still allows URLs on the allowlist', () => { - expect(isMcpDomainAllowed('https://allowed.com/mcp')).toBe(true) + describe('fail-closed behavior', () => { + it('rejects undefined URL', () => { + expect(isMcpDomainAllowed(undefined)).toBe(false) + }) + + it('rejects empty string URL', () => { + expect(isMcpDomainAllowed('')).toBe(false) + }) + + it('rejects malformed URLs', () => { + expect(isMcpDomainAllowed('not-a-url')).toBe(false) + }) + + it('rejects URLs with no protocol', () => { + expect(isMcpDomainAllowed('allowed.com/mcp')).toBe(false) + }) }) - it('still rejects URLs not on the allowlist', () => { - expect(isMcpDomainAllowed('https://evil.com/mcp')).toBe(false) + describe('env var handling — hostname bypass', () => { + it('allows entirely env var URL', () => { + expect(isMcpDomainAllowed('{{MCP_SERVER_URL}}')).toBe(true) + }) + + it('allows env var URL with whitespace', () => { + expect(isMcpDomainAllowed(' {{MCP_SERVER_URL}} ')).toBe(true) + }) + + it('allows multiple env vars composing the entire URL', () => { + expect(isMcpDomainAllowed('{{PROTOCOL}}{{HOST}}{{PATH}}')).toBe(true) + }) + + it('allows env var in hostname portion', () => { + expect(isMcpDomainAllowed('https://{{MCP_HOST}}/mcp')).toBe(true) + }) + + it('allows env var as subdomain', () => { + expect(isMcpDomainAllowed('https://{{TENANT}}.company.com/mcp')).toBe(true) + }) + + it('allows env var in port (authority)', () => { + expect(isMcpDomainAllowed('https://{{HOST}}:{{PORT}}/mcp')).toBe(true) + }) + + it('allows env var as the full authority', () => { + expect(isMcpDomainAllowed('https://{{MCP_HOST}}:{{MCP_PORT}}/api/mcp')).toBe(true) + }) + }) + + describe('env var handling — no bypass when only in path/query', () => { + it('rejects disallowed domain with env var in path', () => { + expect(isMcpDomainAllowed('https://evil.com/{{MCP_PATH}}')).toBe(false) + }) + + it('rejects disallowed domain with env var in query', () => { + expect(isMcpDomainAllowed('https://evil.com/mcp?key={{API_KEY}}')).toBe(false) + }) + + it('rejects disallowed domain with env var in fragment', () => { + expect(isMcpDomainAllowed('https://evil.com/mcp#{{SECTION}}')).toBe(false) + }) + + it('allows allowlisted domain with env var in path', () => { + expect(isMcpDomainAllowed('https://allowed.com/{{MCP_PATH}}')).toBe(true) + }) + + it('allows allowlisted domain with env var in query', () => { + expect(isMcpDomainAllowed('https://allowed.com/mcp?key={{API_KEY}}')).toBe(true) + }) + + it('rejects disallowed domain with env var in both path and query', () => { + expect(isMcpDomainAllowed('https://evil.com/{{PATH}}?token={{TOKEN}}&key={{KEY}}')).toBe( + false + ) + }) + + it('rejects disallowed domain with env var in query but no path', () => { + expect(isMcpDomainAllowed('https://evil.com?token={{SECRET}}')).toBe(false) + }) + + it('rejects disallowed domain with env var in fragment but no path', () => { + expect(isMcpDomainAllowed('https://evil.com#{{SECTION}}')).toBe(false) + }) + }) + + describe('env var security edge cases', () => { + it('rejects URL with env var only after allowed domain in path', () => { + expect(isMcpDomainAllowed('https://evil.com/allowed.com/{{VAR}}')).toBe(false) + }) + + it('rejects URL trying to use env var to sneak past domain check via userinfo', () => { + // https://evil.com@allowed.com would have hostname "allowed.com" per URL spec, + // but https://{{VAR}}@evil.com has env var in authority so it bypasses + expect(isMcpDomainAllowed('https://{{VAR}}@evil.com/mcp')).toBe(true) + }) }) }) }) @@ -128,36 +219,83 @@ describe('validateMcpDomain', () => { it('does not throw for undefined URL', () => { expect(() => validateMcpDomain(undefined)).not.toThrow() }) + + it('does not throw for empty string', () => { + expect(() => validateMcpDomain('')).not.toThrow() + }) }) describe('when allowlist is configured', () => { beforeEach(() => { mockGetAllowedMcpDomainsFromEnv.mockReturnValue(['allowed.com']) - mockGetBaseUrl.mockReturnValue('https://platform.example.com') }) - it('does not throw for allowed URLs', () => { - expect(() => validateMcpDomain('https://allowed.com/mcp')).not.toThrow() + describe('basic validation', () => { + it('does not throw for allowed URLs', () => { + expect(() => validateMcpDomain('https://allowed.com/mcp')).not.toThrow() + }) + + it('throws McpDomainNotAllowedError for disallowed URLs', () => { + expect(() => validateMcpDomain('https://evil.com/mcp')).toThrow(McpDomainNotAllowedError) + }) + + it('throws for undefined URL (fail-closed)', () => { + expect(() => validateMcpDomain(undefined)).toThrow(McpDomainNotAllowedError) + }) + + it('throws for malformed URLs', () => { + expect(() => validateMcpDomain('not-a-url')).toThrow(McpDomainNotAllowedError) + }) + + it('includes the rejected domain in the error message', () => { + expect(() => validateMcpDomain('https://evil.com/mcp')).toThrow(/evil\.com/) + }) + + it('includes "(empty)" in error for undefined URL', () => { + expect(() => validateMcpDomain(undefined)).toThrow(/\(empty\)/) + }) }) - it('throws McpDomainNotAllowedError for disallowed URLs', () => { - expect(() => validateMcpDomain('https://evil.com/mcp')).toThrow(McpDomainNotAllowedError) - }) + describe('env var handling', () => { + it('does not throw for entirely env var URL', () => { + expect(() => validateMcpDomain('{{MCP_SERVER_URL}}')).not.toThrow() + }) - it('throws for undefined URL (fail-closed)', () => { - expect(() => validateMcpDomain(undefined)).toThrow(McpDomainNotAllowedError) - }) + it('does not throw for env var in hostname', () => { + expect(() => validateMcpDomain('https://{{MCP_HOST}}/mcp')).not.toThrow() + }) - it('throws for malformed URLs', () => { - expect(() => validateMcpDomain('not-a-url')).toThrow(McpDomainNotAllowedError) - }) + it('does not throw for env var in authority', () => { + expect(() => validateMcpDomain('https://{{HOST}}:{{PORT}}/mcp')).not.toThrow() + }) - it('includes the rejected domain in the error message', () => { - expect(() => validateMcpDomain('https://evil.com/mcp')).toThrow(/evil\.com/) - }) + it('throws for disallowed URL with env var only in path', () => { + expect(() => validateMcpDomain('https://evil.com/{{MCP_PATH}}')).toThrow( + McpDomainNotAllowedError + ) + }) - it('does not throw for platform hostname', () => { - expect(() => validateMcpDomain('https://platform.example.com/mcp')).not.toThrow() + it('throws for disallowed URL with env var only in query', () => { + expect(() => validateMcpDomain('https://evil.com/mcp?key={{API_KEY}}')).toThrow( + McpDomainNotAllowedError + ) + }) + + it('does not throw for allowed URL with env var in path', () => { + expect(() => validateMcpDomain('https://allowed.com/{{PATH}}')).not.toThrow() + }) + + it('throws for disallowed URL with env var in query but no path', () => { + expect(() => validateMcpDomain('https://evil.com?token={{SECRET}}')).toThrow( + McpDomainNotAllowedError + ) + }) + + it('throws for disallowed URL with env var in fragment but no path', () => { + expect(() => validateMcpDomain('https://evil.com#{{SECTION}}')).toThrow( + McpDomainNotAllowedError + ) + }) }) }) }) diff --git a/apps/sim/lib/mcp/domain-check.ts b/apps/sim/lib/mcp/domain-check.ts index e4087031f..84d4d59d5 100644 --- a/apps/sim/lib/mcp/domain-check.ts +++ b/apps/sim/lib/mcp/domain-check.ts @@ -1,5 +1,5 @@ import { getAllowedMcpDomainsFromEnv } from '@/lib/core/config/feature-flags' -import { getBaseUrl } from '@/lib/core/utils/urls' +import { createEnvVarPattern } from '@/executor/utils/reference-validation' export class McpDomainNotAllowedError extends Error { constructor(domain: string) { @@ -8,22 +8,6 @@ export class McpDomainNotAllowedError extends Error { } } -let cachedPlatformHostname: string | null = null - -/** - * Returns the platform's own hostname (from getBaseUrl), lazy-cached. - * Always lowercase. Returns null if the base URL is not configured or invalid. - */ -function getPlatformHostname(): string | null { - if (cachedPlatformHostname !== null) return cachedPlatformHostname - try { - cachedPlatformHostname = new URL(getBaseUrl()).hostname.toLowerCase() - } catch { - return null - } - return cachedPlatformHostname -} - /** * Core domain check. Returns null if the URL is allowed, or the hostname/url * string to use in the rejection error. @@ -33,27 +17,51 @@ function checkMcpDomain(url: string): string | null { if (allowedDomains === null) return null try { const hostname = new URL(url).hostname.toLowerCase() - if (hostname === getPlatformHostname()) return null return allowedDomains.includes(hostname) ? null : hostname } catch { return url } } +/** + * Returns true if the URL's hostname contains an env var reference, + * meaning domain validation must be deferred until env var resolution. + * Only bypasses validation when the hostname itself is unresolvable — + * env vars in the path/query do NOT bypass the domain check. + */ +function hasEnvVarInHostname(url: string): boolean { + // If the entire URL is an env var reference, hostname is unknown + if (url.trim().replace(createEnvVarPattern(), '').trim() === '') return true + try { + // Extract the authority portion (between :// and the first /, ?, or # per RFC 3986) + const protocolEnd = url.indexOf('://') + if (protocolEnd === -1) return createEnvVarPattern().test(url) + const afterProtocol = url.substring(protocolEnd + 3) + const authorityEnd = afterProtocol.search(/[/?#]/) + const authority = authorityEnd === -1 ? afterProtocol : afterProtocol.substring(0, authorityEnd) + return createEnvVarPattern().test(authority) + } catch { + return createEnvVarPattern().test(url) + } +} + /** * Returns true if the URL's domain is allowed (or no restriction is configured). - * The platform's own hostname (from getBaseUrl) is always allowed. + * URLs with env var references in the hostname are allowed — they will be + * validated after resolution at execution time. */ export function isMcpDomainAllowed(url: string | undefined): boolean { if (!url) { return getAllowedMcpDomainsFromEnv() === null } + if (hasEnvVarInHostname(url)) return true return checkMcpDomain(url) === null } /** * Throws McpDomainNotAllowedError if the URL's domain is not in the allowlist. - * The platform's own hostname (from getBaseUrl) is always allowed. + * URLs with env var references in the hostname are skipped — they will be + * validated after resolution at execution time. */ export function validateMcpDomain(url: string | undefined): void { if (!url) { @@ -62,6 +70,7 @@ export function validateMcpDomain(url: string | undefined): void { } return } + if (hasEnvVarInHostname(url)) return const rejected = checkMcpDomain(url) if (rejected !== null) { throw new McpDomainNotAllowedError(rejected) diff --git a/apps/sim/lib/mcp/service.ts b/apps/sim/lib/mcp/service.ts index d9bc1aa20..67801f85f 100644 --- a/apps/sim/lib/mcp/service.ts +++ b/apps/sim/lib/mcp/service.ts @@ -10,7 +10,7 @@ import { isTest } from '@/lib/core/config/feature-flags' import { generateRequestId } from '@/lib/core/utils/request' import { McpClient } from '@/lib/mcp/client' import { mcpConnectionManager } from '@/lib/mcp/connection-manager' -import { isMcpDomainAllowed } from '@/lib/mcp/domain-check' +import { isMcpDomainAllowed, validateMcpDomain } from '@/lib/mcp/domain-check' import { resolveMcpConfigEnvVars } from '@/lib/mcp/resolve-config' import { createMcpCacheAdapter, @@ -67,6 +67,7 @@ class McpService { const { config: resolvedConfig } = await resolveMcpConfigEnvVars(config, userId, workspaceId, { strict: true, }) + validateMcpDomain(resolvedConfig.url) return resolvedConfig } diff --git a/packages/db/migrations/0155_strong_spyke.sql b/packages/db/migrations/0155_strong_spyke.sql new file mode 100644 index 000000000..89c14c44d --- /dev/null +++ b/packages/db/migrations/0155_strong_spyke.sql @@ -0,0 +1,23 @@ +CREATE TABLE "audit_log" ( + "id" text PRIMARY KEY NOT NULL, + "workspace_id" text, + "actor_id" text, + "action" text NOT NULL, + "resource_type" text NOT NULL, + "resource_id" text, + "actor_name" text, + "actor_email" text, + "resource_name" text, + "description" text, + "metadata" jsonb DEFAULT '{}', + "ip_address" text, + "user_agent" text, + "created_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "audit_log" ADD CONSTRAINT "audit_log_workspace_id_workspace_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspace"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "audit_log" ADD CONSTRAINT "audit_log_actor_id_user_id_fk" FOREIGN KEY ("actor_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "audit_log_workspace_created_idx" ON "audit_log" USING btree ("workspace_id","created_at");--> statement-breakpoint +CREATE INDEX "audit_log_actor_created_idx" ON "audit_log" USING btree ("actor_id","created_at");--> statement-breakpoint +CREATE INDEX "audit_log_resource_idx" ON "audit_log" USING btree ("resource_type","resource_id");--> statement-breakpoint +CREATE INDEX "audit_log_action_idx" ON "audit_log" USING btree ("action"); \ No newline at end of file diff --git a/packages/db/migrations/meta/0155_snapshot.json b/packages/db/migrations/meta/0155_snapshot.json new file mode 100644 index 000000000..bccc9a181 --- /dev/null +++ b/packages/db/migrations/meta/0155_snapshot.json @@ -0,0 +1,11154 @@ +{ + "id": "0c338900-c013-407a-9d16-ab12e699a69d", + "prevId": "49f580f7-7eba-4431-bdf4-61db0e606546", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.a2a_agent": { + "name": "a2a_agent", + "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": 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 + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'1.0.0'" + }, + "capabilities": { + "name": "capabilities", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "skills": { + "name": "skills", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "authentication": { + "name": "authentication", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "signatures": { + "name": "signatures", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "is_published": { + "name": "is_published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "published_at": { + "name": "published_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": { + "a2a_agent_workspace_id_idx": { + "name": "a2a_agent_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "a2a_agent_workflow_id_idx": { + "name": "a2a_agent_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "a2a_agent_created_by_idx": { + "name": "a2a_agent_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "a2a_agent_workspace_workflow_unique": { + "name": "a2a_agent_workspace_workflow_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "a2a_agent_workspace_id_workspace_id_fk": { + "name": "a2a_agent_workspace_id_workspace_id_fk", + "tableFrom": "a2a_agent", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "a2a_agent_workflow_id_workflow_id_fk": { + "name": "a2a_agent_workflow_id_workflow_id_fk", + "tableFrom": "a2a_agent", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "a2a_agent_created_by_user_id_fk": { + "name": "a2a_agent_created_by_user_id_fk", + "tableFrom": "a2a_agent", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.a2a_push_notification_config": { + "name": "a2a_push_notification_config", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auth_schemes": { + "name": "auth_schemes", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "auth_credentials": { + "name": "auth_credentials", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": 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": { + "a2a_push_notification_config_task_id_idx": { + "name": "a2a_push_notification_config_task_id_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "a2a_push_notification_config_task_unique": { + "name": "a2a_push_notification_config_task_unique", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "a2a_push_notification_config_task_id_a2a_task_id_fk": { + "name": "a2a_push_notification_config_task_id_a2a_task_id_fk", + "tableFrom": "a2a_push_notification_config", + "tableTo": "a2a_task", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.a2a_task": { + "name": "a2a_task", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "a2a_task_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'submitted'" + }, + "messages": { + "name": "messages", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "artifacts": { + "name": "artifacts", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "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()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "a2a_task_agent_id_idx": { + "name": "a2a_task_agent_id_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "a2a_task_session_id_idx": { + "name": "a2a_task_session_id_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "a2a_task_status_idx": { + "name": "a2a_task_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "a2a_task_execution_id_idx": { + "name": "a2a_task_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "a2a_task_created_at_idx": { + "name": "a2a_task_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "a2a_task_agent_id_a2a_agent_id_fk": { + "name": "a2a_task_agent_id_a2a_agent_id_fk", + "tableFrom": "a2a_task", + "tableTo": "a2a_agent", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "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": {} + }, + "account_user_provider_unique": { + "name": "account_user_provider_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "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 + }, + "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": {} + } + }, + "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": {} + } + }, + "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_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.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": "'[]'" + }, + "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, + "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_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": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "messages": { + "name": "messages", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "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 + }, + "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_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": {} + } + }, + "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" + } + }, + "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.credential_set": { + "name": "credential_set", + "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 + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "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_set_organization_id_idx": { + "name": "credential_set_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_set_created_by_idx": { + "name": "credential_set_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_set_org_name_unique": { + "name": "credential_set_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": {} + }, + "credential_set_provider_id_idx": { + "name": "credential_set_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_set_organization_id_organization_id_fk": { + "name": "credential_set_organization_id_organization_id_fk", + "tableFrom": "credential_set", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_set_created_by_user_id_fk": { + "name": "credential_set_created_by_user_id_fk", + "tableFrom": "credential_set", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential_set_invitation": { + "name": "credential_set_invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "credential_set_id": { + "name": "credential_set_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "credential_set_invitation_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "accepted_by_user_id": { + "name": "accepted_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_set_invitation_set_id_idx": { + "name": "credential_set_invitation_set_id_idx", + "columns": [ + { + "expression": "credential_set_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_set_invitation_token_idx": { + "name": "credential_set_invitation_token_idx", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_set_invitation_status_idx": { + "name": "credential_set_invitation_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_set_invitation_expires_at_idx": { + "name": "credential_set_invitation_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_set_invitation_credential_set_id_credential_set_id_fk": { + "name": "credential_set_invitation_credential_set_id_credential_set_id_fk", + "tableFrom": "credential_set_invitation", + "tableTo": "credential_set", + "columnsFrom": ["credential_set_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_set_invitation_invited_by_user_id_fk": { + "name": "credential_set_invitation_invited_by_user_id_fk", + "tableFrom": "credential_set_invitation", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_set_invitation_accepted_by_user_id_user_id_fk": { + "name": "credential_set_invitation_accepted_by_user_id_user_id_fk", + "tableFrom": "credential_set_invitation", + "tableTo": "user", + "columnsFrom": ["accepted_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "credential_set_invitation_token_unique": { + "name": "credential_set_invitation_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential_set_member": { + "name": "credential_set_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "credential_set_id": { + "name": "credential_set_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "credential_set_member_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "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_set_member_set_id_idx": { + "name": "credential_set_member_set_id_idx", + "columns": [ + { + "expression": "credential_set_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_set_member_user_id_idx": { + "name": "credential_set_member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_set_member_unique": { + "name": "credential_set_member_unique", + "columns": [ + { + "expression": "credential_set_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_set_member_status_idx": { + "name": "credential_set_member_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_set_member_credential_set_id_credential_set_id_fk": { + "name": "credential_set_member_credential_set_id_credential_set_id_fk", + "tableFrom": "credential_set_member", + "tableTo": "credential_set", + "columnsFrom": ["credential_set_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_set_member_user_id_user_id_fk": { + "name": "credential_set_member_user_id_user_id_fk", + "tableFrom": "credential_set_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_set_member_invited_by_user_id_fk": { + "name": "credential_set_member_invited_by_user_id_fk", + "tableFrom": "credential_set_member", + "tableTo": "user", + "columnsFrom": ["invited_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.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 + }, + "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 + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": 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 + }, + "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_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" + } + }, + "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.form": { + "name": "form", + "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": "'[]'" + }, + "show_branding": { + "name": "show_branding", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": 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": { + "form_identifier_idx": { + "name": "form_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "form_workflow_id_idx": { + "name": "form_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "form_user_id_idx": { + "name": "form_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "form_workflow_id_workflow_id_fk": { + "name": "form_workflow_id_workflow_id_fk", + "tableFrom": "form", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "form_user_id_user_id_fk": { + "name": "form_user_id_user_id_fk", + "tableFrom": "form", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "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 + }, + "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": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "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()" + } + }, + "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": {} + } + }, + "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": {}, + "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": {} + } + }, + "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": "no action", + "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.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 + }, + "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_idx": { + "name": "mcp_servers_workspace_deleted_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "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": {} + } + }, + "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.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 + }, + "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 + }, + "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.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 + } + }, + "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": {} + } + }, + "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.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()" + }, + "auto_add_new_members": { + "name": "auto_add_new_members", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "permission_group_organization_id_idx": { + "name": "permission_group_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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_org_name_unique": { + "name": "permission_group_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": {} + }, + "permission_group_org_auto_add_unique": { + "name": "permission_group_org_auto_add_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "auto_add_new_members = 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 + }, + "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_user_id_unique": { + "name": "permission_group_member_user_id_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "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_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.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.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.referral_attribution": { + "name": "referral_attribution", + "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": false + }, + "campaign_id": { + "name": "campaign_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "utm_source": { + "name": "utm_source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "utm_medium": { + "name": "utm_medium", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "utm_campaign": { + "name": "utm_campaign", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "utm_content": { + "name": "utm_content", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "referrer_url": { + "name": "referrer_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "landing_page": { + "name": "landing_page", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bonus_credit_amount": { + "name": "bonus_credit_amount", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "referral_attribution_user_id_idx": { + "name": "referral_attribution_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "referral_attribution_org_unique_idx": { + "name": "referral_attribution_org_unique_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"referral_attribution\".\"organization_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "referral_attribution_campaign_id_idx": { + "name": "referral_attribution_campaign_id_idx", + "columns": [ + { + "expression": "campaign_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "referral_attribution_utm_campaign_idx": { + "name": "referral_attribution_utm_campaign_idx", + "columns": [ + { + "expression": "utm_campaign", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "referral_attribution_utm_content_idx": { + "name": "referral_attribution_utm_content_idx", + "columns": [ + { + "expression": "utm_content", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "referral_attribution_created_at_idx": { + "name": "referral_attribution_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "referral_attribution_user_id_user_id_fk": { + "name": "referral_attribution_user_id_user_id_fk", + "tableFrom": "referral_attribution", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "referral_attribution_organization_id_organization_id_fk": { + "name": "referral_attribution_organization_id_organization_id_fk", + "tableFrom": "referral_attribution", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "referral_attribution_campaign_id_referral_campaigns_id_fk": { + "name": "referral_attribution_campaign_id_referral_campaigns_id_fk", + "tableFrom": "referral_attribution", + "tableTo": "referral_campaigns", + "columnsFrom": ["campaign_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "referral_attribution_user_id_unique": { + "name": "referral_attribution_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.referral_campaigns": { + "name": "referral_campaigns", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "utm_source": { + "name": "utm_source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "utm_medium": { + "name": "utm_medium", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "utm_campaign": { + "name": "utm_campaign", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "utm_content": { + "name": "utm_content", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bonus_credit_amount": { + "name": "bonus_credit_amount", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": 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": { + "referral_campaigns_active_idx": { + "name": "referral_campaigns_active_idx", + "columns": [ + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "referral_campaigns_code_unique": { + "name": "referral_campaigns_code_unique", + "nullsNotDistinct": false, + "columns": ["code"] + } + }, + "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 + } + }, + "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": "'dark'" + }, + "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 + }, + "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 + }, + "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": "'[]'" + }, + "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.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_id_idx": { + "name": "skill_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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 + }, + "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 + }, + "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.template_creators": { + "name": "template_creators", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "reference_type": { + "name": "reference_type", + "type": "template_creator_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "profile_image_url": { + "name": "profile_image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "verified": { + "name": "verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": 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": { + "template_creators_reference_idx": { + "name": "template_creators_reference_idx", + "columns": [ + { + "expression": "reference_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_creators_reference_id_idx": { + "name": "template_creators_reference_id_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_creators_created_by_idx": { + "name": "template_creators_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "template_creators_created_by_user_id_fk": { + "name": "template_creators_created_by_user_id_fk", + "tableFrom": "template_creators", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.template_stars": { + "name": "template_stars", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "template_id": { + "name": "template_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "starred_at": { + "name": "starred_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "template_stars_user_id_idx": { + "name": "template_stars_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_stars_template_id_idx": { + "name": "template_stars_template_id_idx", + "columns": [ + { + "expression": "template_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_stars_user_template_idx": { + "name": "template_stars_user_template_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "template_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_stars_template_user_idx": { + "name": "template_stars_template_user_idx", + "columns": [ + { + "expression": "template_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_stars_starred_at_idx": { + "name": "template_stars_starred_at_idx", + "columns": [ + { + "expression": "starred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_stars_template_starred_at_idx": { + "name": "template_stars_template_starred_at_idx", + "columns": [ + { + "expression": "template_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "starred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "template_stars_user_template_unique": { + "name": "template_stars_user_template_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "template_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "template_stars_user_id_user_id_fk": { + "name": "template_stars_user_id_user_id_fk", + "tableFrom": "template_stars", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "template_stars_template_id_templates_id_fk": { + "name": "template_stars_template_id_templates_id_fk", + "tableFrom": "template_stars", + "tableTo": "templates", + "columnsFrom": ["template_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.templates": { + "name": "templates", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "creator_id": { + "name": "creator_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "views": { + "name": "views", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "stars": { + "name": "stars", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "template_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "tags": { + "name": "tags", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "required_credentials": { + "name": "required_credentials", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "state": { + "name": "state", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "og_image_url": { + "name": "og_image_url", + "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": { + "templates_status_idx": { + "name": "templates_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_creator_id_idx": { + "name": "templates_creator_id_idx", + "columns": [ + { + "expression": "creator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_views_idx": { + "name": "templates_views_idx", + "columns": [ + { + "expression": "views", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_stars_idx": { + "name": "templates_stars_idx", + "columns": [ + { + "expression": "stars", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_status_views_idx": { + "name": "templates_status_views_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "views", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_status_stars_idx": { + "name": "templates_status_stars_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "stars", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_created_at_idx": { + "name": "templates_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "templates_updated_at_idx": { + "name": "templates_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "templates_workflow_id_workflow_id_fk": { + "name": "templates_workflow_id_workflow_id_fk", + "tableFrom": "templates", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "templates_creator_id_template_creators_id_fk": { + "name": "templates_creator_id_template_creators_id_fk", + "tableFrom": "templates", + "tableTo": "template_creators", + "columnsFrom": ["creator_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 + }, + "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": {} + } + }, + "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": {}, + "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 + }, + "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 + }, + "is_super_user": { + "name": "is_super_user", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["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_a2a_executions": { + "name": "total_a2a_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_tokens_used": { + "name": "total_tokens_used", + "type": "integer", + "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": "'20'" + }, + "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'" + }, + "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": "integer", + "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 + } + }, + "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.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 + }, + "credential_set_id": { + "name": "credential_set_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": { + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_webhook_on_workflow_id_block_id": { + "name": "idx_webhook_on_workflow_id_block_id", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "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_credential_set_id_idx": { + "name": "webhook_credential_set_id_idx", + "columns": [ + { + "expression": "credential_set_id", + "isExpression": false, + "asc": true, + "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" + }, + "webhook_credential_set_id_credential_set_id_fk": { + "name": "webhook_credential_set_id_credential_set_id_fk", + "tableFrom": "webhook", + "tableTo": "credential_set", + "columnsFrom": ["credential_set_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "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 + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#3972F6'" + }, + "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 + }, + "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": "'{}'" + } + }, + "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_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": {} + } + }, + "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 + }, + "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": {} + } + }, + "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 + }, + "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()" + } + }, + "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": {} + } + }, + "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 + }, + "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": {} + } + }, + "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": "'{}'" + }, + "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, + "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": 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 + }, + "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 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_failed_at": { + "name": "last_failed_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, + "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": {} + } + }, + "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" + } + }, + "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 + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "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 + }, + "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": { + "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_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" + } + }, + "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 + }, + "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_provider_unique": { + "name": "workspace_byok_provider_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_byok_workspace_idx": { + "name": "workspace_byok_workspace_idx", + "columns": [ + { + "expression": "workspace_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 + }, + "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": {} + } + }, + "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_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 + }, + "context": { + "name": "context", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "original_name": { + "name": "original_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "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_context_idx": { + "name": "workspace_files_context_idx", + "columns": [ + { + "expression": "context", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "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" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_files_key_unique": { + "name": "workspace_files_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_invitation": { + "name": "workspace_invitation", + "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 + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "status": { + "name": "status", + "type": "workspace_invitation_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permissions": { + "name": "permissions", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'admin'" + }, + "org_invitation_id": { + "name": "org_invitation_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()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "workspace_invitation_workspace_id_workspace_id_fk": { + "name": "workspace_invitation_workspace_id_workspace_id_fk", + "tableFrom": "workspace_invitation", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_invitation_inviter_id_user_id_fk": { + "name": "workspace_invitation_inviter_id_user_id_fk", + "tableFrom": "workspace_invitation", + "tableTo": "user", + "columnsFrom": ["inviter_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_invitation_token_unique": { + "name": "workspace_invitation_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_notification_delivery": { + "name": "workspace_notification_delivery", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "subscription_id": { + "name": "subscription_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "notification_delivery_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_attempt_at": { + "name": "last_attempt_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "response_status": { + "name": "response_status", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "response_body": { + "name": "response_body", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "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_notification_delivery_subscription_id_idx": { + "name": "workspace_notification_delivery_subscription_id_idx", + "columns": [ + { + "expression": "subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_notification_delivery_execution_id_idx": { + "name": "workspace_notification_delivery_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_notification_delivery_status_idx": { + "name": "workspace_notification_delivery_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_notification_delivery_next_attempt_idx": { + "name": "workspace_notification_delivery_next_attempt_idx", + "columns": [ + { + "expression": "next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_notification_delivery_subscription_id_workspace_notification_subscription_id_fk": { + "name": "workspace_notification_delivery_subscription_id_workspace_notification_subscription_id_fk", + "tableFrom": "workspace_notification_delivery", + "tableTo": "workspace_notification_subscription", + "columnsFrom": ["subscription_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_notification_delivery_workflow_id_workflow_id_fk": { + "name": "workspace_notification_delivery_workflow_id_workflow_id_fk", + "tableFrom": "workspace_notification_delivery", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_notification_subscription": { + "name": "workspace_notification_subscription", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "notification_type": { + "name": "notification_type", + "type": "notification_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "workflow_ids": { + "name": "workflow_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "all_workflows": { + "name": "all_workflows", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "level_filter": { + "name": "level_filter", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY['info', 'error']::text[]" + }, + "trigger_filter": { + "name": "trigger_filter", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY['api', 'webhook', 'schedule', 'manual', 'chat']::text[]" + }, + "include_final_output": { + "name": "include_final_output", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "include_trace_spans": { + "name": "include_trace_spans", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "include_rate_limits": { + "name": "include_rate_limits", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "include_usage_data": { + "name": "include_usage_data", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "webhook_config": { + "name": "webhook_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "email_recipients": { + "name": "email_recipients", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "slack_config": { + "name": "slack_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "alert_config": { + "name": "alert_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "last_alert_at": { + "name": "last_alert_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "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": { + "workspace_notification_workspace_id_idx": { + "name": "workspace_notification_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_notification_active_idx": { + "name": "workspace_notification_active_idx", + "columns": [ + { + "expression": "active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_notification_type_idx": { + "name": "workspace_notification_type_idx", + "columns": [ + { + "expression": "notification_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_notification_subscription_workspace_id_workspace_id_fk": { + "name": "workspace_notification_subscription_workspace_id_workspace_id_fk", + "tableFrom": "workspace_notification_subscription", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_notification_subscription_created_by_user_id_fk": { + "name": "workspace_notification_subscription_created_by_user_id_fk", + "tableFrom": "workspace_notification_subscription", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.a2a_task_status": { + "name": "a2a_task_status", + "schema": "public", + "values": [ + "submitted", + "working", + "input-required", + "completed", + "failed", + "canceled", + "rejected", + "auth-required", + "unknown" + ] + }, + "public.billing_blocked_reason": { + "name": "billing_blocked_reason", + "schema": "public", + "values": ["payment_failed", "dispute"] + }, + "public.credential_set_invitation_status": { + "name": "credential_set_invitation_status", + "schema": "public", + "values": ["pending", "accepted", "expired", "cancelled"] + }, + "public.credential_set_member_status": { + "name": "credential_set_member_status", + "schema": "public", + "values": ["active", "pending", "revoked"] + }, + "public.notification_delivery_status": { + "name": "notification_delivery_status", + "schema": "public", + "values": ["pending", "in_progress", "success", "failed"] + }, + "public.notification_type": { + "name": "notification_type", + "schema": "public", + "values": ["webhook", "email", "slack"] + }, + "public.permission_type": { + "name": "permission_type", + "schema": "public", + "values": ["admin", "write", "read"] + }, + "public.template_creator_type": { + "name": "template_creator_type", + "schema": "public", + "values": ["user", "organization"] + }, + "public.template_status": { + "name": "template_status", + "schema": "public", + "values": ["pending", "approved", "rejected"] + }, + "public.usage_log_category": { + "name": "usage_log_category", + "schema": "public", + "values": ["model", "fixed"] + }, + "public.usage_log_source": { + "name": "usage_log_source", + "schema": "public", + "values": ["workflow", "wand", "copilot", "mcp_copilot"] + }, + "public.workspace_invitation_status": { + "name": "workspace_invitation_status", + "schema": "public", + "values": ["pending", "accepted", "rejected", "cancelled"] + } + }, + "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 2b83d1c90..c088f8381 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -1079,6 +1079,13 @@ "when": 1770869658697, "tag": "0154_bumpy_living_mummy", "breakpoints": true + }, + { + "idx": 155, + "version": "7", + "when": 1771401034633, + "tag": "0155_strong_spyke", + "breakpoints": true } ] } diff --git a/packages/db/schema.ts b/packages/db/schema.ts index 090ab0855..452ad24d2 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -2026,6 +2026,35 @@ export const a2aPushNotificationConfig = pgTable( }) ) +export const auditLog = pgTable( + 'audit_log', + { + id: text('id').primaryKey(), + workspaceId: text('workspace_id').references(() => workspace.id, { onDelete: 'set null' }), + actorId: text('actor_id').references(() => user.id, { onDelete: 'set null' }), + action: text('action').notNull(), + resourceType: text('resource_type').notNull(), + resourceId: text('resource_id'), + actorName: text('actor_name'), + actorEmail: text('actor_email'), + resourceName: text('resource_name'), + description: text('description'), + metadata: jsonb('metadata').default('{}'), + ipAddress: text('ip_address'), + userAgent: text('user_agent'), + createdAt: timestamp('created_at').notNull().defaultNow(), + }, + (table) => ({ + workspaceCreatedIdx: index('audit_log_workspace_created_idx').on( + table.workspaceId, + table.createdAt + ), + actorCreatedIdx: index('audit_log_actor_created_idx').on(table.actorId, table.createdAt), + resourceIdx: index('audit_log_resource_idx').on(table.resourceType, table.resourceId), + actionIdx: index('audit_log_action_idx').on(table.action), + }) +) + export const usageLogCategoryEnum = pgEnum('usage_log_category', ['model', 'fixed']) export const usageLogSourceEnum = pgEnum('usage_log_source', [ 'workflow', diff --git a/packages/testing/src/index.ts b/packages/testing/src/index.ts index 429f7d460..ce0d00cf0 100644 --- a/packages/testing/src/index.ts +++ b/packages/testing/src/index.ts @@ -45,6 +45,7 @@ export * from './assertions' export * from './builders' export * from './factories' export { + auditMock, clearRedisMocks, createEnvMock, createMockDb, diff --git a/packages/testing/src/mocks/audit.mock.ts b/packages/testing/src/mocks/audit.mock.ts new file mode 100644 index 000000000..13d28a732 --- /dev/null +++ b/packages/testing/src/mocks/audit.mock.ts @@ -0,0 +1,108 @@ +import { vi } from 'vitest' + +/** + * Mock module for @/lib/audit/log. + * Use with vi.mock() to replace the real audit logger in tests. + * + * @example + * ```ts + * vi.mock('@/lib/audit/log', () => auditMock) + * ``` + */ +export const auditMock = { + recordAudit: vi.fn(), + AuditAction: { + API_KEY_CREATED: 'api_key.created', + API_KEY_UPDATED: 'api_key.updated', + API_KEY_REVOKED: 'api_key.revoked', + PERSONAL_API_KEY_CREATED: 'personal_api_key.created', + PERSONAL_API_KEY_REVOKED: 'personal_api_key.revoked', + BYOK_KEY_CREATED: 'byok_key.created', + BYOK_KEY_DELETED: 'byok_key.deleted', + CHAT_DEPLOYED: 'chat.deployed', + CHAT_UPDATED: 'chat.updated', + CHAT_DELETED: 'chat.deleted', + 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_INVITATION_CREATED: 'credential_set_invitation.created', + CREDENTIAL_SET_INVITATION_REVOKED: 'credential_set_invitation.revoked', + DOCUMENT_UPLOADED: 'document.uploaded', + DOCUMENT_UPDATED: 'document.updated', + DOCUMENT_DELETED: 'document.deleted', + ENVIRONMENT_UPDATED: 'environment.updated', + FILE_UPLOADED: 'file.uploaded', + FILE_DELETED: 'file.deleted', + FOLDER_CREATED: 'folder.created', + FOLDER_DELETED: 'folder.deleted', + FOLDER_DUPLICATED: 'folder.duplicated', + FORM_CREATED: 'form.created', + FORM_UPDATED: 'form.updated', + FORM_DELETED: 'form.deleted', + INVITATION_ACCEPTED: 'invitation.accepted', + INVITATION_REVOKED: 'invitation.revoked', + KNOWLEDGE_BASE_CREATED: 'knowledge_base.created', + KNOWLEDGE_BASE_UPDATED: 'knowledge_base.updated', + KNOWLEDGE_BASE_DELETED: 'knowledge_base.deleted', + MCP_SERVER_ADDED: 'mcp_server.added', + MCP_SERVER_UPDATED: 'mcp_server.updated', + MCP_SERVER_REMOVED: 'mcp_server.removed', + MEMBER_INVITED: 'member.invited', + MEMBER_REMOVED: 'member.removed', + MEMBER_ROLE_CHANGED: 'member.role_changed', + NOTIFICATION_CREATED: 'notification.created', + NOTIFICATION_UPDATED: 'notification.updated', + NOTIFICATION_DELETED: 'notification.deleted', + OAUTH_DISCONNECTED: 'oauth.disconnected', + ORGANIZATION_CREATED: 'organization.created', + ORGANIZATION_UPDATED: 'organization.updated', + ORG_MEMBER_ADDED: 'org_member.added', + ORG_MEMBER_REMOVED: 'org_member.removed', + ORG_MEMBER_ROLE_CHANGED: 'org_member.role_changed', + ORG_INVITATION_CREATED: 'org_invitation.created', + ORG_INVITATION_ACCEPTED: 'org_invitation.accepted', + ORG_INVITATION_REJECTED: 'org_invitation.rejected', + ORG_INVITATION_CANCELLED: 'org_invitation.cancelled', + ORG_INVITATION_REVOKED: 'org_invitation.revoked', + PERMISSION_GROUP_CREATED: 'permission_group.created', + PERMISSION_GROUP_UPDATED: 'permission_group.updated', + PERMISSION_GROUP_DELETED: 'permission_group.deleted', + PERMISSION_GROUP_MEMBER_ADDED: 'permission_group_member.added', + PERMISSION_GROUP_MEMBER_REMOVED: 'permission_group_member.removed', + SCHEDULE_UPDATED: 'schedule.updated', + WEBHOOK_CREATED: 'webhook.created', + WEBHOOK_DELETED: 'webhook.deleted', + WORKFLOW_CREATED: 'workflow.created', + WORKFLOW_DELETED: 'workflow.deleted', + WORKFLOW_DEPLOYED: 'workflow.deployed', + WORKFLOW_UNDEPLOYED: 'workflow.undeployed', + WORKFLOW_DUPLICATED: 'workflow.duplicated', + WORKFLOW_DEPLOYMENT_REVERTED: 'workflow.deployment_reverted', + WORKFLOW_VARIABLES_UPDATED: 'workflow.variables_updated', + WORKSPACE_CREATED: 'workspace.created', + WORKSPACE_DELETED: 'workspace.deleted', + WORKSPACE_DUPLICATED: 'workspace.duplicated', + }, + AuditResourceType: { + API_KEY: 'api_key', + BYOK_KEY: 'byok_key', + CHAT: 'chat', + CREDENTIAL_SET: 'credential_set', + DOCUMENT: 'document', + ENVIRONMENT: 'environment', + FILE: 'file', + FOLDER: 'folder', + FORM: 'form', + KNOWLEDGE_BASE: 'knowledge_base', + MCP_SERVER: 'mcp_server', + NOTIFICATION: 'notification', + OAUTH: 'oauth', + ORGANIZATION: 'organization', + PERMISSION_GROUP: 'permission_group', + SCHEDULE: 'schedule', + WEBHOOK: 'webhook', + WORKFLOW: 'workflow', + WORKSPACE: 'workspace', + }, +} diff --git a/packages/testing/src/mocks/index.ts b/packages/testing/src/mocks/index.ts index 19bceca6b..1a302f627 100644 --- a/packages/testing/src/mocks/index.ts +++ b/packages/testing/src/mocks/index.ts @@ -24,6 +24,8 @@ export { mockKnowledgeSchemas, setupCommonApiMocks, } from './api.mock' +// Audit mocks +export { auditMock } from './audit.mock' // Auth mocks export { defaultMockUser, From 7c7c0fd955032c08eacd3d8179a02f79ec62827b Mon Sep 17 00:00:00 2001 From: Waleed Date: Wed, 18 Feb 2026 11:53:08 -0800 Subject: [PATCH 08/10] feat(audit-log): add audit events for templates, billing, credentials, env, deployments, passwords (#3246) * feat(audit-log): add audit events for templates, billing, credentials, env, deployments, passwords * improvement(audit-log): add actorName/actorEmail to all recordAudit calls * fix(audit-log): resolve user for password reset, add CREDENTIAL_SET_INVITATION_RESENT action * fix(audit-log): add workspaceId to deployment activation audit * improvement(audit-log): use better-auth callback for password reset audit, remove cast - Move password reset audit to onPasswordReset callback in auth config instead of coupling to better-auth's verification table internals - Remove ugly double-cast on workflowData.workspaceId in deployment activation * fix(audit-log): add missing actorName/actorEmail to workflow duplicate * improvement(audit-log): add resourceName to credential set invitation accept --- apps/sim/app/api/billing/credits/route.ts | 12 +++++++++ .../[id]/invite/[invitationId]/route.ts | 14 ++++++++++ .../credential-sets/invite/[token]/route.ts | 19 +++++++++++--- .../api/credential-sets/memberships/route.ts | 12 +++++++++ apps/sim/app/api/environment/route.ts | 12 +++++++++ .../[id]/documents/[documentId]/route.ts | 4 +++ .../app/api/knowledge/[id]/documents/route.ts | 4 +++ apps/sim/app/api/knowledge/[id]/route.ts | 4 +++ apps/sim/app/api/mcp/servers/[id]/route.ts | 8 +++++- apps/sim/app/api/mcp/servers/route.ts | 8 ++++-- .../api/mcp/workflow-servers/[id]/route.ts | 16 ++++++++++-- .../[id]/tools/[toolId]/route.ts | 16 ++++++++++-- .../mcp/workflow-servers/[id]/tools/route.ts | 8 +++++- .../sim/app/api/mcp/workflow-servers/route.ts | 4 ++- apps/sim/app/api/templates/[id]/route.ts | 26 +++++++++++++++++++ apps/sim/app/api/templates/route.ts | 13 ++++++++++ apps/sim/app/api/webhooks/[id]/route.ts | 2 ++ apps/sim/app/api/webhooks/route.ts | 5 +++- .../[id]/deployments/[version]/route.ts | 14 ++++++++++ .../app/api/workflows/[id]/duplicate/route.ts | 2 ++ apps/sim/app/api/workflows/[id]/route.ts | 2 ++ .../app/api/workflows/[id]/variables/route.ts | 2 ++ apps/sim/app/api/workflows/route.ts | 2 ++ apps/sim/lib/audit/log.ts | 18 +++++++++++++ apps/sim/lib/auth/auth.ts | 11 ++++++++ apps/sim/lib/auth/hybrid.ts | 6 +++++ apps/sim/lib/mcp/middleware.ts | 4 +++ packages/testing/src/mocks/audit.mock.ts | 12 +++++++++ 28 files changed, 246 insertions(+), 14 deletions(-) diff --git a/apps/sim/app/api/billing/credits/route.ts b/apps/sim/app/api/billing/credits/route.ts index 9a87e8c92..7dfeafb2e 100644 --- a/apps/sim/app/api/billing/credits/route.ts +++ b/apps/sim/app/api/billing/credits/route.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' +import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { getCreditBalance } from '@/lib/billing/credits/balance' import { purchaseCredits } from '@/lib/billing/credits/purchase' @@ -57,6 +58,17 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: result.error }, { status: 400 }) } + recordAudit({ + actorId: session.user.id, + actorName: session.user.name, + actorEmail: session.user.email, + action: AuditAction.CREDIT_PURCHASED, + resourceType: AuditResourceType.BILLING, + description: `Purchased $${validation.data.amount} in credits`, + metadata: { amount: validation.data.amount, requestId: validation.data.requestId }, + request, + }) + return NextResponse.json({ success: true }) } catch (error) { logger.error('Failed to purchase credits', { error, userId: session.user.id }) 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 index 2e7a5a7dc..b48a544e2 100644 --- a/apps/sim/app/api/credential-sets/[id]/invite/[invitationId]/route.ts +++ b/apps/sim/app/api/credential-sets/[id]/invite/[invitationId]/route.ts @@ -4,6 +4,7 @@ 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 { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { hasCredentialSetsAccess } from '@/lib/billing' import { getBaseUrl } from '@/lib/core/utils/urls' @@ -148,6 +149,19 @@ export async function POST( userId: session.user.id, }) + recordAudit({ + 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, email: invitation.email }, + request: req, + }) + return NextResponse.json({ success: true }) } catch (error) { logger.error('Error resending invitation', error) diff --git a/apps/sim/app/api/credential-sets/invite/[token]/route.ts b/apps/sim/app/api/credential-sets/invite/[token]/route.ts index c42fbecda..b85c65fed 100644 --- a/apps/sim/app/api/credential-sets/invite/[token]/route.ts +++ b/apps/sim/app/api/credential-sets/invite/[token]/route.ts @@ -8,6 +8,7 @@ import { import { createLogger } from '@sim/logger' import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' +import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { syncAllWebhooksForCredentialSet } from '@/lib/webhooks/utils.server' @@ -78,6 +79,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ tok status: credentialSetInvitation.status, expiresAt: credentialSetInvitation.expiresAt, invitedBy: credentialSetInvitation.invitedBy, + credentialSetName: credentialSet.name, providerId: credentialSet.providerId, }) .from(credentialSetInvitation) @@ -125,7 +127,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ tok const now = new Date() const requestId = crypto.randomUUID().slice(0, 8) - // Use transaction to ensure membership + invitation update + webhook sync are atomic await db.transaction(async (tx) => { await tx.insert(credentialSetMember).values({ id: crypto.randomUUID(), @@ -147,8 +148,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ tok }) .where(eq(credentialSetInvitation.id, invitation.id)) - // Clean up all other pending invitations for the same credential set and email - // This prevents duplicate invites from showing up after accepting one if (invitation.email) { await tx .update(credentialSetInvitation) @@ -166,7 +165,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ tok ) } - // Sync webhooks within the transaction const syncResult = await syncAllWebhooksForCredentialSet( invitation.credentialSetId, requestId, @@ -184,6 +182,19 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ tok 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 }, + request: req, + }) + return NextResponse.json({ success: true, credentialSetId: invitation.credentialSetId, diff --git a/apps/sim/app/api/credential-sets/memberships/route.ts b/apps/sim/app/api/credential-sets/memberships/route.ts index 5ce0384d4..045d68ad1 100644 --- a/apps/sim/app/api/credential-sets/memberships/route.ts +++ b/apps/sim/app/api/credential-sets/memberships/route.ts @@ -3,6 +3,7 @@ import { credentialSet, credentialSetMember, organization } from '@sim/db/schema import { createLogger } from '@sim/logger' import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' +import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { syncAllWebhooksForCredentialSet } from '@/lib/webhooks/utils.server' @@ -106,6 +107,17 @@ export async function DELETE(req: NextRequest) { 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`, + request: req, + }) + return NextResponse.json({ success: true }) } catch (error) { const message = error instanceof Error ? error.message : 'Failed to leave credential set' diff --git a/apps/sim/app/api/environment/route.ts b/apps/sim/app/api/environment/route.ts index ad2818b0d..494058f9c 100644 --- a/apps/sim/app/api/environment/route.ts +++ b/apps/sim/app/api/environment/route.ts @@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger' import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' +import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption' import { generateRequestId } from '@/lib/core/utils/request' @@ -53,6 +54,17 @@ export async function POST(req: NextRequest) { }, }) + recordAudit({ + actorId: session.user.id, + actorName: session.user.name, + actorEmail: session.user.email, + action: AuditAction.ENVIRONMENT_UPDATED, + resourceType: AuditResourceType.ENVIRONMENT, + description: 'Updated global environment variables', + metadata: { variableCount: Object.keys(variables).length }, + request: req, + }) + return NextResponse.json({ success: true }) } catch (validationError) { if (validationError instanceof z.ZodError) { diff --git a/apps/sim/app/api/knowledge/[id]/documents/[documentId]/route.ts b/apps/sim/app/api/knowledge/[id]/documents/[documentId]/route.ts index 02fadc7d1..d7f6932c2 100644 --- a/apps/sim/app/api/knowledge/[id]/documents/[documentId]/route.ts +++ b/apps/sim/app/api/knowledge/[id]/documents/[documentId]/route.ts @@ -201,6 +201,8 @@ export async function PUT( recordAudit({ workspaceId: accessCheck.knowledgeBase?.workspaceId ?? null, actorId: userId, + actorName: auth.userName, + actorEmail: auth.userEmail, action: AuditAction.DOCUMENT_UPDATED, resourceType: AuditResourceType.DOCUMENT, resourceId: documentId, @@ -272,6 +274,8 @@ export async function DELETE( recordAudit({ workspaceId: accessCheck.knowledgeBase?.workspaceId ?? null, actorId: userId, + actorName: auth.userName, + actorEmail: auth.userEmail, action: AuditAction.DOCUMENT_DELETED, resourceType: AuditResourceType.DOCUMENT, resourceId: documentId, diff --git a/apps/sim/app/api/knowledge/[id]/documents/route.ts b/apps/sim/app/api/knowledge/[id]/documents/route.ts index 55817ea10..4c0ba0217 100644 --- a/apps/sim/app/api/knowledge/[id]/documents/route.ts +++ b/apps/sim/app/api/knowledge/[id]/documents/route.ts @@ -248,6 +248,8 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: recordAudit({ workspaceId: accessCheck.knowledgeBase?.workspaceId ?? null, actorId: userId, + actorName: auth.userName, + actorEmail: auth.userEmail, action: AuditAction.DOCUMENT_UPLOADED, resourceType: AuditResourceType.DOCUMENT, resourceId: knowledgeBaseId, @@ -307,6 +309,8 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: recordAudit({ workspaceId: accessCheck.knowledgeBase?.workspaceId ?? null, actorId: userId, + actorName: auth.userName, + actorEmail: auth.userEmail, action: AuditAction.DOCUMENT_UPLOADED, resourceType: AuditResourceType.DOCUMENT, resourceId: knowledgeBaseId, diff --git a/apps/sim/app/api/knowledge/[id]/route.ts b/apps/sim/app/api/knowledge/[id]/route.ts index 09e42803e..7c3075a5d 100644 --- a/apps/sim/app/api/knowledge/[id]/route.ts +++ b/apps/sim/app/api/knowledge/[id]/route.ts @@ -139,6 +139,8 @@ export async function PUT(req: NextRequest, { params }: { params: Promise<{ id: recordAudit({ workspaceId: accessCheck.knowledgeBase.workspaceId ?? null, actorId: userId, + actorName: auth.userName, + actorEmail: auth.userEmail, action: AuditAction.KNOWLEDGE_BASE_UPDATED, resourceType: AuditResourceType.KNOWLEDGE_BASE, resourceId: id, @@ -212,6 +214,8 @@ export async function DELETE( recordAudit({ workspaceId: accessCheck.knowledgeBase.workspaceId ?? null, actorId: userId, + actorName: auth.userName, + actorEmail: auth.userEmail, action: AuditAction.KNOWLEDGE_BASE_DELETED, resourceType: AuditResourceType.KNOWLEDGE_BASE, resourceId: id, diff --git a/apps/sim/app/api/mcp/servers/[id]/route.ts b/apps/sim/app/api/mcp/servers/[id]/route.ts index d839357e2..19c2609ab 100644 --- a/apps/sim/app/api/mcp/servers/[id]/route.ts +++ b/apps/sim/app/api/mcp/servers/[id]/route.ts @@ -17,7 +17,11 @@ export const dynamic = 'force-dynamic' * PATCH - Update an MCP server in the workspace (requires write or admin permission) */ export const PATCH = withMcpAuth<{ id: string }>('write')( - async (request: NextRequest, { userId, workspaceId, requestId }, { params }) => { + async ( + request: NextRequest, + { userId, userName, userEmail, workspaceId, requestId }, + { params } + ) => { const { id: serverId } = await params try { @@ -90,6 +94,8 @@ export const PATCH = withMcpAuth<{ id: string }>('write')( recordAudit({ workspaceId, actorId: userId, + actorName: userName, + actorEmail: userEmail, action: AuditAction.MCP_SERVER_UPDATED, resourceType: AuditResourceType.MCP_SERVER, resourceId: serverId, diff --git a/apps/sim/app/api/mcp/servers/route.ts b/apps/sim/app/api/mcp/servers/route.ts index dbc289fe0..3087ff9bd 100644 --- a/apps/sim/app/api/mcp/servers/route.ts +++ b/apps/sim/app/api/mcp/servers/route.ts @@ -56,7 +56,7 @@ export const GET = withMcpAuth('read')( * it will be updated instead of creating a duplicate. */ export const POST = withMcpAuth('write')( - async (request: NextRequest, { userId, workspaceId, requestId }) => { + async (request: NextRequest, { userId, userName, userEmail, workspaceId, requestId }) => { try { const body = getParsedBody(request) || (await request.json()) @@ -165,6 +165,8 @@ export const POST = withMcpAuth('write')( recordAudit({ workspaceId, actorId: userId, + actorName: userName, + actorEmail: userEmail, action: AuditAction.MCP_SERVER_ADDED, resourceType: AuditResourceType.MCP_SERVER, resourceId: serverId, @@ -190,7 +192,7 @@ export const POST = withMcpAuth('write')( * DELETE - Delete an MCP server from the workspace (requires admin permission) */ export const DELETE = withMcpAuth('admin')( - async (request: NextRequest, { userId, workspaceId, requestId }) => { + async (request: NextRequest, { userId, userName, userEmail, workspaceId, requestId }) => { try { const { searchParams } = new URL(request.url) const serverId = searchParams.get('serverId') @@ -225,6 +227,8 @@ export const DELETE = withMcpAuth('admin')( recordAudit({ workspaceId, actorId: userId, + actorName: userName, + actorEmail: userEmail, action: AuditAction.MCP_SERVER_REMOVED, resourceType: AuditResourceType.MCP_SERVER, resourceId: serverId!, diff --git a/apps/sim/app/api/mcp/workflow-servers/[id]/route.ts b/apps/sim/app/api/mcp/workflow-servers/[id]/route.ts index 8145c4d58..4890dbc8f 100644 --- a/apps/sim/app/api/mcp/workflow-servers/[id]/route.ts +++ b/apps/sim/app/api/mcp/workflow-servers/[id]/route.ts @@ -72,7 +72,11 @@ export const GET = withMcpAuth('read')( * PATCH - Update a workflow MCP server */ export const PATCH = withMcpAuth('write')( - async (request: NextRequest, { userId, workspaceId, requestId }, { params }) => { + async ( + request: NextRequest, + { userId, userName, userEmail, workspaceId, requestId }, + { params } + ) => { try { const { id: serverId } = await params const body = getParsedBody(request) || (await request.json()) @@ -116,6 +120,8 @@ export const PATCH = withMcpAuth('write')( recordAudit({ workspaceId, actorId: userId, + actorName: userName, + actorEmail: userEmail, action: AuditAction.MCP_SERVER_UPDATED, resourceType: AuditResourceType.MCP_SERVER, resourceId: serverId, @@ -140,7 +146,11 @@ export const PATCH = withMcpAuth('write')( * DELETE - Delete a workflow MCP server and all its tools */ export const DELETE = withMcpAuth('admin')( - async (request: NextRequest, { userId, workspaceId, requestId }, { params }) => { + async ( + request: NextRequest, + { userId, userName, userEmail, workspaceId, requestId }, + { params } + ) => { try { const { id: serverId } = await params @@ -164,6 +174,8 @@ export const DELETE = withMcpAuth('admin')( recordAudit({ workspaceId, actorId: userId, + actorName: userName, + actorEmail: userEmail, action: AuditAction.MCP_SERVER_REMOVED, resourceType: AuditResourceType.MCP_SERVER, resourceId: serverId, diff --git a/apps/sim/app/api/mcp/workflow-servers/[id]/tools/[toolId]/route.ts b/apps/sim/app/api/mcp/workflow-servers/[id]/tools/[toolId]/route.ts index 89d4e8dea..9a2d374ed 100644 --- a/apps/sim/app/api/mcp/workflow-servers/[id]/tools/[toolId]/route.ts +++ b/apps/sim/app/api/mcp/workflow-servers/[id]/tools/[toolId]/route.ts @@ -66,7 +66,11 @@ export const GET = withMcpAuth('read')( * PATCH - Update a tool's configuration */ export const PATCH = withMcpAuth('write')( - async (request: NextRequest, { userId, workspaceId, requestId }, { params }) => { + async ( + request: NextRequest, + { userId, userName, userEmail, workspaceId, requestId }, + { params } + ) => { try { const { id: serverId, toolId } = await params const body = getParsedBody(request) || (await request.json()) @@ -122,6 +126,8 @@ export const PATCH = withMcpAuth('write')( recordAudit({ workspaceId, actorId: userId, + actorName: userName, + actorEmail: userEmail, action: AuditAction.MCP_SERVER_UPDATED, resourceType: AuditResourceType.MCP_SERVER, resourceId: serverId, @@ -146,7 +152,11 @@ export const PATCH = withMcpAuth('write')( * DELETE - Remove a tool from an MCP server */ export const DELETE = withMcpAuth('write')( - async (request: NextRequest, { userId, workspaceId, requestId }, { params }) => { + async ( + request: NextRequest, + { userId, userName, userEmail, workspaceId, requestId }, + { params } + ) => { try { const { id: serverId, toolId } = await params @@ -180,6 +190,8 @@ export const DELETE = withMcpAuth('write')( recordAudit({ workspaceId, actorId: userId, + actorName: userName, + actorEmail: userEmail, action: AuditAction.MCP_SERVER_UPDATED, resourceType: AuditResourceType.MCP_SERVER, resourceId: serverId, diff --git a/apps/sim/app/api/mcp/workflow-servers/[id]/tools/route.ts b/apps/sim/app/api/mcp/workflow-servers/[id]/tools/route.ts index 4619b7c89..bdd9139f9 100644 --- a/apps/sim/app/api/mcp/workflow-servers/[id]/tools/route.ts +++ b/apps/sim/app/api/mcp/workflow-servers/[id]/tools/route.ts @@ -77,7 +77,11 @@ export const GET = withMcpAuth('read')( * POST - Add a workflow as a tool to an MCP server */ export const POST = withMcpAuth('write')( - async (request: NextRequest, { userId, workspaceId, requestId }, { params }) => { + async ( + request: NextRequest, + { userId, userName, userEmail, workspaceId, requestId }, + { params } + ) => { try { const { id: serverId } = await params const body = getParsedBody(request) || (await request.json()) @@ -201,6 +205,8 @@ export const POST = withMcpAuth('write')( recordAudit({ workspaceId, actorId: userId, + actorName: userName, + actorEmail: userEmail, action: AuditAction.MCP_SERVER_UPDATED, resourceType: AuditResourceType.MCP_SERVER, resourceId: serverId, diff --git a/apps/sim/app/api/mcp/workflow-servers/route.ts b/apps/sim/app/api/mcp/workflow-servers/route.ts index 6ab7ef5f9..275159413 100644 --- a/apps/sim/app/api/mcp/workflow-servers/route.ts +++ b/apps/sim/app/api/mcp/workflow-servers/route.ts @@ -86,7 +86,7 @@ export const GET = withMcpAuth('read')( * POST - Create a new workflow MCP server */ export const POST = withMcpAuth('write')( - async (request: NextRequest, { userId, workspaceId, requestId }) => { + async (request: NextRequest, { userId, userName, userEmail, workspaceId, requestId }) => { try { const body = getParsedBody(request) || (await request.json()) @@ -192,6 +192,8 @@ export const POST = withMcpAuth('write')( recordAudit({ workspaceId, actorId: userId, + actorName: userName, + actorEmail: userEmail, action: AuditAction.MCP_SERVER_ADDED, resourceType: AuditResourceType.MCP_SERVER, resourceId: serverId, diff --git a/apps/sim/app/api/templates/[id]/route.ts b/apps/sim/app/api/templates/[id]/route.ts index fbc3005d9..b7a0425ff 100644 --- a/apps/sim/app/api/templates/[id]/route.ts +++ b/apps/sim/app/api/templates/[id]/route.ts @@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger' import { eq, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' +import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' import { @@ -247,6 +248,18 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ logger.info(`[${requestId}] Successfully updated template: ${id}`) + recordAudit({ + actorId: session.user.id, + actorName: session.user.name, + actorEmail: session.user.email, + action: AuditAction.TEMPLATE_UPDATED, + resourceType: AuditResourceType.TEMPLATE, + resourceId: id, + resourceName: name ?? template.name, + description: `Updated template "${name ?? template.name}"`, + request, + }) + return NextResponse.json({ data: updatedTemplate[0], message: 'Template updated successfully', @@ -300,6 +313,19 @@ export async function DELETE( await db.delete(templates).where(eq(templates.id, id)) logger.info(`[${requestId}] Deleted template: ${id}`) + + recordAudit({ + actorId: session.user.id, + actorName: session.user.name, + actorEmail: session.user.email, + action: AuditAction.TEMPLATE_DELETED, + resourceType: AuditResourceType.TEMPLATE, + resourceId: id, + resourceName: template.name, + description: `Deleted template "${template.name}"`, + request, + }) + return NextResponse.json({ success: true }) } catch (error: any) { logger.error(`[${requestId}] Error deleting template: ${id}`, error) diff --git a/apps/sim/app/api/templates/route.ts b/apps/sim/app/api/templates/route.ts index 2985684e4..74d84be2b 100644 --- a/apps/sim/app/api/templates/route.ts +++ b/apps/sim/app/api/templates/route.ts @@ -11,6 +11,7 @@ import { and, desc, eq, ilike, or, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { v4 as uuidv4 } from 'uuid' import { z } from 'zod' +import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' import { verifyEffectiveSuperUser } from '@/lib/templates/permissions' @@ -285,6 +286,18 @@ export async function POST(request: NextRequest) { logger.info(`[${requestId}] Successfully created template: ${templateId}`) + recordAudit({ + actorId: session.user.id, + actorName: session.user.name, + actorEmail: session.user.email, + action: AuditAction.TEMPLATE_CREATED, + resourceType: AuditResourceType.TEMPLATE, + resourceId: templateId, + resourceName: data.name, + description: `Created template "${data.name}"`, + request, + }) + return NextResponse.json( { id: templateId, diff --git a/apps/sim/app/api/webhooks/[id]/route.ts b/apps/sim/app/api/webhooks/[id]/route.ts index d605c8e49..447527236 100644 --- a/apps/sim/app/api/webhooks/[id]/route.ts +++ b/apps/sim/app/api/webhooks/[id]/route.ts @@ -265,6 +265,8 @@ export async function DELETE( recordAudit({ workspaceId: webhookData.workflow.workspaceId || null, actorId: userId, + actorName: auth.userName, + actorEmail: auth.userEmail, action: AuditAction.WEBHOOK_DELETED, resourceType: AuditResourceType.WEBHOOK, resourceId: id, diff --git a/apps/sim/app/api/webhooks/route.ts b/apps/sim/app/api/webhooks/route.ts index 4221ce52d..27a8b866a 100644 --- a/apps/sim/app/api/webhooks/route.ts +++ b/apps/sim/app/api/webhooks/route.ts @@ -146,7 +146,8 @@ export async function GET(request: NextRequest) { // Create or Update a webhook export async function POST(request: NextRequest) { const requestId = generateRequestId() - const userId = (await getSession())?.user?.id + const session = await getSession() + const userId = session?.user?.id if (!userId) { logger.warn(`[${requestId}] Unauthorized webhook creation attempt`) @@ -683,6 +684,8 @@ export async function POST(request: NextRequest) { recordAudit({ workspaceId: workflowRecord.workspaceId || null, actorId: userId, + actorName: session?.user?.name ?? undefined, + actorEmail: session?.user?.email ?? undefined, action: AuditAction.WEBHOOK_CREATED, resourceType: AuditResourceType.WEBHOOK, resourceId: savedWebhook.id, diff --git a/apps/sim/app/api/workflows/[id]/deployments/[version]/route.ts b/apps/sim/app/api/workflows/[id]/deployments/[version]/route.ts index 3af21e758..56802840e 100644 --- a/apps/sim/app/api/workflows/[id]/deployments/[version]/route.ts +++ b/apps/sim/app/api/workflows/[id]/deployments/[version]/route.ts @@ -3,6 +3,7 @@ import { createLogger } from '@sim/logger' import { and, eq } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { z } from 'zod' +import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { generateRequestId } from '@/lib/core/utils/request' import { syncMcpToolsForWorkflow } from '@/lib/mcp/workflow-mcp-sync' import { restorePreviousVersionWebhooks, saveTriggerWebhooksForDeploy } from '@/lib/webhooks/deploy' @@ -297,6 +298,19 @@ export async function PATCH( } } + recordAudit({ + workspaceId: workflowData?.workspaceId, + actorId: actorUserId, + actorName: session?.user?.name, + actorEmail: session?.user?.email, + action: AuditAction.WORKFLOW_DEPLOYMENT_ACTIVATED, + resourceType: AuditResourceType.WORKFLOW, + resourceId: id, + description: `Activated deployment version ${versionNum}`, + metadata: { version: versionNum }, + request, + }) + return createSuccessResponse({ success: true, deployedAt: result.deployedAt, diff --git a/apps/sim/app/api/workflows/[id]/duplicate/route.ts b/apps/sim/app/api/workflows/[id]/duplicate/route.ts index 5a43359ce..ad37410c9 100644 --- a/apps/sim/app/api/workflows/[id]/duplicate/route.ts +++ b/apps/sim/app/api/workflows/[id]/duplicate/route.ts @@ -65,6 +65,8 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: recordAudit({ workspaceId: workspaceId || null, actorId: userId, + actorName: auth.userName, + actorEmail: auth.userEmail, action: AuditAction.WORKFLOW_DUPLICATED, resourceType: AuditResourceType.WORKFLOW, resourceId: result.id, diff --git a/apps/sim/app/api/workflows/[id]/route.ts b/apps/sim/app/api/workflows/[id]/route.ts index 406216fa5..170dc83fa 100644 --- a/apps/sim/app/api/workflows/[id]/route.ts +++ b/apps/sim/app/api/workflows/[id]/route.ts @@ -340,6 +340,8 @@ export async function DELETE( recordAudit({ workspaceId: workflowData.workspaceId || null, actorId: userId, + actorName: auth.userName, + actorEmail: auth.userEmail, action: AuditAction.WORKFLOW_DELETED, resourceType: AuditResourceType.WORKFLOW, resourceId: workflowId, diff --git a/apps/sim/app/api/workflows/[id]/variables/route.ts b/apps/sim/app/api/workflows/[id]/variables/route.ts index 75990d32b..74824ddca 100644 --- a/apps/sim/app/api/workflows/[id]/variables/route.ts +++ b/apps/sim/app/api/workflows/[id]/variables/route.ts @@ -83,6 +83,8 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: recordAudit({ workspaceId: workflowData.workspaceId ?? null, actorId: userId, + actorName: auth.userName, + actorEmail: auth.userEmail, action: AuditAction.WORKFLOW_VARIABLES_UPDATED, resourceType: AuditResourceType.WORKFLOW, resourceId: workflowId, diff --git a/apps/sim/app/api/workflows/route.ts b/apps/sim/app/api/workflows/route.ts index 641fbb12b..003c9fc63 100644 --- a/apps/sim/app/api/workflows/route.ts +++ b/apps/sim/app/api/workflows/route.ts @@ -192,6 +192,8 @@ export async function POST(req: NextRequest) { recordAudit({ workspaceId, actorId: userId, + actorName: auth.userName, + actorEmail: auth.userEmail, action: AuditAction.WORKFLOW_CREATED, resourceType: AuditResourceType.WORKFLOW, resourceId: workflowId, diff --git a/apps/sim/lib/audit/log.ts b/apps/sim/lib/audit/log.ts index a3bed1d23..f3c50041e 100644 --- a/apps/sim/lib/audit/log.ts +++ b/apps/sim/lib/audit/log.ts @@ -24,12 +24,18 @@ export const AuditAction = { CHAT_UPDATED: 'chat.updated', CHAT_DELETED: 'chat.deleted', + // 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', // Documents @@ -81,6 +87,9 @@ export const AuditAction = { // OAuth OAUTH_DISCONNECTED: 'oauth.disconnected', + // Password + PASSWORD_RESET: 'password.reset', + // Organizations ORGANIZATION_CREATED: 'organization.created', ORGANIZATION_UPDATED: 'organization.updated', @@ -103,6 +112,11 @@ export const AuditAction = { // Schedules SCHEDULE_UPDATED: 'schedule.updated', + // Templates + TEMPLATE_CREATED: 'template.created', + TEMPLATE_UPDATED: 'template.updated', + TEMPLATE_DELETED: 'template.deleted', + // Webhooks WEBHOOK_CREATED: 'webhook.created', WEBHOOK_DELETED: 'webhook.deleted', @@ -113,6 +127,7 @@ export const AuditAction = { WORKFLOW_DEPLOYED: 'workflow.deployed', WORKFLOW_UNDEPLOYED: 'workflow.undeployed', WORKFLOW_DUPLICATED: 'workflow.duplicated', + WORKFLOW_DEPLOYMENT_ACTIVATED: 'workflow.deployment_activated', WORKFLOW_DEPLOYMENT_REVERTED: 'workflow.deployment_reverted', WORKFLOW_VARIABLES_UPDATED: 'workflow.variables_updated', @@ -129,6 +144,7 @@ export type AuditActionType = (typeof AuditAction)[keyof typeof AuditAction] */ export const AuditResourceType = { API_KEY: 'api_key', + BILLING: 'billing', BYOK_KEY: 'byok_key', CHAT: 'chat', CREDENTIAL_SET: 'credential_set', @@ -142,8 +158,10 @@ export const AuditResourceType = { NOTIFICATION: 'notification', OAUTH: 'oauth', ORGANIZATION: 'organization', + PASSWORD: 'password', PERMISSION_GROUP: 'permission_group', SCHEDULE: 'schedule', + TEMPLATE: 'template', WEBHOOK: 'webhook', WORKFLOW: 'workflow', WORKSPACE: 'workspace', diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts index be5b961f0..4bfc80038 100644 --- a/apps/sim/lib/auth/auth.ts +++ b/apps/sim/lib/auth/auth.ts @@ -483,6 +483,17 @@ export const auth = betterAuth({ throw new Error(`Failed to send reset password email: ${result.message}`) } }, + onPasswordReset: async ({ user: resetUser }) => { + const { AuditAction, AuditResourceType, recordAudit } = await import('@/lib/audit/log') + recordAudit({ + actorId: resetUser.id, + actorName: resetUser.name, + actorEmail: resetUser.email, + action: AuditAction.PASSWORD_RESET, + resourceType: AuditResourceType.PASSWORD, + description: 'Password reset completed', + }) + }, }, hooks: { before: createAuthMiddleware(async (ctx) => { diff --git a/apps/sim/lib/auth/hybrid.ts b/apps/sim/lib/auth/hybrid.ts index 1c34286f6..0cc00e72e 100644 --- a/apps/sim/lib/auth/hybrid.ts +++ b/apps/sim/lib/auth/hybrid.ts @@ -9,6 +9,8 @@ const logger = createLogger('HybridAuth') export interface AuthResult { success: boolean userId?: string + userName?: string | null + userEmail?: string | null authType?: 'session' | 'api_key' | 'internal_jwt' apiKeyType?: 'personal' | 'workspace' error?: string @@ -142,6 +144,8 @@ export async function checkSessionOrInternalAuth( return { success: true, userId: session.user.id, + userName: session.user.name, + userEmail: session.user.email, authType: 'session', } } @@ -189,6 +193,8 @@ export async function checkHybridAuth( return { success: true, userId: session.user.id, + userName: session.user.name, + userEmail: session.user.email, authType: 'session', } } diff --git a/apps/sim/lib/mcp/middleware.ts b/apps/sim/lib/mcp/middleware.ts index f95e4eac7..b342f1cef 100644 --- a/apps/sim/lib/mcp/middleware.ts +++ b/apps/sim/lib/mcp/middleware.ts @@ -11,6 +11,8 @@ export type McpPermissionLevel = 'read' | 'write' | 'admin' export interface McpAuthContext { userId: string + userName?: string | null + userEmail?: string | null workspaceId: string requestId: string } @@ -114,6 +116,8 @@ async function validateMcpAuth( success: true, context: { userId: auth.userId, + userName: auth.userName, + userEmail: auth.userEmail, workspaceId, requestId, }, diff --git a/packages/testing/src/mocks/audit.mock.ts b/packages/testing/src/mocks/audit.mock.ts index 13d28a732..d0f913c7f 100644 --- a/packages/testing/src/mocks/audit.mock.ts +++ b/packages/testing/src/mocks/audit.mock.ts @@ -22,11 +22,15 @@ export const auditMock = { CHAT_DEPLOYED: 'chat.deployed', CHAT_UPDATED: 'chat.updated', CHAT_DELETED: 'chat.deleted', + 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', DOCUMENT_UPLOADED: 'document.uploaded', DOCUMENT_UPDATED: 'document.updated', @@ -55,6 +59,7 @@ export const auditMock = { NOTIFICATION_UPDATED: 'notification.updated', NOTIFICATION_DELETED: 'notification.deleted', OAUTH_DISCONNECTED: 'oauth.disconnected', + PASSWORD_RESET: 'password.reset', ORGANIZATION_CREATED: 'organization.created', ORGANIZATION_UPDATED: 'organization.updated', ORG_MEMBER_ADDED: 'org_member.added', @@ -71,6 +76,9 @@ export const auditMock = { PERMISSION_GROUP_MEMBER_ADDED: 'permission_group_member.added', PERMISSION_GROUP_MEMBER_REMOVED: 'permission_group_member.removed', SCHEDULE_UPDATED: 'schedule.updated', + TEMPLATE_CREATED: 'template.created', + TEMPLATE_UPDATED: 'template.updated', + TEMPLATE_DELETED: 'template.deleted', WEBHOOK_CREATED: 'webhook.created', WEBHOOK_DELETED: 'webhook.deleted', WORKFLOW_CREATED: 'workflow.created', @@ -78,6 +86,7 @@ export const auditMock = { WORKFLOW_DEPLOYED: 'workflow.deployed', WORKFLOW_UNDEPLOYED: 'workflow.undeployed', WORKFLOW_DUPLICATED: 'workflow.duplicated', + WORKFLOW_DEPLOYMENT_ACTIVATED: 'workflow.deployment_activated', WORKFLOW_DEPLOYMENT_REVERTED: 'workflow.deployment_reverted', WORKFLOW_VARIABLES_UPDATED: 'workflow.variables_updated', WORKSPACE_CREATED: 'workspace.created', @@ -86,6 +95,7 @@ export const auditMock = { }, AuditResourceType: { API_KEY: 'api_key', + BILLING: 'billing', BYOK_KEY: 'byok_key', CHAT: 'chat', CREDENTIAL_SET: 'credential_set', @@ -99,8 +109,10 @@ export const auditMock = { NOTIFICATION: 'notification', OAUTH: 'oauth', ORGANIZATION: 'organization', + PASSWORD: 'password', PERMISSION_GROUP: 'permission_group', SCHEDULE: 'schedule', + TEMPLATE: 'template', WEBHOOK: 'webhook', WORKFLOW: 'workflow', WORKSPACE: 'workspace', From e3964624ac30fff924ad80e7ca8908904ceb9752 Mon Sep 17 00:00:00 2001 From: Emir Karabeg <78010029+emir-karabeg@users.noreply.github.com> Date: Wed, 18 Feb 2026 12:01:47 -0800 Subject: [PATCH 09/10] feat(sub): hide usage limits and seats info from enterprise members (non-admin) (#3243) - Add isEnterpriseMember and canViewUsageInfo flags to subscription permissions - Hide UsageHeader, CreditBalance, billing date, and usage notifications from enterprise members - Show only plan name in subscription tab for enterprise members (non-admin) - Hide usage indicator details (amount, progress pills) from enterprise members - Team tab already hidden via requiresTeam check in settings modal Closes #6882 Co-authored-by: Cursor Agent Co-authored-by: Emir Karabeg --- .../subscription/subscription-permissions.ts | 7 + .../components/subscription/subscription.tsx | 155 ++++++++++-------- .../usage-indicator/usage-indicator.tsx | 13 ++ 3 files changed, 104 insertions(+), 71 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/subscription-permissions.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/subscription-permissions.ts index 1be513cbc..9d901c8e7 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/subscription-permissions.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/subscription-permissions.ts @@ -7,6 +7,8 @@ export interface SubscriptionPermissions { canCancelSubscription: boolean showTeamMemberView: boolean showUpgradePlans: boolean + isEnterpriseMember: boolean + canViewUsageInfo: boolean } export interface SubscriptionState { @@ -31,6 +33,9 @@ export function getSubscriptionPermissions( const { isFree, isPro, isTeam, isEnterprise, isPaid } = subscription const { isTeamAdmin } = userRole + const isEnterpriseMember = isEnterprise && !isTeamAdmin + const canViewUsageInfo = !isEnterpriseMember + return { canUpgradeToPro: isFree, canUpgradeToTeam: isFree || (isPro && !isTeam), @@ -40,6 +45,8 @@ export function getSubscriptionPermissions( canCancelSubscription: isPaid && !isEnterprise && !(isTeam && !isTeamAdmin), // Team members can't cancel showTeamMemberView: isTeam && !isTeamAdmin, showUpgradePlans: isFree || (isPro && !isTeam) || (isTeam && isTeamAdmin), // Free users, Pro users, Team owners see plans + isEnterpriseMember, + canViewUsageInfo, } } diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/subscription.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/subscription.tsx index 5ccdde897..3132ce95e 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/subscription.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/subscription.tsx @@ -300,12 +300,16 @@ export function Subscription() { ) const showBadge = - (permissions.canEditUsageLimit && !permissions.showTeamMemberView) || - permissions.showTeamMemberView || - subscription.isEnterprise || - isBlocked + !permissions.isEnterpriseMember && + ((permissions.canEditUsageLimit && !permissions.showTeamMemberView) || + permissions.showTeamMemberView || + subscription.isEnterprise || + isBlocked) const getBadgeConfig = (): { text: string; variant: 'blue-secondary' | 'red' } => { + if (permissions.isEnterpriseMember) { + return { text: '', variant: 'blue-secondary' } + } if (permissions.showTeamMemberView || subscription.isEnterprise) { return { text: `${subscription.seats} seats`, variant: 'blue-secondary' } } @@ -443,67 +447,75 @@ export function Subscription() { return (
- {/* Current Plan & Usage Overview */} - { - logger.info('Usage limit updated') - }} - /> - ) : undefined - } - /> + {/* Current Plan & Usage Overview - hidden from enterprise members (non-admin) */} + {permissions.canViewUsageInfo ? ( + { + logger.info('Usage limit updated') + }} + /> + ) : undefined + } + /> + ) : ( +
+ + {formatPlanName(subscription.plan)} + +
+ )} {/* Upgrade Plans */} {permissions.showUpgradePlans && ( @@ -539,8 +551,8 @@ export function Subscription() {
)} - {/* Credit Balance */} - {subscription.isPaid && ( + {/* Credit Balance - hidden from enterprise members (non-admin) */} + {subscription.isPaid && permissions.canViewUsageInfo && ( refetchSubscription()} /> )} - {/* Next Billing Date - hidden from team members */} + {/* Next Billing Date - hidden from team members and enterprise members (non-admin) */} {subscription.isPaid && subscriptionData?.data?.periodEnd && - !permissions.showTeamMemberView && ( + !permissions.showTeamMemberView && + !permissions.isEnterpriseMember && (
@@ -566,8 +579,8 @@ export function Subscription() {
)} - {/* Usage notifications */} - {subscription.isPaid && } + {/* Usage notifications - hidden from enterprise members (non-admin) */} + {subscription.isPaid && permissions.canViewUsageInfo && } {/* Cancel Subscription */} {permissions.canCancelSubscription && ( diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/usage-indicator/usage-indicator.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/usage-indicator/usage-indicator.tsx index 742865fb7..0fda657f2 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/usage-indicator/usage-indicator.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/usage-indicator/usage-indicator.tsx @@ -285,6 +285,7 @@ export function UsageIndicator({ onClick }: UsageIndicatorProps) { const isPro = planType === 'pro' const isTeam = planType === 'team' const isEnterprise = planType === 'enterprise' + const isEnterpriseMember = isEnterprise && !userCanManageBilling const handleUpgradeToPro = useCallback(async () => { try { @@ -463,6 +464,18 @@ export function UsageIndicator({ onClick }: UsageIndicatorProps) { } } + if (isEnterpriseMember) { + return ( +
+
+ + {PLAN_NAMES[planType]} + +
+
+ ) + } + return ( <>
Date: Wed, 18 Feb 2026 12:08:03 -0800 Subject: [PATCH 10/10] fix(normalization): update allowed integrations checks to be fully lowercase (#3248) --- .../settings-modal/components/integrations/integrations.tsx | 2 +- apps/sim/hooks/use-permission-config.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/integrations/integrations.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/integrations/integrations.tsx index 6f7fb5397..9f02c1cdf 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/integrations/integrations.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/integrations/integrations.tsx @@ -227,7 +227,7 @@ export function Integrations({ onOpenChange, registerCloseHandler }: Integration (acc, service) => { if ( permissionConfig.allowedIntegrations !== null && - !permissionConfig.allowedIntegrations.includes(service.id.replace(/-/g, '_')) + !permissionConfig.allowedIntegrations.includes(service.id.replace(/-/g, '_').toLowerCase()) ) { return acc } diff --git a/apps/sim/hooks/use-permission-config.ts b/apps/sim/hooks/use-permission-config.ts index 32c16e227..5cdf30016 100644 --- a/apps/sim/hooks/use-permission-config.ts +++ b/apps/sim/hooks/use-permission-config.ts @@ -44,7 +44,7 @@ function useAllowedIntegrationsFromEnv() { */ function intersectAllowlists(a: string[] | null, b: string[] | null): string[] | null { if (a === null) return b - if (b === null) return a + if (b === null) return a.map((i) => i.toLowerCase()) return a.map((i) => i.toLowerCase()).filter((i) => b.includes(i)) }